mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-16 18:02:09 +00:00
Compare commits
16 Commits
feat/fix-u
...
service-ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11daefad6 | ||
|
|
1606548fdb | ||
|
|
1f53069495 | ||
|
|
881ac92165 | ||
|
|
0201e8055b | ||
|
|
b0b642d6be | ||
|
|
3ceedbb050 | ||
|
|
38230e820d | ||
|
|
222ccea1d6 | ||
|
|
f0e459da0e | ||
|
|
197a7518bb | ||
|
|
c00c4248ff | ||
|
|
35513b96ee | ||
|
|
65ef147e81 | ||
|
|
c16a622306 | ||
|
|
f728d9217f |
15
.github/CODEOWNERS
vendored
15
.github/CODEOWNERS
vendored
@@ -1,6 +1,8 @@
|
||||
# CODEOWNERS info: https://help.github.com/en/articles/about-code-owners
|
||||
|
||||
# Owners are automatically requested for review for PRs that changes code that they own.
|
||||
# Owners are automatically requested for review for PRs that changes code
|
||||
|
||||
# that they own.
|
||||
|
||||
/frontend/ @SigNoz/frontend-maintainers
|
||||
|
||||
@@ -9,10 +11,8 @@
|
||||
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
|
||||
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
|
||||
|
||||
# CI
|
||||
/deploy/ @therealpandey
|
||||
.github @therealpandey
|
||||
go.mod @therealpandey
|
||||
/deploy/ @SigNoz/devops
|
||||
.github @SigNoz/devops
|
||||
|
||||
# Scaffold Owners
|
||||
|
||||
@@ -127,15 +127,12 @@ go.mod @therealpandey
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
||||
|
||||
# Dashboard Widget Page
|
||||
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Public Dashboard Page
|
||||
|
||||
|
||||
60
.github/workflows/mergequeueci.yaml
vendored
60
.github/workflows/mergequeueci.yaml
vendored
@@ -1,60 +0,0 @@
|
||||
name: mergequeueci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- dequeued
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.merged == false
|
||||
steps:
|
||||
- name: alert
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_MERGE_QUEUE_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"text": ":x: PR removed from merge queue",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":x: PR Removed from Merge Queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*<${{ github.event.pull_request.html_url }}|PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}>*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Author*\n@${{ github.event.pull_request.user.login }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
- name: comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
run: |
|
||||
gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments \
|
||||
-f body="> :x: **PR removed from merge queue**
|
||||
>
|
||||
> @$PR_AUTHOR your PR was removed from the merge queue. Fix the issue and re-queue when ready."
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -74,8 +73,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
},
|
||||
signoz.NewSQLStoreProviderFactories(),
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing, userGetter user.Getter) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, userGetter)
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
|
||||
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
|
||||
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
|
||||
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
@@ -96,7 +95,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
},
|
||||
sqlstoreFactories,
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing, userGetter user.Getter) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -107,7 +106,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, userGetter)
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -220,13 +220,6 @@ components:
|
||||
- additions
|
||||
- deletions
|
||||
type: object
|
||||
AuthtypesPatchableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- description
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
@@ -243,15 +236,6 @@ components:
|
||||
password:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesPostableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
AuthtypesPostableRotateToken:
|
||||
properties:
|
||||
refreshToken:
|
||||
@@ -267,31 +251,6 @@ components:
|
||||
- name
|
||||
- type
|
||||
type: object
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
@@ -1763,6 +1722,47 @@ components:
|
||||
- status
|
||||
- error
|
||||
type: object
|
||||
RoletypesPatchableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- description
|
||||
type: object
|
||||
RoletypesPostableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
RoletypesRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
ServiceaccounttypesFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -1984,6 +1984,52 @@ components:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
TypesChangePasswordRequest:
|
||||
properties:
|
||||
newPassword:
|
||||
type: string
|
||||
oldPassword:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
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
|
||||
TypesGettableGlobalConfig:
|
||||
properties:
|
||||
external_url:
|
||||
@@ -1998,6 +2044,31 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesInvite:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
inviteLink:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesOrganization:
|
||||
properties:
|
||||
alias:
|
||||
@@ -2020,78 +2091,7 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
UsertypesChangePasswordRequest:
|
||||
properties:
|
||||
newPassword:
|
||||
type: string
|
||||
oldPassword:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
type: object
|
||||
UsertypesGettableAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
createdByUser:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
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/UsertypesUser'
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
UsertypesInvite:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
inviteLink:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
UsertypesPostableAPIKey:
|
||||
TypesPostableAPIKey:
|
||||
properties:
|
||||
expiresInDays:
|
||||
format: int64
|
||||
@@ -2101,7 +2101,7 @@ components:
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
UsertypesPostableAcceptInvite:
|
||||
TypesPostableAcceptInvite:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
@@ -2112,16 +2112,16 @@ components:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
UsertypesPostableBulkInviteRequest:
|
||||
TypesPostableBulkInviteRequest:
|
||||
properties:
|
||||
invites:
|
||||
items:
|
||||
$ref: '#/components/schemas/UsertypesPostableInvite'
|
||||
$ref: '#/components/schemas/TypesPostableInvite'
|
||||
type: array
|
||||
required:
|
||||
- invites
|
||||
type: object
|
||||
UsertypesPostableForgotPassword:
|
||||
TypesPostableForgotPassword:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
@@ -2133,7 +2133,7 @@ components:
|
||||
- orgId
|
||||
- email
|
||||
type: object
|
||||
UsertypesPostableInvite:
|
||||
TypesPostableInvite:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
@@ -2144,14 +2144,14 @@ components:
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
UsertypesPostableResetPassword:
|
||||
TypesPostableResetPassword:
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
UsertypesResetPasswordToken:
|
||||
TypesResetPasswordToken:
|
||||
properties:
|
||||
expiresAt:
|
||||
format: date-time
|
||||
@@ -2165,7 +2165,7 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
UsertypesStorableAPIKey:
|
||||
TypesStorableAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -2192,7 +2192,7 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
UsertypesUser:
|
||||
TypesUser:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -2392,7 +2392,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesChangePasswordRequest'
|
||||
$ref: '#/components/schemas/TypesChangePasswordRequest'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
@@ -3197,7 +3197,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesResetPasswordToken'
|
||||
$ref: '#/components/schemas/TypesResetPasswordToken'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3302,7 +3302,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/UsertypesInvite'
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -3345,7 +3345,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesPostableInvite'
|
||||
$ref: '#/components/schemas/TypesPostableInvite'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -3353,7 +3353,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesInvite'
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3469,7 +3469,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesInvite'
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3507,7 +3507,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesPostableAcceptInvite'
|
||||
$ref: '#/components/schemas/TypesPostableAcceptInvite'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -3515,7 +3515,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3553,7 +3553,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesPostableBulkInviteRequest'
|
||||
$ref: '#/components/schemas/TypesPostableBulkInviteRequest'
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
@@ -3878,7 +3878,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/UsertypesGettableAPIKey'
|
||||
$ref: '#/components/schemas/TypesGettableAPIKey'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -3921,7 +3921,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesPostableAPIKey'
|
||||
$ref: '#/components/schemas/TypesPostableAPIKey'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -3929,7 +3929,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesGettableAPIKey'
|
||||
$ref: '#/components/schemas/TypesGettableAPIKey'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -4035,7 +4035,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesStorableAPIKey'
|
||||
$ref: '#/components/schemas/TypesStorableAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
@@ -4196,7 +4196,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesPostableResetPassword'
|
||||
$ref: '#/components/schemas/TypesPostableResetPassword'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
@@ -4234,7 +4234,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/RoletypesRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -4277,7 +4277,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableRole'
|
||||
$ref: '#/components/schemas/RoletypesPostableRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -4422,7 +4422,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/RoletypesRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -4470,7 +4470,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
$ref: '#/components/schemas/RoletypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
@@ -5271,7 +5271,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -5369,7 +5369,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -5423,7 +5423,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -5431,7 +5431,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -5489,7 +5489,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UsertypesUser'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -5692,7 +5692,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UsertypesPostableForgotPassword'
|
||||
$ref: '#/components/schemas/TypesPostableForgotPassword'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
|
||||
@@ -22,7 +23,7 @@ type provider struct {
|
||||
pkgAuthzService authz.AuthZ
|
||||
openfgaServer *openfgaserver.Server
|
||||
licensing licensing.Licensing
|
||||
store authtypes.RoleStore
|
||||
store roletypes.Store
|
||||
registry []authz.RegisterTypeable
|
||||
}
|
||||
|
||||
@@ -81,23 +82,23 @@ func (provider *provider) Write(ctx context.Context, additions []*openfgav1.Tupl
|
||||
return provider.openfgaServer.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) {
|
||||
return provider.pkgAuthzService.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*roletypes.Role, error) {
|
||||
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*authtypes.Role, error) {
|
||||
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) {
|
||||
return provider.pkgAuthzService.List(ctx, orgID)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
|
||||
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*roletypes.Role, error) {
|
||||
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*authtypes.Role, error) {
|
||||
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.Role, error) {
|
||||
return provider.pkgAuthzService.ListByOrgIDAndIDs(ctx, orgID, ids)
|
||||
}
|
||||
|
||||
@@ -113,7 +114,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
|
||||
return provider.pkgAuthzService.Revoke(ctx, orgID, names, subject)
|
||||
}
|
||||
|
||||
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*authtypes.Role) error {
|
||||
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*roletypes.Role) error {
|
||||
return provider.pkgAuthzService.CreateManagedRoles(ctx, orgID, managedRoles)
|
||||
}
|
||||
|
||||
@@ -135,16 +136,16 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
|
||||
return provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) (*roletypes.Role, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -158,10 +159,10 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return authtypes.NewRoleFromStorableRole(existingRole), nil
|
||||
return roletypes.NewRoleFromStorableRole(existingRole), nil
|
||||
}
|
||||
|
||||
err = provider.store.Create(ctx, authtypes.NewStorableRoleFromRole(role))
|
||||
err = provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -216,13 +217,13 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, authtypes.NewStorableRoleFromRole(role))
|
||||
return provider.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
|
||||
}
|
||||
|
||||
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
|
||||
@@ -231,12 +232,12 @@ func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, n
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
|
||||
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
|
||||
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -260,7 +261,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return err
|
||||
}
|
||||
|
||||
role := authtypes.NewRoleFromStorableRole(storableRole)
|
||||
role := roletypes.NewRoleFromStorableRole(storableRole)
|
||||
err = role.ErrIfManaged()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -270,7 +271,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
|
||||
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
|
||||
return []authtypes.Typeable{authtypes.TypeableRole, authtypes.TypeableResourcesRoles}
|
||||
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
|
||||
}
|
||||
|
||||
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
@@ -282,7 +283,7 @@ func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID va
|
||||
adminSubject,
|
||||
authtypes.RelationAssignee,
|
||||
[]authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
|
||||
},
|
||||
orgID,
|
||||
)
|
||||
@@ -297,7 +298,7 @@ func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID va
|
||||
anonymousSubject,
|
||||
authtypes.RelationAssignee,
|
||||
[]authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAnonymousRoleName),
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAnonymousRoleName),
|
||||
},
|
||||
orgID,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -213,7 +214,7 @@ 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 authtypes.LegacyRole, lock bool) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
|
||||
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
|
||||
return map[string][]*authtypes.Transaction{
|
||||
authtypes.SigNozAnonymousRoleName: {
|
||||
roletypes.SigNozAnonymousRoleName: {
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationRead,
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"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/usertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"go.uber.org/zap"
|
||||
@@ -143,10 +143,10 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
zap.String("cloudProvider", cloudProvider),
|
||||
)
|
||||
|
||||
newPAT, err := usertypes.NewStorableAPIKey(
|
||||
newPAT, err := types.NewStorableAPIKey(
|
||||
integrationPATName,
|
||||
integrationUser.ID,
|
||||
authtypes.RoleViewer,
|
||||
types.RoleViewer,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -166,16 +166,16 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) (*usertypes.User, *basemodel.ApiError) {
|
||||
) (*types.User, *basemodel.ApiError) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
cloudIntegrationUser, err := usertypes.NewUser(cloudIntegrationUserName, email, authtypes.RoleViewer, valuer.MustNewUUID(orgId), usertypes.UserStatusActive)
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
}
|
||||
|
||||
password := usertypes.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
|
||||
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
|
||||
if err != nil {
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"react-full-screen": "1.1.1",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 214 KiB |
@@ -15,5 +15,6 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,6 @@
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members"
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
AuthtypesPatchableObjectsDTO,
|
||||
AuthtypesPatchableRoleDTO,
|
||||
AuthtypesPostableRoleDTO,
|
||||
CreateRole201,
|
||||
DeleteRolePathParameters,
|
||||
GetObjects200,
|
||||
@@ -33,6 +31,8 @@ import type {
|
||||
PatchObjectsPathParameters,
|
||||
PatchRolePathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RoletypesPatchableRoleDTO,
|
||||
RoletypesPostableRoleDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -118,14 +118,14 @@ export const invalidateListRoles = async (
|
||||
* @summary Create role
|
||||
*/
|
||||
export const createRole = (
|
||||
authtypesPostableRoleDTO: BodyType<AuthtypesPostableRoleDTO>,
|
||||
roletypesPostableRoleDTO: BodyType<RoletypesPostableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateRole201>({
|
||||
url: `/api/v1/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableRoleDTO,
|
||||
data: roletypesPostableRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -137,13 +137,13 @@ export const getCreateRoleMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
{ data: BodyType<AuthtypesPostableRoleDTO> },
|
||||
{ data: BodyType<RoletypesPostableRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
{ data: BodyType<AuthtypesPostableRoleDTO> },
|
||||
{ data: BodyType<RoletypesPostableRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createRole'];
|
||||
@@ -157,7 +157,7 @@ export const getCreateRoleMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
{ data: BodyType<AuthtypesPostableRoleDTO> }
|
||||
{ data: BodyType<RoletypesPostableRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -170,7 +170,7 @@ export const getCreateRoleMutationOptions = <
|
||||
export type CreateRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createRole>>
|
||||
>;
|
||||
export type CreateRoleMutationBody = BodyType<AuthtypesPostableRoleDTO>;
|
||||
export type CreateRoleMutationBody = BodyType<RoletypesPostableRoleDTO>;
|
||||
export type CreateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -183,13 +183,13 @@ export const useCreateRole = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
{ data: BodyType<AuthtypesPostableRoleDTO> },
|
||||
{ data: BodyType<RoletypesPostableRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
{ data: BodyType<AuthtypesPostableRoleDTO> },
|
||||
{ data: BodyType<RoletypesPostableRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateRoleMutationOptions(options);
|
||||
@@ -370,13 +370,13 @@ export const invalidateGetRole = async (
|
||||
*/
|
||||
export const patchRole = (
|
||||
{ id }: PatchRolePathParameters,
|
||||
authtypesPatchableRoleDTO: BodyType<AuthtypesPatchableRoleDTO>,
|
||||
roletypesPatchableRoleDTO: BodyType<RoletypesPatchableRoleDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPatchableRoleDTO,
|
||||
data: roletypesPatchableRoleDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -389,7 +389,7 @@ export const getPatchRoleMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
data: BodyType<RoletypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -398,7 +398,7 @@ export const getPatchRoleMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
data: BodyType<RoletypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -415,7 +415,7 @@ export const getPatchRoleMutationOptions = <
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
data: BodyType<RoletypesPatchableRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -429,7 +429,7 @@ export const getPatchRoleMutationOptions = <
|
||||
export type PatchRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchRole>>
|
||||
>;
|
||||
export type PatchRoleMutationBody = BodyType<AuthtypesPatchableRoleDTO>;
|
||||
export type PatchRoleMutationBody = BodyType<RoletypesPatchableRoleDTO>;
|
||||
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -444,7 +444,7 @@ export const usePatchRole = <
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
data: BodyType<RoletypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -453,7 +453,7 @@ export const usePatchRole = <
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
data: BodyType<RoletypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
@@ -278,13 +278,6 @@ export interface AuthtypesPatchableObjectsDTO {
|
||||
deletions: AuthtypesGettableObjectsDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesPatchableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
@@ -308,17 +301,6 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRotateTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -337,39 +319,6 @@ export interface AuthtypesResourceDTO {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -2090,6 +2039,57 @@ export interface RenderErrorResponseDTO {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface RoletypesPatchableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RoletypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RoletypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2330,59 +2330,7 @@ export interface TelemetrytypesTelemetryFieldValuesDTO {
|
||||
stringValues?: string[];
|
||||
}
|
||||
|
||||
export interface TypesGettableGlobalConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
ingestion_url?: string;
|
||||
}
|
||||
|
||||
export interface TypesIdentifiableDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TypesOrganizationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
key?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface UsertypesChangePasswordRequestDTO {
|
||||
export interface TypesChangePasswordRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2397,7 +2345,7 @@ export interface UsertypesChangePasswordRequestDTO {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesGettableAPIKeyDTO {
|
||||
export interface TypesGettableAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2407,7 +2355,7 @@ export interface UsertypesGettableAPIKeyDTO {
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
createdByUser?: UsertypesUserDTO;
|
||||
createdByUser?: TypesUserDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -2447,14 +2395,32 @@ export interface UsertypesGettableAPIKeyDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
updatedByUser?: UsertypesUserDTO;
|
||||
updatedByUser?: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesInviteDTO {
|
||||
export interface TypesGettableGlobalConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
ingestion_url?: string;
|
||||
}
|
||||
|
||||
export interface TypesIdentifiableDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TypesInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2495,7 +2461,41 @@ export interface UsertypesInviteDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface UsertypesPostableAPIKeyDTO {
|
||||
export interface TypesOrganizationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
key?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesPostableAPIKeyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -2511,7 +2511,7 @@ export interface UsertypesPostableAPIKeyDTO {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesPostableAcceptInviteDTO {
|
||||
export interface TypesPostableAcceptInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2530,14 +2530,14 @@ export interface UsertypesPostableAcceptInviteDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesPostableBulkInviteRequestDTO {
|
||||
export interface TypesPostableBulkInviteRequestDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
invites: UsertypesPostableInviteDTO[];
|
||||
invites: TypesPostableInviteDTO[];
|
||||
}
|
||||
|
||||
export interface UsertypesPostableForgotPasswordDTO {
|
||||
export interface TypesPostableForgotPasswordDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2552,7 +2552,7 @@ export interface UsertypesPostableForgotPasswordDTO {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface UsertypesPostableInviteDTO {
|
||||
export interface TypesPostableInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2571,7 +2571,7 @@ export interface UsertypesPostableInviteDTO {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesPostableResetPasswordDTO {
|
||||
export interface TypesPostableResetPasswordDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2582,7 +2582,7 @@ export interface UsertypesPostableResetPasswordDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesResetPasswordTokenDTO {
|
||||
export interface TypesResetPasswordTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2602,7 +2602,7 @@ export interface UsertypesResetPasswordTokenDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesStorableAPIKeyDTO {
|
||||
export interface TypesStorableAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2647,7 +2647,7 @@ export interface UsertypesStorableAPIKeyDTO {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface UsertypesUserDTO {
|
||||
export interface TypesUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -3018,7 +3018,7 @@ export type GetResetPasswordTokenPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetResetPasswordToken200 = {
|
||||
data: UsertypesResetPasswordTokenDTO;
|
||||
data: TypesResetPasswordTokenDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3037,7 +3037,7 @@ export type ListInvite200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: UsertypesInviteDTO[];
|
||||
data: TypesInviteDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3045,7 +3045,7 @@ export type ListInvite200 = {
|
||||
};
|
||||
|
||||
export type CreateInvite201 = {
|
||||
data: UsertypesInviteDTO;
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3059,7 +3059,7 @@ export type GetInvitePathParameters = {
|
||||
token: string;
|
||||
};
|
||||
export type GetInvite200 = {
|
||||
data: UsertypesInviteDTO;
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3067,7 +3067,7 @@ export type GetInvite200 = {
|
||||
};
|
||||
|
||||
export type AcceptInvite201 = {
|
||||
data: UsertypesUserDTO;
|
||||
data: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3115,7 +3115,7 @@ export type ListAPIKeys200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: UsertypesGettableAPIKeyDTO[];
|
||||
data: TypesGettableAPIKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3123,7 +3123,7 @@ export type ListAPIKeys200 = {
|
||||
};
|
||||
|
||||
export type CreateAPIKey201 = {
|
||||
data: UsertypesGettableAPIKeyDTO;
|
||||
data: TypesGettableAPIKeyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3163,7 +3163,7 @@ export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesRoleDTO[];
|
||||
data: RoletypesRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3185,7 +3185,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleDTO;
|
||||
data: RoletypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3290,7 +3290,7 @@ export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: UsertypesUserDTO[];
|
||||
data: TypesUserDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3304,7 +3304,7 @@ export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUser200 = {
|
||||
data: UsertypesUserDTO;
|
||||
data: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3315,7 +3315,7 @@ export type UpdateUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateUser200 = {
|
||||
data: UsertypesUserDTO;
|
||||
data: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3323,7 +3323,7 @@ export type UpdateUser200 = {
|
||||
};
|
||||
|
||||
export type GetMyUser200 = {
|
||||
data: UsertypesUserDTO;
|
||||
data: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -38,18 +38,18 @@ import type {
|
||||
ListUsers200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeAPIKeyPathParameters,
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesPostableAcceptInviteDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableBulkInviteRequestDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
TypesStorableAPIKeyDTO,
|
||||
TypesUserDTO,
|
||||
UpdateAPIKeyPathParameters,
|
||||
UpdateUser200,
|
||||
UpdateUserPathParameters,
|
||||
UsertypesChangePasswordRequestDTO,
|
||||
UsertypesPostableAcceptInviteDTO,
|
||||
UsertypesPostableAPIKeyDTO,
|
||||
UsertypesPostableBulkInviteRequestDTO,
|
||||
UsertypesPostableForgotPasswordDTO,
|
||||
UsertypesPostableInviteDTO,
|
||||
UsertypesPostableResetPasswordDTO,
|
||||
UsertypesStorableAPIKeyDTO,
|
||||
UsertypesUserDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -58,14 +58,14 @@ import type {
|
||||
*/
|
||||
export const changePassword = (
|
||||
{ id }: ChangePasswordPathParameters,
|
||||
usertypesChangePasswordRequestDTO: BodyType<UsertypesChangePasswordRequestDTO>,
|
||||
typesChangePasswordRequestDTO: BodyType<TypesChangePasswordRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/changePassword/${id}`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesChangePasswordRequestDTO,
|
||||
data: typesChangePasswordRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -79,7 +79,7 @@ export const getChangePasswordMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: ChangePasswordPathParameters;
|
||||
data: BodyType<UsertypesChangePasswordRequestDTO>;
|
||||
data: BodyType<TypesChangePasswordRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -88,7 +88,7 @@ export const getChangePasswordMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: ChangePasswordPathParameters;
|
||||
data: BodyType<UsertypesChangePasswordRequestDTO>;
|
||||
data: BodyType<TypesChangePasswordRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -105,7 +105,7 @@ export const getChangePasswordMutationOptions = <
|
||||
Awaited<ReturnType<typeof changePassword>>,
|
||||
{
|
||||
pathParams: ChangePasswordPathParameters;
|
||||
data: BodyType<UsertypesChangePasswordRequestDTO>;
|
||||
data: BodyType<TypesChangePasswordRequestDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -119,7 +119,7 @@ export const getChangePasswordMutationOptions = <
|
||||
export type ChangePasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof changePassword>>
|
||||
>;
|
||||
export type ChangePasswordMutationBody = BodyType<UsertypesChangePasswordRequestDTO>;
|
||||
export type ChangePasswordMutationBody = BodyType<TypesChangePasswordRequestDTO>;
|
||||
export type ChangePasswordMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -134,7 +134,7 @@ export const useChangePassword = <
|
||||
TError,
|
||||
{
|
||||
pathParams: ChangePasswordPathParameters;
|
||||
data: BodyType<UsertypesChangePasswordRequestDTO>;
|
||||
data: BodyType<TypesChangePasswordRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -143,7 +143,7 @@ export const useChangePassword = <
|
||||
TError,
|
||||
{
|
||||
pathParams: ChangePasswordPathParameters;
|
||||
data: BodyType<UsertypesChangePasswordRequestDTO>;
|
||||
data: BodyType<TypesChangePasswordRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -338,14 +338,14 @@ export const invalidateListInvite = async (
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const createInvite = (
|
||||
usertypesPostableInviteDTO: BodyType<UsertypesPostableInviteDTO>,
|
||||
typesPostableInviteDTO: BodyType<TypesPostableInviteDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateInvite201>({
|
||||
url: `/api/v1/invite`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesPostableInviteDTO,
|
||||
data: typesPostableInviteDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -357,13 +357,13 @@ export const getCreateInviteMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableInviteDTO> },
|
||||
{ data: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableInviteDTO> },
|
||||
{ data: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createInvite'];
|
||||
@@ -377,7 +377,7 @@ export const getCreateInviteMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
{ data: BodyType<UsertypesPostableInviteDTO> }
|
||||
{ data: BodyType<TypesPostableInviteDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -390,7 +390,7 @@ export const getCreateInviteMutationOptions = <
|
||||
export type CreateInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createInvite>>
|
||||
>;
|
||||
export type CreateInviteMutationBody = BodyType<UsertypesPostableInviteDTO>;
|
||||
export type CreateInviteMutationBody = BodyType<TypesPostableInviteDTO>;
|
||||
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -403,13 +403,13 @@ export const useCreateInvite = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableInviteDTO> },
|
||||
{ data: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableInviteDTO> },
|
||||
{ data: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateInviteMutationOptions(options);
|
||||
@@ -589,14 +589,14 @@ export const invalidateGetInvite = async (
|
||||
* @summary Accept invite
|
||||
*/
|
||||
export const acceptInvite = (
|
||||
usertypesPostableAcceptInviteDTO: BodyType<UsertypesPostableAcceptInviteDTO>,
|
||||
typesPostableAcceptInviteDTO: BodyType<TypesPostableAcceptInviteDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<AcceptInvite201>({
|
||||
url: `/api/v1/invite/accept`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesPostableAcceptInviteDTO,
|
||||
data: typesPostableAcceptInviteDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -608,13 +608,13 @@ export const getAcceptInviteMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAcceptInviteDTO> },
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAcceptInviteDTO> },
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['acceptInvite'];
|
||||
@@ -628,7 +628,7 @@ export const getAcceptInviteMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
{ data: BodyType<UsertypesPostableAcceptInviteDTO> }
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -641,7 +641,7 @@ export const getAcceptInviteMutationOptions = <
|
||||
export type AcceptInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof acceptInvite>>
|
||||
>;
|
||||
export type AcceptInviteMutationBody = BodyType<UsertypesPostableAcceptInviteDTO>;
|
||||
export type AcceptInviteMutationBody = BodyType<TypesPostableAcceptInviteDTO>;
|
||||
export type AcceptInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -654,13 +654,13 @@ export const useAcceptInvite = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAcceptInviteDTO> },
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAcceptInviteDTO> },
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getAcceptInviteMutationOptions(options);
|
||||
@@ -672,14 +672,14 @@ export const useAcceptInvite = <
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const createBulkInvite = (
|
||||
usertypesPostableBulkInviteRequestDTO: BodyType<UsertypesPostableBulkInviteRequestDTO>,
|
||||
typesPostableBulkInviteRequestDTO: BodyType<TypesPostableBulkInviteRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/invite/bulk`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesPostableBulkInviteRequestDTO,
|
||||
data: typesPostableBulkInviteRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -691,13 +691,13 @@ export const getCreateBulkInviteMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableBulkInviteRequestDTO> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableBulkInviteRequestDTO> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createBulkInvite'];
|
||||
@@ -711,7 +711,7 @@ export const getCreateBulkInviteMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
{ data: BodyType<UsertypesPostableBulkInviteRequestDTO> }
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -724,7 +724,7 @@ export const getCreateBulkInviteMutationOptions = <
|
||||
export type CreateBulkInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>
|
||||
>;
|
||||
export type CreateBulkInviteMutationBody = BodyType<UsertypesPostableBulkInviteRequestDTO>;
|
||||
export type CreateBulkInviteMutationBody = BodyType<TypesPostableBulkInviteRequestDTO>;
|
||||
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -737,13 +737,13 @@ export const useCreateBulkInvite = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableBulkInviteRequestDTO> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableBulkInviteRequestDTO> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateBulkInviteMutationOptions(options);
|
||||
@@ -841,14 +841,14 @@ export const invalidateListAPIKeys = async (
|
||||
* @summary Create api key
|
||||
*/
|
||||
export const createAPIKey = (
|
||||
usertypesPostableAPIKeyDTO: BodyType<UsertypesPostableAPIKeyDTO>,
|
||||
typesPostableAPIKeyDTO: BodyType<TypesPostableAPIKeyDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAPIKey201>({
|
||||
url: `/api/v1/pats`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesPostableAPIKeyDTO,
|
||||
data: typesPostableAPIKeyDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -860,13 +860,13 @@ export const getCreateAPIKeyMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAPIKeyDTO> },
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAPIKeyDTO> },
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createAPIKey'];
|
||||
@@ -880,7 +880,7 @@ export const getCreateAPIKeyMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
{ data: BodyType<UsertypesPostableAPIKeyDTO> }
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -893,7 +893,7 @@ export const getCreateAPIKeyMutationOptions = <
|
||||
export type CreateAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createAPIKey>>
|
||||
>;
|
||||
export type CreateAPIKeyMutationBody = BodyType<UsertypesPostableAPIKeyDTO>;
|
||||
export type CreateAPIKeyMutationBody = BodyType<TypesPostableAPIKeyDTO>;
|
||||
export type CreateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -906,13 +906,13 @@ export const useCreateAPIKey = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAPIKeyDTO> },
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableAPIKeyDTO> },
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateAPIKeyMutationOptions(options);
|
||||
@@ -1002,13 +1002,13 @@ export const useRevokeAPIKey = <
|
||||
*/
|
||||
export const updateAPIKey = (
|
||||
{ id }: UpdateAPIKeyPathParameters,
|
||||
usertypesStorableAPIKeyDTO: BodyType<UsertypesStorableAPIKeyDTO>,
|
||||
typesStorableAPIKeyDTO: BodyType<TypesStorableAPIKeyDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/pats/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesStorableAPIKeyDTO,
|
||||
data: typesStorableAPIKeyDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export const getUpdateAPIKeyMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<UsertypesStorableAPIKeyDTO>;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -1030,7 +1030,7 @@ export const getUpdateAPIKeyMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<UsertypesStorableAPIKeyDTO>;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -1047,7 +1047,7 @@ export const getUpdateAPIKeyMutationOptions = <
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<UsertypesStorableAPIKeyDTO>;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -1061,7 +1061,7 @@ export const getUpdateAPIKeyMutationOptions = <
|
||||
export type UpdateAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>
|
||||
>;
|
||||
export type UpdateAPIKeyMutationBody = BodyType<UsertypesStorableAPIKeyDTO>;
|
||||
export type UpdateAPIKeyMutationBody = BodyType<TypesStorableAPIKeyDTO>;
|
||||
export type UpdateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -1076,7 +1076,7 @@ export const useUpdateAPIKey = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<UsertypesStorableAPIKeyDTO>;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -1085,7 +1085,7 @@ export const useUpdateAPIKey = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<UsertypesStorableAPIKeyDTO>;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -1098,14 +1098,14 @@ export const useUpdateAPIKey = <
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const resetPassword = (
|
||||
usertypesPostableResetPasswordDTO: BodyType<UsertypesPostableResetPasswordDTO>,
|
||||
typesPostableResetPasswordDTO: BodyType<TypesPostableResetPasswordDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/resetPassword`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesPostableResetPasswordDTO,
|
||||
data: typesPostableResetPasswordDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -1117,13 +1117,13 @@ export const getResetPasswordMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableResetPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableResetPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['resetPassword'];
|
||||
@@ -1137,7 +1137,7 @@ export const getResetPasswordMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
{ data: BodyType<UsertypesPostableResetPasswordDTO> }
|
||||
{ data: BodyType<TypesPostableResetPasswordDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -1150,7 +1150,7 @@ export const getResetPasswordMutationOptions = <
|
||||
export type ResetPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof resetPassword>>
|
||||
>;
|
||||
export type ResetPasswordMutationBody = BodyType<UsertypesPostableResetPasswordDTO>;
|
||||
export type ResetPasswordMutationBody = BodyType<TypesPostableResetPasswordDTO>;
|
||||
export type ResetPasswordMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -1163,13 +1163,13 @@ export const useResetPassword = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableResetPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableResetPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getResetPasswordMutationOptions(options);
|
||||
@@ -1428,13 +1428,13 @@ export const invalidateGetUser = async (
|
||||
*/
|
||||
export const updateUser = (
|
||||
{ id }: UpdateUserPathParameters,
|
||||
usertypesUserDTO: BodyType<UsertypesUserDTO>,
|
||||
typesUserDTO: BodyType<TypesUserDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateUser200>({
|
||||
url: `/api/v1/user/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesUserDTO,
|
||||
data: typesUserDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1445,13 +1445,13 @@ export const getUpdateUserMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateUser>>,
|
||||
TError,
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<UsertypesUserDTO> },
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateUser>>,
|
||||
TError,
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<UsertypesUserDTO> },
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateUser'];
|
||||
@@ -1465,7 +1465,7 @@ export const getUpdateUserMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateUser>>,
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<UsertypesUserDTO> }
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> }
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
@@ -1478,7 +1478,7 @@ export const getUpdateUserMutationOptions = <
|
||||
export type UpdateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateUser>>
|
||||
>;
|
||||
export type UpdateUserMutationBody = BodyType<UsertypesUserDTO>;
|
||||
export type UpdateUserMutationBody = BodyType<TypesUserDTO>;
|
||||
export type UpdateUserMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -1491,13 +1491,13 @@ export const useUpdateUser = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateUser>>,
|
||||
TError,
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<UsertypesUserDTO> },
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateUser>>,
|
||||
TError,
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<UsertypesUserDTO> },
|
||||
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateUserMutationOptions(options);
|
||||
@@ -1587,14 +1587,14 @@ export const invalidateGetMyUser = async (
|
||||
* @summary Forgot password
|
||||
*/
|
||||
export const forgotPassword = (
|
||||
usertypesPostableForgotPasswordDTO: BodyType<UsertypesPostableForgotPasswordDTO>,
|
||||
typesPostableForgotPasswordDTO: BodyType<TypesPostableForgotPasswordDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/factor_password/forgot`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: usertypesPostableForgotPasswordDTO,
|
||||
data: typesPostableForgotPasswordDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -1606,13 +1606,13 @@ export const getForgotPasswordMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableForgotPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableForgotPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableForgotPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableForgotPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['forgotPassword'];
|
||||
@@ -1626,7 +1626,7 @@ export const getForgotPasswordMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
{ data: BodyType<UsertypesPostableForgotPasswordDTO> }
|
||||
{ data: BodyType<TypesPostableForgotPasswordDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -1639,7 +1639,7 @@ export const getForgotPasswordMutationOptions = <
|
||||
export type ForgotPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof forgotPassword>>
|
||||
>;
|
||||
export type ForgotPasswordMutationBody = BodyType<UsertypesPostableForgotPasswordDTO>;
|
||||
export type ForgotPasswordMutationBody = BodyType<TypesPostableForgotPasswordDTO>;
|
||||
export type ForgotPasswordMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -1652,13 +1652,13 @@ export const useForgotPassword = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableForgotPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableForgotPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<UsertypesPostableForgotPasswordDTO> },
|
||||
{ data: BodyType<TypesPostableForgotPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getForgotPasswordMutationOptions(options);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
.announcement-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
height: 40px;
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-size: var(--label-base-500-font-size);
|
||||
line-height: var(--label-base-500-line-height);
|
||||
font-weight: var(--label-base-500-font-weight);
|
||||
letter-spacing: -0.065px;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--callout-warning-background);
|
||||
color: var(--callout-warning-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-warning-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
background-color: var(--callout-primary-background);
|
||||
color: var(--callout-primary-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-primary-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--callout-error-background);
|
||||
color: var(--callout-error-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
background-color: var(--callout-success-background);
|
||||
color: var(--callout-success-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-success-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
height: 24px;
|
||||
font-size: var(--label-small-500-font-size);
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__dismiss {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
AnnouncementBanner,
|
||||
AnnouncementBannerProps,
|
||||
PersistedAnnouncementBanner,
|
||||
} from './index';
|
||||
|
||||
const STORAGE_KEY = 'test-banner-dismissed';
|
||||
|
||||
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
|
||||
render(<AnnouncementBanner message="Test message" {...props} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('AnnouncementBanner', () => {
|
||||
it('renders message and default warning variant', () => {
|
||||
renderBanner({ message: <strong>Heads up</strong> });
|
||||
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass('announcement-banner--warning');
|
||||
expect(alert).toHaveTextContent('Heads up');
|
||||
});
|
||||
|
||||
it.each(['warning', 'info', 'success', 'error'] as const)(
|
||||
'renders %s variant correctly',
|
||||
(type) => {
|
||||
renderBanner({ type, message: 'Test message' });
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass(`announcement-banner--${type}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('calls action onClick when action button is clicked', async () => {
|
||||
const onClick = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderBanner({ action: { label: 'Go to Settings', onClick } });
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /go to settings/i }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
|
||||
renderBanner({ onClose: undefined, icon: null });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /dismiss/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersistedAnnouncementBanner', () => {
|
||||
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
|
||||
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(
|
||||
<PersistedAnnouncementBanner
|
||||
message="Test message"
|
||||
storageKey={STORAGE_KEY}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
it('does not render when storageKey is already set in localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
render(
|
||||
<PersistedAnnouncementBanner
|
||||
message="Test message"
|
||||
storageKey={STORAGE_KEY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import './AnnouncementBanner.styles.scss';
|
||||
|
||||
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
|
||||
|
||||
export interface AnnouncementBannerAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface AnnouncementBannerProps {
|
||||
message: ReactNode;
|
||||
type?: AnnouncementBannerType;
|
||||
icon?: ReactNode | null;
|
||||
action?: AnnouncementBannerAction;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
|
||||
warning: <TriangleAlert size={14} />,
|
||||
info: <Info size={14} />,
|
||||
error: <CircleAlert size={14} />,
|
||||
success: <CircleCheckBig size={14} />,
|
||||
};
|
||||
|
||||
export default function AnnouncementBanner({
|
||||
message,
|
||||
type = 'warning',
|
||||
icon,
|
||||
action,
|
||||
onClose,
|
||||
className,
|
||||
}: AnnouncementBannerProps): JSX.Element {
|
||||
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cx(
|
||||
'announcement-banner',
|
||||
`announcement-banner--${type}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="announcement-banner__body">
|
||||
{resolvedIcon && (
|
||||
<span className="announcement-banner__icon">{resolvedIcon}</span>
|
||||
)}
|
||||
<span className="announcement-banner__message">{message}</span>
|
||||
{action && (
|
||||
<Button
|
||||
type="button"
|
||||
className="announcement-banner__action"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="announcement-banner__dismiss"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import AnnouncementBanner, {
|
||||
AnnouncementBannerProps,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
|
||||
storageKey: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
function isDismissed(storageKey: string): boolean {
|
||||
return localStorage.getItem(storageKey) === 'true';
|
||||
}
|
||||
|
||||
export default function PersistedAnnouncementBanner({
|
||||
storageKey,
|
||||
onDismiss,
|
||||
...props
|
||||
}: PersistedAnnouncementBannerProps): JSX.Element | null {
|
||||
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = (): void => {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
setVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return <AnnouncementBanner {...props} onClose={handleClose} />;
|
||||
}
|
||||
12
frontend/src/components/AnnouncementBanner/index.ts
Normal file
12
frontend/src/components/AnnouncementBanner/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import AnnouncementBanner from './AnnouncementBanner';
|
||||
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
|
||||
|
||||
export type {
|
||||
AnnouncementBannerAction,
|
||||
AnnouncementBannerProps,
|
||||
AnnouncementBannerType,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
export { AnnouncementBanner, PersistedAnnouncementBanner };
|
||||
|
||||
export default AnnouncementBanner;
|
||||
@@ -0,0 +1,106 @@
|
||||
.create-sa-modal {
|
||||
max-width: 530px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-base-white);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.create-sa-modal__content {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
> label {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--destructive);
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__helper {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--l3-foreground);
|
||||
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-sa-modal {
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useCreateServiceAccount } 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 { EMAIL_REGEX } from 'utils/app';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
interface CreateServiceAccountModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
function CreateServiceAccountModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateServiceAccountModalProps): JSX.Element {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isValid, errors },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: createServiceAccount } = useCreateServiceAccount();
|
||||
const {
|
||||
roles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
function handleClose(): void {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function onSubmit(values: FormValues): Promise<void> {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
roles: values.roles,
|
||||
},
|
||||
});
|
||||
toast.success('Service account created successfully', { richColors: true });
|
||||
reset();
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New Service Account"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<form className="create-sa-form">
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-name">Name</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: 'Name is required' }}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-name"
|
||||
placeholder="Enter a name"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<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}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.roles && (
|
||||
<p className="create-sa-form__error">{errors.roles.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateServiceAccountModal;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
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: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('submit button is disabled when form is empty', () => {
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submit button remains disabled when email is invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
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 calls onSuccess + onClose', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={onClose} onSuccess={onSuccess} />,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Service account created successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows toast.error and does not call onSuccess on API error', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({ status: 'error', error: 'Internal Server Error' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={onSuccess} />,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Failed to create service account/i),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('Cancel button calls onClose without submitting', async () => {
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={onClose} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows "Name is required" after clearing the name field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter a name');
|
||||
await user.type(nameInput, 'Bot');
|
||||
await user.clear(nameInput);
|
||||
|
||||
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 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await screen.findByText('Please enter a valid email address');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,6 @@
|
||||
.custom-time-picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
flex-direction: column;
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
|
||||
@@ -16,15 +16,6 @@ jest.mock('react-router-dom', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: jest.fn(() => jest.fn()),
|
||||
useSelector: jest.fn(() => ({
|
||||
minTime: 0,
|
||||
maxTime: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
FixedDurationSuggestionOptions,
|
||||
@@ -19,11 +17,9 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -70,8 +66,6 @@ interface CustomTimePickerProps {
|
||||
showRecentlyUsed?: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -94,7 +88,6 @@ function CustomTimePicker({
|
||||
showRecentlyUsed = true,
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -123,14 +116,6 @@ function CustomTimePicker({
|
||||
|
||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
||||
|
||||
const durationMs = (maxTime - minTime) / 1e6;
|
||||
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
|
||||
|
||||
const handleZoomOut = useZoomOut({
|
||||
isDisabled: zoomOutDisabled,
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
});
|
||||
|
||||
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
|
||||
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||
@@ -297,11 +282,7 @@ function CustomTimePicker({
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
const handleInputPressEnter = (
|
||||
event?: React.KeyboardEvent<HTMLInputElement>,
|
||||
): void => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const handleInputPressEnter = (): void => {
|
||||
// check if the entered time is in the format of 1m, 2h, 3d, 4w
|
||||
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);
|
||||
|
||||
@@ -650,23 +631,6 @@ function CustomTimePicker({
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefixIcon={<ZoomOut size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
|
||||
maxTime: NOW_MS * 1e6,
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: () => string;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: (): string => 'relativeTime=45m',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string; offset: string } } => ({
|
||||
timezone: { value: 'UTC', offset: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
onSelect: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
selectedValue: '15m',
|
||||
selectedTime: '15m',
|
||||
onValidCustomDateChange: jest.fn(),
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
items: [
|
||||
{ value: '15m', label: 'Last 15 minutes' },
|
||||
{ value: '1h', label: 'Last 1 hour' },
|
||||
],
|
||||
minTime: (now - 15 * 60 * 1000) * 1e6,
|
||||
maxTime: now * 1e6,
|
||||
};
|
||||
|
||||
describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render zoom out button when showLiveLogs is false', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when showLiveLogs is true', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when isModalTimeSelection is true', () => {
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
showLiveLogs={false}
|
||||
isModalTimeSelection={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleZoomOut when zoom out button is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete activeLogId when zoom out is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
|
||||
it('should disable zoom button when time range is >= 1 month', () => {
|
||||
const now = Date.now();
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
minTime={(now - 31 * MS_PER_DAY) * 1e6}
|
||||
maxTime={now * 1e6}
|
||||
showLiveLogs={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,8 @@ function InviteMembersModal({
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-font-height);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
90
frontend/src/components/RolesSelect/RolesSelect.styles.scss
Normal file
90
frontend/src/components/RolesSelect/RolesSelect.styles.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
.roles-select {
|
||||
width: 100%;
|
||||
|
||||
// todo: styles should easeup once upgrade to select from periscope
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0 var(--padding-1) 0 6px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--foreground);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.roles-select-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
color: var(--destructive);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&__msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
&__retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
172
frontend/src/components/RolesSelect/RolesSelect.tsx
Normal file
172
frontend/src/components/RolesSelect/RolesSelect.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { CircleAlert, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './RolesSelect.styles.scss';
|
||||
|
||||
export interface RoleOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: RoletypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const { data, isLoading, isError, error, refetch } = useListRoles();
|
||||
return {
|
||||
roles: data?.data ?? [],
|
||||
isLoading,
|
||||
isError,
|
||||
error: convertToApiError(error),
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: RoletypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
function ErrorContent({
|
||||
error,
|
||||
onRefetch,
|
||||
}: {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}): JSX.Element {
|
||||
const errorMessage = error?.message || 'Failed to load roles';
|
||||
|
||||
return (
|
||||
<div className="roles-select-error">
|
||||
<span className="roles-select-error__msg">
|
||||
<CircleAlert size={12} />
|
||||
{errorMessage}
|
||||
</span>
|
||||
{onRefetch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRefetch();
|
||||
}}
|
||||
className="roles-select-error__retry-btn"
|
||||
title="Retry"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: RoletypesRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string) => void;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
mode: 'multiple';
|
||||
value?: string[];
|
||||
onChange?: (roles: string[]) => void;
|
||||
}
|
||||
|
||||
export type RolesSelectProps = SingleProps | MultipleProps;
|
||||
|
||||
function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
const externalRoles = props.roles;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: internalLoading,
|
||||
isError: internalError,
|
||||
error: internalErrorObj,
|
||||
refetch: internalRefetch,
|
||||
} = useListRoles({
|
||||
query: { enabled: externalRoles === undefined },
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
id,
|
||||
placeholder = 'Select role',
|
||||
className,
|
||||
getPopupContainer,
|
||||
loading = internalLoading,
|
||||
isError = internalError,
|
||||
error = convertToApiError(internalErrorObj),
|
||||
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
|
||||
} = props;
|
||||
|
||||
const notFoundContent = isError ? (
|
||||
<ErrorContent error={error} onRefetch={onRefetch} />
|
||||
) : undefined;
|
||||
|
||||
if (mode === 'multiple') {
|
||||
const { value = [], onChange } = props as MultipleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
mode="multiple"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={cx('roles-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={value.includes(option.value as string)}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={cx('roles-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesSelect;
|
||||
2
frontend/src/components/RolesSelect/index.ts
Normal file
2
frontend/src/components/RolesSelect/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { RoleOption, RolesSelectProps } from './RolesSelect';
|
||||
export { default, getRoleOptions, useRoles } from './RolesSelect';
|
||||
@@ -0,0 +1,179 @@
|
||||
.add-key-modal {
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
[data-slot='toggle-group'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.add-key-modal-datepicker-popup {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--popover);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 var(--padding-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__expiry-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__expiry-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
268
frontend/src/components/ServiceAccountDrawer/AddKeyModal.tsx
Normal file
268
frontend/src/components/ServiceAccountDrawer/AddKeyModal.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useCreateServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from './utils';
|
||||
|
||||
import './AddKeyModal.styles.scss';
|
||||
|
||||
interface AddKeyModalProps {
|
||||
open: boolean;
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type Phase = 'form' | 'created';
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
function AddKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AddKeyModalProps): JSX.Element {
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
|
||||
const [expiryDate, setExpiryDate] = useState<Dayjs | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [
|
||||
createdKey,
|
||||
setCreatedKey,
|
||||
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase('form');
|
||||
setKeyName('');
|
||||
setExpiryMode('none');
|
||||
setExpiryDate(null);
|
||||
setIsSubmitting(false);
|
||||
setCreatedKey(null);
|
||||
setHasCopied(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { mutateAsync: createKey } = useCreateServiceAccountKey();
|
||||
|
||||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
if (!keyName.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const expiresAt =
|
||||
expiryMode === 'date' && expiryDate ? expiryDate.endOf('day').unix() : 0;
|
||||
const response = await createKey({
|
||||
pathParams: { id: accountId },
|
||||
data: { name: keyName.trim(), expiresAt },
|
||||
});
|
||||
const keyData = response?.data;
|
||||
if (keyData) {
|
||||
setCreatedKey(keyData);
|
||||
setPhase('created');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to create key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [keyName, expiryMode, expiryDate, accountId, createKey]);
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
if (!createdKey?.key) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy key', { richColors: true });
|
||||
}
|
||||
}, [createdKey]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (phase === 'created') {
|
||||
onSuccess();
|
||||
}
|
||||
onClose();
|
||||
}, [phase, onSuccess, onClose]);
|
||||
|
||||
const expiryLabel = (): string => {
|
||||
if (expiryMode === 'none' || !expiryDate) {
|
||||
return 'Never';
|
||||
}
|
||||
try {
|
||||
return expiryDate.format('MMM D, YYYY');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
};
|
||||
|
||||
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{phase === 'form' && (
|
||||
<>
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="key-name">
|
||||
Name <span style={{ color: 'var(--destructive)' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={keyName}
|
||||
onChange={(e): void => setKeyName(e.target.value)}
|
||||
placeholder="Enter key name e.g.: Service Owner"
|
||||
className="add-key-modal__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Expiration</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={expiryMode}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setExpiryMode(val as ExpiryMode);
|
||||
if (val === 'none') {
|
||||
setExpiryDate(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="add-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="expiry-date">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<DatePicker
|
||||
id="expiry-date"
|
||||
value={expiryDate}
|
||||
onChange={(date): void => setExpiryDate(date)}
|
||||
popupClassName="add-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!keyName.trim()}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'created' && createdKey && (
|
||||
<>
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Key</span>
|
||||
<div className="add-key-modal__key-display">
|
||||
<span className="add-key-modal__key-text">{createdKey.key}</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="add-key-modal__copy-btn"
|
||||
>
|
||||
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__expiry-meta">
|
||||
<span className="add-key-modal__expiry-label">Expiration</span>
|
||||
<Badge color="vanilla">{expiryLabel()}</Badge>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
message="Store the key securely. This is the only time it will be displayed."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddKeyModal;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { PowerOff, X } from '@signozhq/icons';
|
||||
|
||||
interface DisableAccountModalProps {
|
||||
open: boolean;
|
||||
accountName: string | null | undefined;
|
||||
isDisabling: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function DisableAccountModal({
|
||||
open,
|
||||
accountName,
|
||||
isDisabling,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DisableAccountModalProps): JSX.Element {
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={`Disable service account ${accountName ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog sa-disable-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>
|
||||
<DialogFooter className="sa-disable-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isDisabling}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisableAccountModal;
|
||||
@@ -0,0 +1,188 @@
|
||||
.edit-key-modal {
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
[data-slot='toggle-group'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-key-modal-datepicker-popup {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--popover);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--destructive);
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
282
frontend/src/components/ServiceAccountDrawer/EditKeyModal.tsx
Normal file
282
frontend/src/components/ServiceAccountDrawer/EditKeyModal.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useRevokeServiceAccountKey,
|
||||
useUpdateServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { RevokeKeyContent } from './RevokeKeyModal';
|
||||
import { disabledDate, formatLastObservedAt } from './utils';
|
||||
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
interface EditKeyModalProps {
|
||||
open: boolean;
|
||||
accountId: string;
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function EditKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
keyItem,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: EditKeyModalProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
|
||||
const [localDate, setLocalDate] = useState<Dayjs | null>(null);
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyItem) {
|
||||
setLocalName(keyItem.name ?? '');
|
||||
if (keyItem.expiresAt === 0) {
|
||||
setExpiryMode('none');
|
||||
setLocalDate(null);
|
||||
} else {
|
||||
setExpiryMode('date');
|
||||
setLocalDate(dayjs.unix(keyItem.expiresAt));
|
||||
}
|
||||
}
|
||||
}, [keyItem]);
|
||||
|
||||
const originalExpiresAt = keyItem?.expiresAt ?? 0;
|
||||
const currentExpiresAt =
|
||||
expiryMode === 'none' || !localDate ? 0 : localDate.endOf('day').unix();
|
||||
const isDirty =
|
||||
keyItem !== null &&
|
||||
(localName !== (keyItem.name ?? '') ||
|
||||
currentExpiresAt !== originalExpiresAt);
|
||||
|
||||
const {
|
||||
mutate: updateKey,
|
||||
isLoading: isSaving,
|
||||
} = useUpdateServiceAccountKey();
|
||||
const {
|
||||
mutate: revokeKey,
|
||||
isLoading: isRevoking,
|
||||
} = useRevokeServiceAccountKey();
|
||||
|
||||
function handleSave(): void {
|
||||
if (!keyItem || !isDirty) {
|
||||
return;
|
||||
}
|
||||
updateKey(
|
||||
{
|
||||
pathParams: { id: accountId, fid: keyItem.id },
|
||||
data: { name: localName, expiresAt: currentExpiresAt },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Key updated successfully', { richColors: true });
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleRevoke(): void {
|
||||
if (!keyItem) {
|
||||
return;
|
||||
}
|
||||
revokeKey(
|
||||
{
|
||||
pathParams: { id: accountId, fid: keyItem.id },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setIsRevokeConfirmOpen(false);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
if (isRevokeConfirmOpen) {
|
||||
setIsRevokeConfirmOpen(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
isRevokeConfirmOpen
|
||||
? `Revoke ${keyItem?.name ?? 'key'}?`
|
||||
: 'Edit Key Details'
|
||||
}
|
||||
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
|
||||
className={
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<RevokeKeyContent
|
||||
keyName={keyItem?.name}
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setIsRevokeConfirmOpen(false)}
|
||||
onConfirm={handleRevoke}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="edit-key-modal__form">
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-key-name"
|
||||
value={localName}
|
||||
onChange={(e): void => setLocalName(e.target.value)}
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-display">
|
||||
Key
|
||||
</label>
|
||||
<div id="edit-key-display" className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__key-text">********************</span>
|
||||
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<span className="edit-key-modal__label">Expiration</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={expiryMode}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setExpiryMode(val as ExpiryMode);
|
||||
if (val === 'none') {
|
||||
setLocalDate(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="edit-key-modal__datepicker">
|
||||
<DatePicker
|
||||
value={localDate}
|
||||
id="edit-key-datepicker"
|
||||
onChange={(date): void => setLocalDate(date)}
|
||||
popupClassName="edit-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="edit-key-modal__meta">
|
||||
<span className="edit-key-modal__meta-label">Last Observed At</span>
|
||||
<Badge color="vanilla">
|
||||
{formatLastObservedAt(
|
||||
keyItem?.lastObservedAt ?? null,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
className="edit-key-modal__footer-danger"
|
||||
onClick={(): void => setIsRevokeConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditKeyModal;
|
||||
285
frontend/src/components/ServiceAccountDrawer/KeysTab.tsx
Normal file
285
frontend/src/components/ServiceAccountDrawer/KeysTab.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useRevokeServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import EditKeyModal from './EditKeyModal';
|
||||
import RevokeKeyModal from './RevokeKeyModal';
|
||||
import { formatLastObservedAt } from './utils';
|
||||
|
||||
interface KeysTabProps {
|
||||
accountId: string;
|
||||
keys: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
onRefetch: () => void;
|
||||
onAddKeyClick: () => void;
|
||||
}
|
||||
|
||||
interface BuildColumnsParams {
|
||||
isDisabled: boolean;
|
||||
setRevokeTarget: React.Dispatch<
|
||||
React.SetStateAction<ServiceaccounttypesFactorAPIKeyDTO | null>
|
||||
>;
|
||||
handleformatLastObservedAt: (
|
||||
lastObservedAt: Date | null | undefined,
|
||||
) => string;
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt: number): JSX.Element {
|
||||
if (expiresAt === 0) {
|
||||
return <span className="keys-tab__expiry--never">Never</span>;
|
||||
}
|
||||
const expiryDate = dayjs.unix(expiresAt);
|
||||
if (expiryDate.isBefore(dayjs())) {
|
||||
return <span className="keys-tab__expiry--expired">Expired</span>;
|
||||
}
|
||||
return <span>{expiryDate.format('MMM D, YYYY')}</span>;
|
||||
}
|
||||
|
||||
function buildColumns({
|
||||
isDisabled,
|
||||
setRevokeTarget,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
|
||||
return [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
className: 'keys-tab__name-column',
|
||||
sorter: (a, b): number => (a.name ?? '').localeCompare(b.name ?? ''),
|
||||
render: (_, record): JSX.Element => (
|
||||
<span className="keys-tab__name-text">{record.name ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Expiry',
|
||||
dataIndex: 'expiresAt',
|
||||
key: 'expiry',
|
||||
width: 160,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
const aVal = a.expiresAt === 0 ? Infinity : a.expiresAt;
|
||||
const bVal = b.expiresAt === 0 ? Infinity : b.expiresAt;
|
||||
return aVal - bVal;
|
||||
},
|
||||
render: (expiresAt: number): JSX.Element => formatExpiry(expiresAt),
|
||||
},
|
||||
{
|
||||
title: 'Last Observed At',
|
||||
dataIndex: 'lastObservedAt',
|
||||
key: 'lastObservedAt',
|
||||
width: 220,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
const aVal = a.lastObservedAt
|
||||
? new Date(a.lastObservedAt).getTime()
|
||||
: -Infinity;
|
||||
const bVal = b.lastObservedAt
|
||||
? new Date(b.lastObservedAt).getTime()
|
||||
: -Infinity;
|
||||
return aVal - bVal;
|
||||
},
|
||||
render: (lastObservedAt: Date | null | undefined): string =>
|
||||
handleformatLastObservedAt(lastObservedAt),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 48,
|
||||
align: 'right' as const,
|
||||
render: (_, record): JSX.Element => (
|
||||
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setRevokeTarget(record);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function KeysTab({
|
||||
accountId,
|
||||
keys,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
currentPage,
|
||||
pageSize,
|
||||
onRefetch,
|
||||
onAddKeyClick,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [editKeyId, setEditKeyId] = useQueryState(
|
||||
'edit-key',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
|
||||
const [
|
||||
revokeTarget,
|
||||
setRevokeTarget,
|
||||
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
|
||||
const {
|
||||
mutate: revokeKey,
|
||||
isLoading: isRevoking,
|
||||
} = useRevokeServiceAccountKey();
|
||||
|
||||
function handleRevoke(): void {
|
||||
if (!revokeTarget) {
|
||||
return;
|
||||
}
|
||||
revokeKey(
|
||||
{ pathParams: { id: accountId, fid: revokeTarget.id } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setRevokeTarget(null);
|
||||
onRefetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleKeySuccess = useCallback((): void => {
|
||||
void setEditKeyId(null);
|
||||
onRefetch();
|
||||
}, [onRefetch, setEditKeyId]);
|
||||
|
||||
const handleformatLastObservedAt = useCallback(
|
||||
(lastObservedAt: Date | null | undefined): string =>
|
||||
formatLastObservedAt(lastObservedAt, formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildColumns({ isDisabled, setRevokeTarget, handleformatLastObservedAt }),
|
||||
[isDisabled, setRevokeTarget, handleformatLastObservedAt],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="keys-tab__loading">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="keys-tab__empty">
|
||||
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
|
||||
🧐
|
||||
</span>
|
||||
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="keys-tab__learn-more"
|
||||
onClick={onAddKeyClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Todo: use new table component from periscope when ready */}
|
||||
<Table<ServiceaccounttypesFactorAPIKeyDTO>
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
style: { display: 'none' },
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
}}
|
||||
showSorterTooltip={false}
|
||||
className={`keys-tab__table${
|
||||
isDisabled ? ' keys-tab__table--disabled' : ''
|
||||
}`}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'keys-tab__table-row--alt' : ''
|
||||
}
|
||||
onRow={(
|
||||
record,
|
||||
): {
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
role: string;
|
||||
tabIndex: number;
|
||||
'aria-label': string;
|
||||
} => ({
|
||||
onClick: (): void => {
|
||||
if (!isDisabled) {
|
||||
void setEditKeyId(record.id);
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
}
|
||||
void setEditKeyId(record.id);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-label': `Edit key ${record.name || 'options'}`,
|
||||
})}
|
||||
/>
|
||||
|
||||
<RevokeKeyModal
|
||||
open={revokeTarget !== null}
|
||||
keyName={revokeTarget?.name}
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setRevokeTarget(null)}
|
||||
onConfirm={handleRevoke}
|
||||
/>
|
||||
|
||||
<EditKeyModal
|
||||
open={editKey !== null}
|
||||
accountId={accountId}
|
||||
keyItem={editKey}
|
||||
onClose={(): void => void setEditKeyId(null)}
|
||||
onSuccess={handleKeySuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeysTab;
|
||||
153
frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx
Normal file
153
frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: RoletypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
onRefetchRoles?: () => void;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
rolesErrorObj,
|
||||
onRefetchRoles,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
className="sa-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.email || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-roles">
|
||||
Roles
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
</div>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta">
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Status</span>
|
||||
{account.status?.toUpperCase() === 'ACTIVE' ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Created At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Updated At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
|
||||
export interface RevokeKeyContentProps {
|
||||
keyName: string | null | undefined;
|
||||
isRevoking: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function RevokeKeyContent({
|
||||
isRevoking,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RevokeKeyContentProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using this key
|
||||
will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RevokeKeyModalProps extends RevokeKeyContentProps {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
function RevokeKeyModal({
|
||||
open,
|
||||
keyName,
|
||||
isRevoking,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RevokeKeyModalProps): JSX.Element {
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={`Revoke ${keyName ?? 'key'}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<RevokeKeyContent
|
||||
keyName={keyName}
|
||||
isRevoking={isRevoking}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default RevokeKeyModal;
|
||||
@@ -0,0 +1,460 @@
|
||||
.sa-drawer {
|
||||
[data-slot='drawer-close'] + div {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
padding-left: var(--padding-4);
|
||||
margin-left: var(--margin-2);
|
||||
}
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--padding-3) var(--padding-4) var(--padding-2) var(--padding-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tab-group {
|
||||
[data-slot='toggle-group'] {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group-item'] {
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-family: Inter, sans-serif;
|
||||
padding: 0 var(--padding-7);
|
||||
gap: var(--spacing-3);
|
||||
box-shadow: none;
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--secondary);
|
||||
font-size: var(--code-small-400-font-size);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__keys-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
padding: var(--padding-2) 0;
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__input-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--margin-1);
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.keys-tab {
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
text-align: center;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
&__empty-emoji {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: styles should easeup by upgrading the component with table component from persiscope
|
||||
&__table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-thead > tr > td {
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--l1-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-table-column-sorter {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: none;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
|
||||
slashed-zero;
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
background: transparent;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
&.keys-tab__table-row--alt > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
.ant-table-tbody > tr {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover > td {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-cell-row-hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-column {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-text {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__expiry--never {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__expiry--expired {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__revoke-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-disable-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DisableAccountModal from './DisableAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
import { ServiceAccountDrawerTab } from './utils';
|
||||
|
||||
import './ServiceAccountDrawer.styles.scss';
|
||||
|
||||
export interface ServiceAccountDrawerProps {
|
||||
account: ServiceAccountRow | null;
|
||||
onSuccess: (options?: { closeDrawer?: boolean }) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
account,
|
||||
onSuccess,
|
||||
}: ServiceAccountDrawerProps): JSX.Element {
|
||||
const [, setSelectedAccountId] = useQueryState('account');
|
||||
const open = account !== null;
|
||||
const onClose = useCallback((): void => void setSelectedAccountId(null), [
|
||||
setSelectedAccountId,
|
||||
]);
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum<ServiceAccountDrawerTab>(
|
||||
Object.values(ServiceAccountDrawerTab),
|
||||
).withDefault(ServiceAccountDrawerTab.Overview),
|
||||
);
|
||||
const [keysPage, setKeysPage] = useQueryState(
|
||||
'keysPage',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setLocalName(account.name ?? '');
|
||||
setLocalRoles(account.roles ?? []);
|
||||
setKeysPage(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [account]);
|
||||
|
||||
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
|
||||
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const {
|
||||
data: keysData,
|
||||
isLoading: keysLoading,
|
||||
refetch: refetchKeys,
|
||||
} = useListServiceAccountKeys(
|
||||
{ id: account?.id ?? '' },
|
||||
{ query: { enabled: !!account?.id } },
|
||||
);
|
||||
const keys = keysData?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (keysLoading) {
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
|
||||
if (keysPage > maxPage) {
|
||||
setKeysPage(maxPage);
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage, setKeysPage]);
|
||||
|
||||
const {
|
||||
mutate: updateAccount,
|
||||
isLoading: isSaving,
|
||||
} = useUpdateServiceAccount();
|
||||
const {
|
||||
mutate: updateStatus,
|
||||
isLoading: isDisabling,
|
||||
} = useUpdateServiceAccountStatus();
|
||||
|
||||
function handleSave(): void {
|
||||
if (!account || !isDirty) {
|
||||
return;
|
||||
}
|
||||
updateAccount(
|
||||
{
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName, email: account.email, roles: localRoles },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
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 });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDisable(): void {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
updateStatus(
|
||||
{
|
||||
pathParams: { id: account.id },
|
||||
data: { status: 'DISABLED' },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Service account disabled', { richColors: true });
|
||||
setIsDisableConfirmOpen(false);
|
||||
onSuccess({ closeDrawer: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to disable service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsDisableConfirmOpen(false);
|
||||
setIsAddKeyOpen(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeySuccess = useCallback((): void => {
|
||||
setIsAddKeyOpen(false);
|
||||
refetchKeys();
|
||||
}, [refetchKeys]);
|
||||
|
||||
const drawerContent = (
|
||||
<div className="sa-drawer__layout">
|
||||
<div className="sa-drawer__tabs">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as ServiceAccountDrawerTab);
|
||||
}
|
||||
}}
|
||||
className="sa-drawer__tab-group"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Overview}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Keys}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<Key size={14} />
|
||||
Keys
|
||||
{keys.length > 0 && (
|
||||
<span className="sa-drawer__tab-count">{keys.length}</span>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => setIsAddKeyOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sa-drawer__body${
|
||||
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
|
||||
}`}
|
||||
>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={setLocalName}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDisabled}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && account && (
|
||||
<KeysTab
|
||||
accountId={account.id}
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDisabled}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
onRefetch={refetchKeys}
|
||||
onAddKeyClick={(): void => setIsAddKeyOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__footer">
|
||||
{activeTab === ServiceAccountDrawerTab.Keys ? (
|
||||
<Pagination
|
||||
current={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => {
|
||||
void setKeysPage(page);
|
||||
}}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="sa-drawer__footer-btn"
|
||||
onClick={(): void => setIsDisableConfirmOpen(true)}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Service Account Details' }}
|
||||
content={drawerContent}
|
||||
className="sa-drawer"
|
||||
/>
|
||||
|
||||
<DisableAccountModal
|
||||
open={isDisableConfirmOpen}
|
||||
accountName={account?.name}
|
||||
isDisabling={isDisabling}
|
||||
onCancel={(): void => setIsDisableConfirmOpen(false)}
|
||||
onConfirm={handleDisable}
|
||||
/>
|
||||
|
||||
{account && (
|
||||
<AddKeyModal
|
||||
open={isAddKeyOpen}
|
||||
accountId={account.id}
|
||||
onClose={(): void => setIsAddKeyOpen(false)}
|
||||
onSuccess={handleKeySuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountDrawer;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AddKeyModal from '../AddKeyModal';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys';
|
||||
|
||||
const createdKeyResponse = {
|
||||
data: {
|
||||
id: 'key-1',
|
||||
name: 'Deploy Key',
|
||||
key: 'snz_abc123xyz456secret',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
accountId: 'sa-1',
|
||||
onClose: jest.fn(),
|
||||
onSuccess: jest.fn(),
|
||||
};
|
||||
|
||||
describe('AddKeyModal', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createdKeyResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('"Create Key" is disabled when name is empty; enabled after typing a name', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<AddKeyModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<AddKeyModal {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
|
||||
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
|
||||
});
|
||||
|
||||
it('copy button writes key to clipboard and shows toast.success', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const writeTextSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
render(<AddKeyModal {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
|
||||
const copyBtn = screen
|
||||
.getAllByRole('button')
|
||||
.find((btn) => btn.querySelector('svg'));
|
||||
if (!copyBtn) {
|
||||
throw new Error('Copy button not found');
|
||||
}
|
||||
await user.click(copyBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
writeTextSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('onSuccess called only when closing from phase 2, not from phase 1 (Cancel)', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<AddKeyModal {...defaultProps} onSuccess={onSuccess} onClose={onClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditKeyModal from '../EditKeyModal';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
|
||||
|
||||
const keyItem: ServiceaccounttypesFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_abc123',
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
accountId: 'sa-1',
|
||||
keyItem,
|
||||
onClose: jest.fn(),
|
||||
onSuccess: jest.fn(),
|
||||
};
|
||||
|
||||
describe('EditKeyModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.put(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders correctly with initial values', () => {
|
||||
render(<EditKeyModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Original Key Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Expiration')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables save button when name is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<EditKeyModal {...defaultProps} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Enter key name/i);
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'New Key Name');
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls updateKey API and onSuccess on save', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(<EditKeyModal {...defaultProps} onSuccess={onSuccess} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Enter key name/i);
|
||||
await user.type(nameInput, ' Updated');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key updated successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens revoke confirmation and handles revocation', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(<EditKeyModal {...defaultProps} onSuccess={onSuccess} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/Revoking this key will permanently invalidate it/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmRevokeBtn = screen.getByRole('button', { name: /Revoke Key/i });
|
||||
await user.click(confirmRevokeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal when clicking cancel', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onClose = jest.fn();
|
||||
render(<EditKeyModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } 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';
|
||||
|
||||
import KeysTab from '../KeysTab';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
|
||||
|
||||
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_prod_123',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
id: 'key-2',
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
|
||||
key: 'snz_stag_456',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
accountId: 'sa-1',
|
||||
keys,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
onRefetch: jest.fn(),
|
||||
onAddKeyClick: jest.fn(),
|
||||
};
|
||||
|
||||
function renderKeysTab(
|
||||
props: Partial<typeof defaultProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter>
|
||||
<KeysTab {...defaultProps} {...props} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('KeysTab', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
renderKeysTab({ isLoading: true });
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no keys', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAddKeyClick = jest.fn();
|
||||
renderKeysTab({ keys: [], onAddKeyClick });
|
||||
|
||||
expect(
|
||||
screen.getByText(/No keys. Start by creating one./i),
|
||||
).toBeInTheDocument();
|
||||
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
|
||||
await user.click(addBtn);
|
||||
expect(onAddKeyClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders table with keys', () => {
|
||||
renderKeysTab();
|
||||
|
||||
expect(screen.getByText('Production Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staging Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a row opens EditKeyModal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderKeysTab();
|
||||
|
||||
const row = screen.getByText('Production Key').closest('tr');
|
||||
if (!row) {
|
||||
throw new Error('Row not found');
|
||||
}
|
||||
|
||||
await user.click(row);
|
||||
|
||||
await screen.findByRole('dialog', { name: /Edit Key Details/i });
|
||||
});
|
||||
|
||||
it('clicking revoke icon opens confirmation dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderKeysTab();
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
await user.click(revokeBtns[0]);
|
||||
|
||||
await screen.findByRole('dialog', { name: /Revoke Production Key/i });
|
||||
});
|
||||
|
||||
it('handles successful key revocation', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onRefetch = jest.fn();
|
||||
|
||||
renderKeysTab({ onRefetch });
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
await user.click(revokeBtns[0]);
|
||||
|
||||
const confirmBtn = await screen.findByRole('button', { name: /Revoke Key/i });
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables actions when isDisabled is true', () => {
|
||||
renderKeysTab({ isDisabled: true });
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
revokeBtns.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountDrawer, {
|
||||
ServiceAccountDrawerProps,
|
||||
} from '../ServiceAccountDrawer';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_UPDATE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
|
||||
|
||||
const activeAccount: 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',
|
||||
};
|
||||
|
||||
const disabledAccount: ServiceAccountRow = {
|
||||
...activeAccount,
|
||||
id: 'sa-2',
|
||||
status: 'DISABLED',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
props: Partial<ServiceAccountDrawerProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<ServiceAccountDrawer
|
||||
account={activeAccount}
|
||||
onSuccess={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountDrawer', () => {
|
||||
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.put(SA_UPDATE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByDisplayValue('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('editing name enables Save; clicking Save sends correct payload and calls onSuccess', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const updateSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer({ onSuccess });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'CI Bot Updated',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
}),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
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);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('"Disable Service Account" opens confirm dialog; confirming sends correct status and calls onSuccess', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const statusSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
|
||||
statusSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer({ onSuccess });
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Disable Service Account/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /Disable service account CI Bot/i,
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('disabled account shows read-only name, no Save button, no Disable button', () => {
|
||||
renderDrawer({ account: disabledAccount });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Disable Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('CI Bot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switching to Keys tab shows "No keys" empty state', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /Keys/i }));
|
||||
|
||||
await screen.findByText(/No keys/i);
|
||||
});
|
||||
});
|
||||
33
frontend/src/components/ServiceAccountDrawer/utils.ts
Normal file
33
frontend/src/components/ServiceAccountDrawer/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export enum ServiceAccountDrawerTab {
|
||||
Overview = 'overview',
|
||||
Keys = 'keys',
|
||||
}
|
||||
|
||||
export function formatLastObservedAt(
|
||||
lastObservedAt: string | Date | null | undefined,
|
||||
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
|
||||
): string {
|
||||
if (!lastObservedAt) {
|
||||
return '—';
|
||||
}
|
||||
const str =
|
||||
typeof lastObservedAt === 'string'
|
||||
? lastObservedAt
|
||||
: lastObservedAt.toISOString();
|
||||
// Go zero time means the key has never been used
|
||||
if (str.startsWith('0001-01-01')) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(str);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(str, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
}
|
||||
|
||||
export const disabledDate = (current: Dayjs): boolean =>
|
||||
!!current && current < dayjs().startOf('day');
|
||||
@@ -0,0 +1,218 @@
|
||||
.sa-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sa-table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr > td {
|
||||
border-bottom: none !important;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-wrapper,
|
||||
.ant-table-container,
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sa-name-column {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-status-cell {
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-name-email-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
|
||||
.sa-name {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sa-email {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-roles-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sa-dash {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
color: var(--l3-foreground-hover);
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-12) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
color: var(--foreground);
|
||||
|
||||
&__emoji {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sa-pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sa-pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
background-color: var(--bg-slate-500);
|
||||
color: var(--foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: normal;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ant-tooltip-arrow-content {
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-table {
|
||||
.ant-table-tbody {
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
&__text {
|
||||
strong {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import {
|
||||
columns,
|
||||
ServiceAccountsEmptyState,
|
||||
showPaginationTotal,
|
||||
} from './utils';
|
||||
|
||||
import './ServiceAccountsTable.styles.scss';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
interface ServiceAccountsTableProps {
|
||||
data: ServiceAccountRow[];
|
||||
loading: boolean;
|
||||
onRowClick?: (row: ServiceAccountRow) => void;
|
||||
}
|
||||
|
||||
function ServiceAccountsTable({
|
||||
data,
|
||||
loading,
|
||||
onRowClick,
|
||||
}: ServiceAccountsTableProps): JSX.Element {
|
||||
const [currentPage, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''));
|
||||
|
||||
return (
|
||||
<div className="sa-table-wrapper">
|
||||
{/* Todo: use new table component from periscope when ready */}
|
||||
<Table<ServiceAccountRow>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={
|
||||
data.length > PAGE_SIZE
|
||||
? {
|
||||
current: currentPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data.length,
|
||||
showTotal: showPaginationTotal,
|
||||
showSizeChanger: false,
|
||||
onChange: (page: number): void => void setPage(page),
|
||||
className: 'sa-table-pagination',
|
||||
}
|
||||
: false
|
||||
}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'sa-table-row--tinted' : ''
|
||||
}
|
||||
showSorterTooltip={false}
|
||||
locale={{
|
||||
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
|
||||
}}
|
||||
className="sa-table"
|
||||
onRow={(
|
||||
record,
|
||||
): {
|
||||
onClick?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
|
||||
style?: React.CSSProperties;
|
||||
tabIndex?: number;
|
||||
role?: string;
|
||||
'aria-label'?: string;
|
||||
} => {
|
||||
if (!onRowClick) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onClick: (): void => onRowClick(record),
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLElement>): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onRowClick(record);
|
||||
}
|
||||
},
|
||||
style: { cursor: 'pointer' },
|
||||
tabIndex: 0,
|
||||
role: 'button',
|
||||
'aria-label': `View service account ${record.name || record.email}`,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsTable;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsTable from '../ServiceAccountsTable';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
loading: false,
|
||||
onRowClick: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ServiceAccountsTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders name, email, role badge, and ACTIVE status badge', () => {
|
||||
render(<ServiceAccountsTable {...defaultProps} data={[mockActiveAccount]} />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows DISABLED badge and +2 overflow badge for multi-role accounts', () => {
|
||||
render(
|
||||
<ServiceAccountsTable {...defaultProps} data={[mockDisabledAccount]} />,
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const onRowClick = jest.fn() as jest.MockedFunction<
|
||||
(row: ServiceAccountRow) => void
|
||||
>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<ServiceAccountsTable
|
||||
{...defaultProps}
|
||||
data={[mockActiveAccount]}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'sa-1', email: 'ci-bot@signoz.io' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No service accounts" empty state when data is empty and no search query', () => {
|
||||
render(<ServiceAccountsTable {...defaultProps} data={[]} />);
|
||||
|
||||
expect(screen.getByText(/No service accounts/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No results for {query}" empty state when search is active', () => {
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams={{ search: 'ghost' }}>
|
||||
<ServiceAccountsTable {...defaultProps} data={[]} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('ghost')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
133
frontend/src/components/ServiceAccountsTable/utils.tsx
Normal file
133
frontend/src/components/ServiceAccountsTable/utils.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
|
||||
export function NameEmailCell({
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-name-email-cell">
|
||||
{name && (
|
||||
<span className="sa-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title={email} overlayClassName="sa-tooltip">
|
||||
<span className="sa-email">{email}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceAccountsEmptyState({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-empty-state">
|
||||
<span className="sa-empty-state__emoji" role="img" aria-label="monocle face">
|
||||
🧐
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="sa-empty-state__text">
|
||||
No results for <strong>{searchQuery}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="sa-empty-state__text">
|
||||
No service accounts. Start by creating one to manage keys.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const columns: ColumnsType<ServiceAccountRow> = [
|
||||
{
|
||||
title: 'Name / Email',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
className: 'sa-name-column',
|
||||
sorter: (a, b): number => a.email.localeCompare(b.email),
|
||||
render: (_, record): JSX.Element => (
|
||||
<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',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
className: 'sa-status-cell',
|
||||
sorter: (a, b): number =>
|
||||
(a.status?.toUpperCase() === 'ACTIVE' ? 0 : 1) -
|
||||
(b.status?.toUpperCase() === 'ACTIVE' ? 0 : 1),
|
||||
render: (status: string): JSX.Element => <StatusBadge status={status} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const showPaginationTotal = (
|
||||
_total: number,
|
||||
range: number[],
|
||||
): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-pagination-total"> of {_total}</span>
|
||||
</>
|
||||
);
|
||||
@@ -35,4 +35,5 @@ export enum LOCALSTORAGE {
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
@@ -549,49 +548,3 @@ export const DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY: Record<
|
||||
[DataTypes.ArrayBool]: 'boolAttributeValues',
|
||||
[DataTypes.EMPTY]: 'stringAttributeValues',
|
||||
};
|
||||
|
||||
export const listViewInitialLogQuery: Query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
|
||||
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.LIST]: listViewInitialLogQuery,
|
||||
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
|
||||
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
export const listViewInitialTraceQuery: Query = {
|
||||
// it should be the above commented query
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 10,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,6 +86,7 @@ const ROUTES = {
|
||||
METER_EXPLORER_VIEWS: '/meter/explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.panel-type-selection-modal {
|
||||
.graph-selection {
|
||||
.ant-modal-content {
|
||||
width: 515px;
|
||||
max-height: 646px;
|
||||
@@ -76,11 +76,6 @@
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-type-text {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +114,7 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.panel-type-selection-modal {
|
||||
.graph-selection {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
export const PANEL_TYPES_INITIAL_QUERY = {
|
||||
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.LIST]: initialQueriesMap.logs,
|
||||
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
|
||||
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
export const listViewInitialLogQuery: Query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const listViewInitialTraceQuery: Query = {
|
||||
// it should be the above commented query
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 10,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Card, Modal } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { PANEL_TYPES_INITIAL_QUERY } from './constants';
|
||||
import menuItems from './menuItems';
|
||||
import { Text } from './styles';
|
||||
|
||||
import './ComponentSlider.styles.scss';
|
||||
|
||||
function DashboardGraphSlider(): JSX.Element {
|
||||
const { handleToggleDashboardSlider, isDashboardSliderOpen } = useDashboard();
|
||||
|
||||
const onClickHandler = (name: PANEL_TYPES) => (): void => {
|
||||
const id = uuid();
|
||||
handleToggleDashboardSlider(false);
|
||||
logEvent('Dashboard Detail: New panel type selected', {
|
||||
// dashboardId: '',
|
||||
// dashboardName: '',
|
||||
// numberOfPanels: 0, // todo - at this point we don't know these attributes
|
||||
panelType: name,
|
||||
widgetId: id,
|
||||
});
|
||||
const queryParamsLog = {
|
||||
graphType: name,
|
||||
widgetId: id,
|
||||
[QueryParams.compositeQuery]: JSON.stringify({
|
||||
...PANEL_TYPES_INITIAL_QUERY[name],
|
||||
builder: {
|
||||
...PANEL_TYPES_INITIAL_QUERY[name].builder,
|
||||
queryData: [
|
||||
{
|
||||
...PANEL_TYPES_INITIAL_QUERY[name].builder.queryData[0],
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
graphType: name,
|
||||
widgetId: id,
|
||||
[QueryParams.compositeQuery]: JSON.stringify(
|
||||
PANEL_TYPES_INITIAL_QUERY[name],
|
||||
),
|
||||
};
|
||||
if (name === PANEL_TYPES.LIST) {
|
||||
history.push(
|
||||
`${history.location.pathname}/new?${createQueryParams(queryParamsLog)}`,
|
||||
);
|
||||
} else {
|
||||
history.push(
|
||||
`${history.location.pathname}/new?${createQueryParams(queryParams)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = (panelType: PANEL_TYPES): void => {
|
||||
onClickHandler(panelType)();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isDashboardSliderOpen}
|
||||
onCancel={(): void => {
|
||||
handleToggleDashboardSlider(false);
|
||||
}}
|
||||
rootClassName="graph-selection"
|
||||
footer={null}
|
||||
title="New Panel"
|
||||
>
|
||||
<div className="panel-selection">
|
||||
{menuItems.map(({ name, icon, display }) => (
|
||||
<Card onClick={(): void => handleCardClick(name)} id={name} key={name}>
|
||||
{icon}
|
||||
<Text>{display}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardGraphSlider;
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Table,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const PanelTypesWithData: ItemsProps[] = [
|
||||
const Items: ItemsProps[] = [
|
||||
{
|
||||
name: PANEL_TYPES.TIME_SERIES,
|
||||
icon: <LineChart size={16} color={Color.BG_ROBIN_400} />,
|
||||
@@ -52,3 +52,5 @@ export interface ItemsProps {
|
||||
icon: JSX.Element;
|
||||
display: string;
|
||||
}
|
||||
|
||||
export default Items;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Card as CardComponent, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
export const Card = styled(CardComponent)`
|
||||
min-height: 80px;
|
||||
min-width: 120px;
|
||||
overflow-y: auto;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.ant-typography {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
border: 1px solid var(--bg-robin-400);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Text = styled(Typography)`
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
`;
|
||||
@@ -182,7 +182,9 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
|
||||
const mockContextValue: IDashboardContext = {
|
||||
isDashboardSliderOpen: false,
|
||||
isDashboardLocked: false,
|
||||
handleToggleDashboardSlider: jest.fn(),
|
||||
handleDashboardLockToggle: jest.fn(),
|
||||
dashboardResponse: {} as IDashboardContext['dashboardResponse'],
|
||||
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { sortLayout } from 'providers/Dashboard/util';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
@@ -49,10 +48,10 @@ import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardGraphSlider from '../ComponentsSlider';
|
||||
import DashboardSettings from '../DashboardSettings';
|
||||
import { Base64Icons } from '../DashboardSettings/General/utils';
|
||||
import DashboardVariableSelection from '../DashboardVariablesSelection';
|
||||
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
import { VariablesSettingsTab } from './types';
|
||||
import {
|
||||
@@ -70,9 +69,6 @@ interface DashboardDescriptionProps {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const {
|
||||
selectedDashboard,
|
||||
panelMap,
|
||||
@@ -81,6 +77,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
setLayouts,
|
||||
isDashboardLocked,
|
||||
setSelectedDashboard,
|
||||
handleToggleDashboardSlider,
|
||||
handleDashboardLockToggle,
|
||||
} = useDashboard();
|
||||
|
||||
@@ -148,14 +145,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const [addPanelPermission] = useComponentPermission(permissions, userRole);
|
||||
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
handleToggleDashboardSlider(true);
|
||||
logEvent('Dashboard Detail: Add new panel clicked', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
numberOfPanels: selectedDashboard?.data.widgets?.length,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setIsPanelTypeSelectionModalOpen]);
|
||||
}, [handleToggleDashboardSlider]);
|
||||
|
||||
const handleLockDashboardToggle = (): void => {
|
||||
setIsDashbordSettingsOpen(false);
|
||||
@@ -524,7 +521,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
<DashboardVariableSelection />
|
||||
</section>
|
||||
)}
|
||||
<PanelTypeSelectionModal />
|
||||
<DashboardGraphSlider />
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'hooks/dashboard/useDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
@@ -29,7 +30,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable } = useVariablesFromUrl();
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
@@ -49,6 +50,15 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize variables with default values if not in URL
|
||||
initializeDefaultVariables(
|
||||
dashboardVariables,
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
);
|
||||
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
|
||||
|
||||
// Memoize the order key to avoid unnecessary triggers
|
||||
const variableOrderKey = useMemo(() => {
|
||||
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Card, Modal, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { PanelTypesWithData } from './menuItems';
|
||||
|
||||
import './PanelTypeSelectionModal.styles.scss';
|
||||
|
||||
function PanelTypeSelectionModal(): JSX.Element {
|
||||
const {
|
||||
isPanelTypeSelectionModalOpen,
|
||||
setIsPanelTypeSelectionModalOpen,
|
||||
} = usePanelTypeSelectionModalStore();
|
||||
|
||||
const onClickHandler = (name: PANEL_TYPES) => (): void => {
|
||||
const id = uuid();
|
||||
setIsPanelTypeSelectionModalOpen(false);
|
||||
logEvent('Dashboard Detail: New panel type selected', {
|
||||
panelType: name,
|
||||
widgetId: id,
|
||||
});
|
||||
|
||||
const queryParams = {
|
||||
graphType: name,
|
||||
widgetId: id,
|
||||
[QueryParams.compositeQuery]: JSON.stringify(
|
||||
PANEL_TYPES_INITIAL_QUERY[name],
|
||||
),
|
||||
};
|
||||
|
||||
history.push(
|
||||
`${history.location.pathname}/new?${createQueryParams(queryParams)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCardClick = (panelType: PANEL_TYPES): void => {
|
||||
onClickHandler(panelType)();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isPanelTypeSelectionModalOpen}
|
||||
onCancel={(): void => {
|
||||
setIsPanelTypeSelectionModalOpen(false);
|
||||
}}
|
||||
rootClassName="panel-type-selection-modal"
|
||||
footer={null}
|
||||
title="New Panel"
|
||||
>
|
||||
<div className="panel-selection">
|
||||
{PanelTypesWithData.map(({ name, icon, display }) => (
|
||||
<Card onClick={(): void => handleCardClick(name)} id={name} key={name}>
|
||||
{icon}
|
||||
<Typography className="panel-type-text">{display}</Typography>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PanelTypeSelectionModal);
|
||||
@@ -9,18 +9,17 @@ import DashboardSettings from 'container/DashboardContainer/DashboardSettings';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
|
||||
import './DashboardEmptyState.styles.scss';
|
||||
|
||||
export default function DashboardEmptyState(): JSX.Element {
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||
const {
|
||||
selectedDashboard,
|
||||
isDashboardLocked,
|
||||
handleToggleDashboardSlider,
|
||||
} = useDashboard();
|
||||
|
||||
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState<boolean>(
|
||||
@@ -42,14 +41,14 @@ export default function DashboardEmptyState(): JSX.Element {
|
||||
const [addPanelPermission] = useComponentPermission(permissions, userRole);
|
||||
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
handleToggleDashboardSlider(true);
|
||||
logEvent('Dashboard Detail: Add new panel clicked', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
numberOfPanels: selectedDashboard?.data.widgets?.length,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setIsPanelTypeSelectionModalOpen]);
|
||||
}, [handleToggleDashboardSlider]);
|
||||
|
||||
const onConfigureClick = useCallback((): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { Select, Typography } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
|
||||
import GraphTypes from 'container/DashboardContainer/ComponentsSlider/menuItems';
|
||||
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -59,7 +59,7 @@ function PanelTypeSelector({
|
||||
data-testid="panel-change-select"
|
||||
disabled={disabled}
|
||||
>
|
||||
{PanelTypesWithData.map((item) => (
|
||||
{GraphTypes.map((item) => (
|
||||
<Option key={item.name} value={item.name}>
|
||||
<div className="view-panel-select-option">
|
||||
<div className="icon">{item.icon}</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { setSelectedRowWidgetId } from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
@@ -35,11 +34,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
|
||||
} = props;
|
||||
const [isRowSettingsOpen, setIsRowSettingsOpen] = useState<boolean>(false);
|
||||
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||
const {
|
||||
handleToggleDashboardSlider,
|
||||
selectedDashboard,
|
||||
isDashboardLocked,
|
||||
} = useDashboard();
|
||||
|
||||
const permissions: ComponentTypes[] = ['add_panel'];
|
||||
const { user } = useAppContext();
|
||||
@@ -88,7 +87,7 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
|
||||
}
|
||||
|
||||
setSelectedRowWidgetId(selectedDashboard.id, id);
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
handleToggleDashboardSlider(true);
|
||||
}}
|
||||
>
|
||||
New Panel
|
||||
|
||||
@@ -6,16 +6,18 @@ import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
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 { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
@@ -44,7 +46,6 @@ const homeInterval = 30 * 60 * 1000;
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function Home(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
const [endTime, setEndTime] = useState<number | null>(null);
|
||||
@@ -291,6 +292,24 @@ 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),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
leftComponent={
|
||||
@@ -682,11 +701,7 @@ export default function Home(): JSX.Element {
|
||||
|
||||
<div className="checklist-img-container">
|
||||
<img
|
||||
src={
|
||||
isDarkMode
|
||||
? '/Images/allInOne.svg'
|
||||
: '/Images/allInOneLightMode.svg'
|
||||
}
|
||||
src="/Images/allInOne.svg"
|
||||
alt="checklist-img"
|
||||
className="checklist-img"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toISOString } from 'utils/app';
|
||||
|
||||
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
|
||||
|
||||
@@ -61,8 +62,8 @@ function MembersSettings(): JSX.Element {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: user.createdAt ? String(user.createdAt) : null,
|
||||
updatedAt: user?.updatedAt ? String(user.updatedAt) : null,
|
||||
joinedOn: toISOString(user.createdAt),
|
||||
updatedAt: toISOString(user?.updatedAt),
|
||||
}));
|
||||
|
||||
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
|
||||
@@ -72,7 +73,7 @@ function MembersSettings(): JSX.Element {
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
|
||||
joinedOn: toISOString(invite.createdAt),
|
||||
token: invite.token ?? null,
|
||||
}),
|
||||
);
|
||||
@@ -122,6 +123,9 @@ function MembersSettings(): JSX.Element {
|
||||
if (currentPage > maxPage) {
|
||||
setPage(maxPage);
|
||||
}
|
||||
if (currentPage < 1) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [filteredMembers.length, currentPage, setPage]);
|
||||
|
||||
const pendingCount = invitesData?.data?.length ?? 0;
|
||||
@@ -209,6 +213,7 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
<div className="members-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, or role..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
@@ -217,6 +222,7 @@ function MembersSettings(): JSX.Element {
|
||||
}}
|
||||
className="members-search-input"
|
||||
color="secondary"
|
||||
name="members-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
.column-unit-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
|
||||
.heading {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -32,11 +30,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -72,24 +72,22 @@ export function ColumnUnitSelector(
|
||||
return (
|
||||
<section className="column-unit-selector">
|
||||
<Typography.Text className="heading">Column Units</Typography.Text>
|
||||
<div className="column-unit-selector-content">
|
||||
{aggregationQueries.map(({ value, label }) => {
|
||||
const baseQueryName = value.split('.')[0];
|
||||
return (
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
key={value}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{aggregationQueries.map(({ value, label }) => {
|
||||
const baseQueryName = value.split('.')[0];
|
||||
return (
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
key={value}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ describe('ContextLinks Component', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the component renders
|
||||
expect(screen.getByText('Context Links')).toBeInTheDocument();
|
||||
|
||||
// Check that the add button is present
|
||||
expect(
|
||||
screen.getByRole('button', { name: /context link/i }),
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
@@ -134,16 +134,11 @@ function ContextLinks({
|
||||
|
||||
return (
|
||||
<div className="context-links-container">
|
||||
<Typography.Text className="context-links-text">
|
||||
Context Links
|
||||
</Typography.Text>
|
||||
|
||||
<div className="context-links-list">
|
||||
<Button
|
||||
type="default"
|
||||
className="add-context-link-button"
|
||||
icon={<Plus size={12} />}
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleAddContextLink}
|
||||
>
|
||||
Add Context Link
|
||||
</Button>
|
||||
<OverlayScrollbar>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -165,6 +160,16 @@ function ContextLinks({
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</OverlayScrollbar>
|
||||
|
||||
{/* button to add context link */}
|
||||
<Button
|
||||
type="primary"
|
||||
className="add-context-link-button"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={handleAddContextLink}
|
||||
>
|
||||
Context Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.context-links-text {
|
||||
@@ -109,7 +110,10 @@
|
||||
}
|
||||
|
||||
.add-context-link-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.right-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 48px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -25,14 +24,14 @@
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
.control-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-description {
|
||||
padding: 0 0 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 12px 16px 12px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
gap: 8px;
|
||||
|
||||
.typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -89,6 +88,9 @@
|
||||
.panel-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 12px 16px 12px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -102,7 +104,6 @@
|
||||
}
|
||||
|
||||
.panel-type-select {
|
||||
width: 100%;
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
@@ -136,6 +137,7 @@
|
||||
}
|
||||
|
||||
.fill-gaps {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
justify-content: space-between;
|
||||
@@ -154,24 +156,31 @@
|
||||
letter-spacing: 0.52px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fill-gaps-text-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
line-height: 16px; /* 133.333% */
|
||||
}
|
||||
}
|
||||
|
||||
.log-scale,
|
||||
.decimal-precision-selector,
|
||||
.legend-position {
|
||||
.decimal-precision-selector {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-position {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-colors {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel-time-text {
|
||||
margin-top: 16px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
@@ -184,6 +193,7 @@
|
||||
|
||||
.y-axis-unit-selector,
|
||||
.y-axis-unit-selector-v2 {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -268,8 +278,11 @@
|
||||
}
|
||||
|
||||
.stack-chart {
|
||||
flex-direction: row;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
@@ -283,6 +296,11 @@
|
||||
}
|
||||
|
||||
.bucket-config {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
@@ -334,13 +352,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.alerts {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
cursor: pointer;
|
||||
|
||||
.left-section {
|
||||
@@ -366,16 +387,6 @@
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.context-links {
|
||||
padding: 12px 12px 16px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.thresholds-section {
|
||||
padding: 12px 12px 16px 12px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
@@ -407,6 +418,9 @@
|
||||
}
|
||||
|
||||
.name-description {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
@@ -427,6 +441,8 @@
|
||||
}
|
||||
|
||||
.panel-config {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
@@ -462,9 +478,6 @@
|
||||
.fill-gaps-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.fill-gaps-text-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-config {
|
||||
@@ -517,7 +530,7 @@
|
||||
}
|
||||
|
||||
.alerts {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.left-section {
|
||||
.bell-icon {
|
||||
@@ -536,10 +549,6 @@
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.thresholds-section {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.threshold-selector-container {
|
||||
padding: 12px;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.threshold-select {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Antenna, Plus } from 'lucide-react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import Threshold from './Threshold';
|
||||
@@ -68,14 +68,11 @@ function ThresholdSelector({
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="threshold-selector-container">
|
||||
<div className="threshold-select" onClick={addThresholdHandler}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus size={14} />}
|
||||
style={{ width: '100%' }}
|
||||
onClick={addThresholdHandler}
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
<div className="left-section">
|
||||
<Antenna size={14} className="icon" />
|
||||
<Typography.Text className="text">Thresholds</Typography.Text>
|
||||
</div>
|
||||
<Plus size={14} onClick={addThresholdHandler} className="icon" />
|
||||
</div>
|
||||
{thresholds.map((threshold, idx) => (
|
||||
<Threshold
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
.settings-section {
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.settings-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 12px 12px;
|
||||
min-height: 44px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
.settings-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
color: var(--bg-vanilla-400);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section-content {
|
||||
padding: 0 12px 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease;
|
||||
|
||||
&.open {
|
||||
padding-bottom: 24px;
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.settings-section-header {
|
||||
.chevron-icon {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.settings-section-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import './SettingsSection.styles.scss';
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
icon,
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const toggleOpen = (): void => {
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-section-header"
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<span className="settings-section-title">
|
||||
{icon ? icon : null} {title}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={isOpen ? 'chevron-icon open' : 'chevron-icon'}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={
|
||||
isOpen ? 'settings-section-content open' : 'settings-section-content'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsSection;
|
||||
@@ -14,30 +14,23 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
|
||||
import {
|
||||
import GraphTypes, {
|
||||
ItemsProps,
|
||||
PanelTypesWithData,
|
||||
} from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
|
||||
} from 'container/DashboardContainer/ComponentsSlider/menuItems';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
Antenna,
|
||||
Axis3D,
|
||||
ConciergeBell,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LineChart,
|
||||
Link,
|
||||
Pencil,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
Spline,
|
||||
SquareArrowOutUpRight,
|
||||
} from 'lucide-react';
|
||||
@@ -53,7 +46,6 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
||||
import SettingsSection from './components/SettingsSection/SettingsSection';
|
||||
import {
|
||||
panelTypeVsBucketConfig,
|
||||
panelTypeVsColumnUnitPreferences,
|
||||
@@ -151,7 +143,7 @@ function RightContainer({
|
||||
);
|
||||
|
||||
const selectedGraphType =
|
||||
PanelTypesWithData.find((e) => e.name === selectedGraph)?.display || '';
|
||||
GraphTypes.find((e) => e.name === selectedGraph)?.display || '';
|
||||
|
||||
const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView');
|
||||
|
||||
@@ -177,7 +169,7 @@ function RightContainer({
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(PanelTypesWithData);
|
||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
|
||||
|
||||
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
|
||||
return Object.entries(dashboardVariables).map(([, value]) => ({
|
||||
@@ -186,21 +178,6 @@ function RightContainer({
|
||||
}));
|
||||
}, [dashboardVariables]);
|
||||
|
||||
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
|
||||
allowSoftMinMax,
|
||||
allowLogScale,
|
||||
]);
|
||||
|
||||
const isFormattingSectionVisible = useMemo(
|
||||
() => allowYAxisUnit || allowDecimalPrecision || allowPanelColumnPreference,
|
||||
[allowYAxisUnit, allowDecimalPrecision, allowPanelColumnPreference],
|
||||
);
|
||||
|
||||
const isLegendSectionVisible = useMemo(
|
||||
() => allowLegendPosition || allowLegendColors,
|
||||
[allowLegendPosition, allowLegendColors],
|
||||
);
|
||||
|
||||
const updateCursorAndDropdown = (value: string, pos: number): void => {
|
||||
setCursorPos(pos);
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
@@ -216,15 +193,6 @@ function RightContainer({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const decimapPrecisionOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
||||
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
|
||||
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
|
||||
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
|
||||
];
|
||||
}, []);
|
||||
|
||||
const handleInputCursor = (): void => {
|
||||
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||
updateCursorAndDropdown(inputValue, pos);
|
||||
@@ -273,7 +241,7 @@ function RightContainer({
|
||||
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
|
||||
);
|
||||
} else {
|
||||
setGraphTypes(PanelTypesWithData);
|
||||
setGraphTypes(GraphTypes);
|
||||
}
|
||||
}, [currentQuery]);
|
||||
|
||||
@@ -295,297 +263,269 @@ function RightContainer({
|
||||
<div className="right-container">
|
||||
<section className="header">
|
||||
<div className="purple-dot" />
|
||||
<Typography.Text className="header-text">Panel Settings</Typography.Text>
|
||||
<Typography.Text className="header-text">Panel details</Typography.Text>
|
||||
</section>
|
||||
|
||||
<SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
|
||||
<section className="name-description control-container">
|
||||
<Typography.Text className="typography">Name</Typography.Text>
|
||||
<AutoComplete
|
||||
options={dashboardVariableOptions}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onSelect={onSelect}
|
||||
filterOption={filterOption}
|
||||
style={{ width: '100%' }}
|
||||
getPopupContainer={popupContainer}
|
||||
placeholder="Enter the panel name here..."
|
||||
open={autoCompleteOpen}
|
||||
>
|
||||
<Input
|
||||
rootClassName="name-input"
|
||||
ref={inputRef}
|
||||
onSelect={handleInputCursor}
|
||||
onClick={handleInputCursor}
|
||||
onBlur={(): void => setAutoCompleteOpen(false)}
|
||||
/>
|
||||
</AutoComplete>
|
||||
<Typography.Text className="typography">Description</Typography.Text>
|
||||
<TextArea
|
||||
placeholder="Enter the panel description here..."
|
||||
bordered
|
||||
allowClear
|
||||
value={description}
|
||||
onChange={(event): void =>
|
||||
onChangeHandler(setDescription, event.target.value)
|
||||
}
|
||||
rootClassName="description-input"
|
||||
/>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<section className="panel-config">
|
||||
<SettingsSection
|
||||
title="Visualization"
|
||||
defaultOpen
|
||||
icon={<LayoutDashboard size={14} />}
|
||||
<section className="name-description">
|
||||
<Typography.Text className="typography">Name</Typography.Text>
|
||||
<AutoComplete
|
||||
options={dashboardVariableOptions}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onSelect={onSelect}
|
||||
filterOption={filterOption}
|
||||
style={{ width: '100%' }}
|
||||
getPopupContainer={popupContainer}
|
||||
placeholder="Enter the panel name here..."
|
||||
open={autoCompleteOpen}
|
||||
>
|
||||
<section className="panel-type control-container">
|
||||
<Typography.Text className="typography">Panel Type</Typography.Text>
|
||||
<Select
|
||||
onChange={setGraphHandler}
|
||||
value={selectedGraph}
|
||||
className="panel-type-select"
|
||||
data-testid="panel-change-select"
|
||||
data-stacking-state={stackedBarChart ? 'true' : 'false'}
|
||||
>
|
||||
{graphTypes.map((item) => (
|
||||
<Option key={item.name} value={item.name}>
|
||||
<div className="select-option">
|
||||
<div className="icon">{item.icon}</div>
|
||||
<Typography.Text className="display">{item.display}</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</section>
|
||||
|
||||
{allowPanelTimePreference && (
|
||||
<section className="panel-time-preference control-container">
|
||||
<Typography.Text className="panel-time-text">
|
||||
Panel Time Preference
|
||||
</Typography.Text>
|
||||
<TimePreference
|
||||
{...{
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowStackingBarChart && (
|
||||
<section className="stack-chart control-container">
|
||||
<Typography.Text className="label">Stack series</Typography.Text>
|
||||
<Switch
|
||||
checked={stackedBarChart}
|
||||
size="small"
|
||||
onChange={(checked): void => setStackedBarChart(checked)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowFillSpans && (
|
||||
<section className="fill-gaps">
|
||||
<div className="fill-gaps-text-container">
|
||||
<Typography className="fill-gaps-text">Fill gaps</Typography>
|
||||
<Typography.Text className="fill-gaps-text-description">
|
||||
Fill gaps in data with 0 for continuity
|
||||
</Typography.Text>
|
||||
<Input
|
||||
rootClassName="name-input"
|
||||
ref={inputRef}
|
||||
onSelect={handleInputCursor}
|
||||
onClick={handleInputCursor}
|
||||
onBlur={(): void => setAutoCompleteOpen(false)}
|
||||
/>
|
||||
</AutoComplete>
|
||||
<Typography.Text className="typography">Description</Typography.Text>
|
||||
<TextArea
|
||||
placeholder="Enter the panel description here..."
|
||||
bordered
|
||||
allowClear
|
||||
value={description}
|
||||
onChange={(event): void =>
|
||||
onChangeHandler(setDescription, event.target.value)
|
||||
}
|
||||
rootClassName="description-input"
|
||||
/>
|
||||
</section>
|
||||
<section className="panel-config">
|
||||
<Typography.Text className="typography">Panel Type</Typography.Text>
|
||||
<Select
|
||||
onChange={setGraphHandler}
|
||||
value={selectedGraph}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
data-testid="panel-change-select"
|
||||
data-stacking-state={stackedBarChart ? 'true' : 'false'}
|
||||
>
|
||||
{graphTypes.map((item) => (
|
||||
<Option key={item.name} value={item.name}>
|
||||
<div className="select-option">
|
||||
<div className="icon">{item.icon}</div>
|
||||
<Typography.Text className="display">{item.display}</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isFillSpans}
|
||||
size="small"
|
||||
onChange={(checked): void => setIsFillSpans(checked)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{allowFillSpans && (
|
||||
<Space className="fill-gaps">
|
||||
<Typography className="fill-gaps-text">Fill gaps</Typography>
|
||||
<Switch
|
||||
checked={isFillSpans}
|
||||
size="small"
|
||||
onChange={(checked): void => setIsFillSpans(checked)}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{allowPanelTimePreference && (
|
||||
<>
|
||||
<Typography.Text className="panel-time-text">
|
||||
Panel Time Preference
|
||||
</Typography.Text>
|
||||
<TimePreference
|
||||
{...{
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allowPanelColumnPreference && (
|
||||
<ColumnUnitSelector
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowYAxisUnit && (
|
||||
<DashboardYAxisUnitSelectorWrapper
|
||||
onSelect={setYAxisUnit}
|
||||
value={yAxisUnit || ''}
|
||||
fieldLabel={
|
||||
selectedGraphType === PanelDisplay.VALUE ||
|
||||
selectedGraphType === PanelDisplay.PIE
|
||||
? 'Unit'
|
||||
: 'Y Axis Unit'
|
||||
}
|
||||
// Only update the y-axis unit value automatically in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDecimalPrecision && (
|
||||
<section className="decimal-precision-selector">
|
||||
<Typography.Text className="typography">
|
||||
Decimal Precision
|
||||
</Typography.Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
||||
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
|
||||
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
|
||||
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
|
||||
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
|
||||
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
|
||||
]}
|
||||
value={decimalPrecision}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={PrecisionOptionsEnum.TWO}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowSoftMinMax && (
|
||||
<section className="soft-min-max">
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Min</Typography.Text>
|
||||
<InputNumber
|
||||
type="number"
|
||||
value={softMin}
|
||||
onChange={softMinHandler}
|
||||
rootClassName="input"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{isFormattingSectionVisible && (
|
||||
<SettingsSection
|
||||
title="Formatting & Units"
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
>
|
||||
{allowYAxisUnit && (
|
||||
<DashboardYAxisUnitSelectorWrapper
|
||||
onSelect={setYAxisUnit}
|
||||
value={yAxisUnit || ''}
|
||||
fieldLabel={
|
||||
selectedGraphType === PanelDisplay.VALUE ||
|
||||
selectedGraphType === PanelDisplay.PIE
|
||||
? 'Unit'
|
||||
: 'Y Axis Unit'
|
||||
}
|
||||
// Only update the y-axis unit value automatically in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Max</Typography.Text>
|
||||
<InputNumber
|
||||
value={softMax}
|
||||
type="number"
|
||||
rootClassName="input"
|
||||
onChange={softMaxHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDecimalPrecision && (
|
||||
<section className="decimal-precision-selector control-container">
|
||||
<Typography.Text className="typography">
|
||||
Decimal Precision
|
||||
</Typography.Text>
|
||||
<Select
|
||||
options={decimapPrecisionOptions}
|
||||
value={decimalPrecision}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={PrecisionOptionsEnum.TWO}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowPanelColumnPreference && (
|
||||
<ColumnUnitSelector
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isAxisSectionVisible && (
|
||||
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
|
||||
{allowSoftMinMax && (
|
||||
<section className="soft-min-max">
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Min</Typography.Text>
|
||||
<InputNumber
|
||||
type="number"
|
||||
value={softMin}
|
||||
onChange={softMinHandler}
|
||||
rootClassName="input"
|
||||
/>
|
||||
</section>
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Max</Typography.Text>
|
||||
<InputNumber
|
||||
value={softMax}
|
||||
type="number"
|
||||
rootClassName="input"
|
||||
onChange={softMaxHandler}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowLogScale && (
|
||||
<section className="log-scale control-container">
|
||||
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
|
||||
<Select
|
||||
onChange={(value): void =>
|
||||
setIsLogScale(value === LogScale.LOGARITHMIC)
|
||||
}
|
||||
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={LogScale.LINEAR}
|
||||
>
|
||||
<Option value={LogScale.LINEAR}>
|
||||
<div className="select-option">
|
||||
<div className="icon">
|
||||
<LineChart size={16} />
|
||||
</div>
|
||||
<Typography.Text className="display">Linear</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value={LogScale.LOGARITHMIC}>
|
||||
<div className="select-option">
|
||||
<div className="icon">
|
||||
<Spline size={16} />
|
||||
</div>
|
||||
<Typography.Text className="display">Logarithmic</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</section>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{isLegendSectionVisible && (
|
||||
<SettingsSection title="Legend" icon={<Layers size={14} />}>
|
||||
{allowLegendPosition && (
|
||||
<section className="legend-position control-container">
|
||||
<Typography.Text className="typography">Position</Typography.Text>
|
||||
<Select
|
||||
onChange={(value: LegendPosition): void => setLegendPosition(value)}
|
||||
value={legendPosition}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={LegendPosition.BOTTOM}
|
||||
>
|
||||
<Option value={LegendPosition.BOTTOM}>
|
||||
<div className="select-option">
|
||||
<Typography.Text className="display">Bottom</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value={LegendPosition.RIGHT}>
|
||||
<div className="select-option">
|
||||
<Typography.Text className="display">Right</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowLegendColors && (
|
||||
<section className="legend-colors">
|
||||
<LegendColors
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</SettingsSection>
|
||||
{allowStackingBarChart && (
|
||||
<section className="stack-chart">
|
||||
<Typography.Text className="label">Stack series</Typography.Text>
|
||||
<Switch
|
||||
checked={stackedBarChart}
|
||||
size="small"
|
||||
onChange={(checked): void => setStackedBarChart(checked)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowBucketConfig && (
|
||||
<SettingsSection title="Histogram / Buckets">
|
||||
<section className="bucket-config control-container">
|
||||
<Typography.Text className="label">Number of buckets</Typography.Text>
|
||||
<InputNumber
|
||||
value={bucketCount || null}
|
||||
type="number"
|
||||
min={0}
|
||||
rootClassName="bucket-input"
|
||||
placeholder="Default: 30"
|
||||
onChange={(val): void => {
|
||||
setBucketCount(val || 0);
|
||||
}}
|
||||
/>
|
||||
<Typography.Text className="label bucket-size-label">
|
||||
Bucket width
|
||||
<section className="bucket-config">
|
||||
<Typography.Text className="label">Number of buckets</Typography.Text>
|
||||
<InputNumber
|
||||
value={bucketCount || null}
|
||||
type="number"
|
||||
min={0}
|
||||
rootClassName="bucket-input"
|
||||
placeholder="Default: 30"
|
||||
onChange={(val): void => {
|
||||
setBucketCount(val || 0);
|
||||
}}
|
||||
/>
|
||||
<Typography.Text className="label bucket-size-label">
|
||||
Bucket width
|
||||
</Typography.Text>
|
||||
<InputNumber
|
||||
value={bucketWidth || null}
|
||||
type="number"
|
||||
precision={2}
|
||||
placeholder="Default: Auto"
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
rootClassName="bucket-input"
|
||||
onChange={(val): void => {
|
||||
setBucketWidth(val || 0);
|
||||
}}
|
||||
/>
|
||||
<section className="combine-hist">
|
||||
<Typography.Text className="label">
|
||||
Merge all series into one
|
||||
</Typography.Text>
|
||||
<InputNumber
|
||||
value={bucketWidth || null}
|
||||
type="number"
|
||||
precision={2}
|
||||
placeholder="Default: Auto"
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
rootClassName="bucket-input"
|
||||
onChange={(val): void => {
|
||||
setBucketWidth(val || 0);
|
||||
}}
|
||||
<Switch
|
||||
checked={combineHistogram}
|
||||
size="small"
|
||||
onChange={(checked): void => setCombineHistogram(checked)}
|
||||
/>
|
||||
<section className="combine-hist">
|
||||
<Typography.Text className="label">
|
||||
Merge all series into one
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={combineHistogram}
|
||||
size="small"
|
||||
onChange={(checked): void => setCombineHistogram(checked)}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowLogScale && (
|
||||
<section className="log-scale">
|
||||
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
|
||||
<Select
|
||||
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
|
||||
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={LogScale.LINEAR}
|
||||
>
|
||||
<Option value={LogScale.LINEAR}>
|
||||
<div className="select-option">
|
||||
<div className="icon">
|
||||
<LineChart size={16} />
|
||||
</div>
|
||||
<Typography.Text className="display">Linear</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value={LogScale.LOGARITHMIC}>
|
||||
<div className="select-option">
|
||||
<div className="icon">
|
||||
<Spline size={16} />
|
||||
</div>
|
||||
<Typography.Text className="display">Logarithmic</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowLegendPosition && (
|
||||
<section className="legend-position">
|
||||
<Typography.Text className="typography">Legend Position</Typography.Text>
|
||||
<Select
|
||||
onChange={(value: LegendPosition): void => setLegendPosition(value)}
|
||||
value={legendPosition}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={LegendPosition.BOTTOM}
|
||||
>
|
||||
<Option value={LegendPosition.BOTTOM}>
|
||||
<div className="select-option">
|
||||
<Typography.Text className="display">Bottom</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value={LegendPosition.RIGHT}>
|
||||
<div className="select-option">
|
||||
<Typography.Text className="display">Right</Typography.Text>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowLegendColors && (
|
||||
<section className="legend-colors">
|
||||
<LegendColors
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -601,25 +541,17 @@ function RightContainer({
|
||||
)}
|
||||
|
||||
{allowContextLinks && (
|
||||
<SettingsSection
|
||||
title="Context Links"
|
||||
icon={<Link size={14} />}
|
||||
defaultOpen={!!contextLinks.linksData.length}
|
||||
>
|
||||
<section className="context-links">
|
||||
<ContextLinks
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
selectedWidget={selectedWidget}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowThreshold && (
|
||||
<SettingsSection
|
||||
title="Thresholds"
|
||||
icon={<Antenna size={14} />}
|
||||
defaultOpen={!!thresholds.length}
|
||||
>
|
||||
<section>
|
||||
<ThresholdSelector
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
@@ -627,7 +559,7 @@ function RightContainer({
|
||||
selectedGraph={selectedGraph}
|
||||
columnUnits={columnUnits}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ const checkStackSeriesState = (
|
||||
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
|
||||
|
||||
const stackSeriesSection = container.querySelector(
|
||||
'.stack-chart',
|
||||
'section > .stack-chart',
|
||||
) as HTMLElement;
|
||||
expect(stackSeriesSection).toBeInTheDocument();
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
|
||||
expect(getByText('Stack series')).toBeInTheDocument();
|
||||
|
||||
// Verify section exists
|
||||
const section = container.querySelector('.stack-chart');
|
||||
const section = container.querySelector('section > .stack-chart');
|
||||
expect(section).toBeInTheDocument();
|
||||
|
||||
// Verify switch is present and enabled (ant-switch-checked)
|
||||
|
||||
@@ -439,19 +439,6 @@ function NewWidget({
|
||||
globalSelectedInterval,
|
||||
]);
|
||||
|
||||
const navigateToDashboardPage = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const urlVariablesQueryString = query.get(QueryParams.variables);
|
||||
if (urlVariablesQueryString) {
|
||||
params.set(QueryParams.variables, urlVariablesQueryString);
|
||||
}
|
||||
|
||||
const search = params.toString() ? `?${params.toString()}` : '';
|
||||
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }) + search);
|
||||
}, [dashboardId, query, safeNavigate]);
|
||||
|
||||
const onClickSaveHandler = useCallback(() => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
@@ -567,7 +554,9 @@ function NewWidget({
|
||||
updateDashboardMutation.mutateAsync(dashboard, {
|
||||
onSuccess: () => {
|
||||
setToScrollWidgetId(selectedWidget?.id || '');
|
||||
navigateToDashboardPage();
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [
|
||||
@@ -583,7 +572,7 @@ function NewWidget({
|
||||
updateDashboardMutation,
|
||||
widgets,
|
||||
setToScrollWidgetId,
|
||||
navigateToDashboardPage,
|
||||
safeNavigate,
|
||||
dashboardId,
|
||||
]);
|
||||
|
||||
@@ -592,12 +581,12 @@ function NewWidget({
|
||||
setDiscardModal(true);
|
||||
return;
|
||||
}
|
||||
navigateToDashboardPage();
|
||||
}, [isQueryModified, navigateToDashboardPage]);
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [dashboardId, isQueryModified, safeNavigate]);
|
||||
|
||||
const discardChanges = useCallback(() => {
|
||||
navigateToDashboardPage();
|
||||
}, [navigateToDashboardPage]);
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [dashboardId, safeNavigate]);
|
||||
|
||||
const setGraphHandler = (type: PANEL_TYPES): void => {
|
||||
setIsLoadingPanelData(true);
|
||||
@@ -739,14 +728,12 @@ function NewWidget({
|
||||
}
|
||||
const widgetId = query.get('widgetId') || '';
|
||||
const graphType = query.get('graphType') || '';
|
||||
const variables = query.get(QueryParams.variables) || '';
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.graphType]: graphType,
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
[QueryParams.variables]: variables,
|
||||
};
|
||||
|
||||
const updatedSearch = createQueryParams(queryParams);
|
||||
@@ -835,54 +822,56 @@ function NewWidget({
|
||||
</LeftContainerWrapper>
|
||||
|
||||
<RightContainerWrapper>
|
||||
<RightContainer
|
||||
setGraphHandler={setGraphHandler}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
stackedBarChart={stackedBarChart}
|
||||
setStackedBarChart={setStackedBarChart}
|
||||
opacity={opacity}
|
||||
yAxisUnit={yAxisUnit}
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
bucketCount={bucketCount}
|
||||
bucketWidth={bucketWidth}
|
||||
combineHistogram={combineHistogram}
|
||||
setCombineHistogram={setCombineHistogram}
|
||||
setBucketWidth={setBucketWidth}
|
||||
setBucketCount={setBucketCount}
|
||||
setOpacity={setOpacity}
|
||||
selectedNullZeroValue={selectedNullZeroValue}
|
||||
setSelectedNullZeroValue={setSelectedNullZeroValue}
|
||||
selectedGraph={graphType}
|
||||
setSelectedTime={setSelectedTime}
|
||||
selectedTime={selectedTime}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
setDecimalPrecision={setDecimalPrecision}
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
selectedWidget={selectedWidget}
|
||||
isFillSpans={isFillSpans}
|
||||
setIsFillSpans={setIsFillSpans}
|
||||
isLogScale={isLogScale}
|
||||
setIsLogScale={setIsLogScale}
|
||||
legendPosition={legendPosition}
|
||||
setLegendPosition={setLegendPosition}
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
softMin={softMin}
|
||||
setSoftMin={setSoftMin}
|
||||
softMax={softMax}
|
||||
setSoftMax={setSoftMax}
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
<OverlayScrollbar>
|
||||
<RightContainer
|
||||
setGraphHandler={setGraphHandler}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
stackedBarChart={stackedBarChart}
|
||||
setStackedBarChart={setStackedBarChart}
|
||||
opacity={opacity}
|
||||
yAxisUnit={yAxisUnit}
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
bucketCount={bucketCount}
|
||||
bucketWidth={bucketWidth}
|
||||
combineHistogram={combineHistogram}
|
||||
setCombineHistogram={setCombineHistogram}
|
||||
setBucketWidth={setBucketWidth}
|
||||
setBucketCount={setBucketCount}
|
||||
setOpacity={setOpacity}
|
||||
selectedNullZeroValue={selectedNullZeroValue}
|
||||
setSelectedNullZeroValue={setSelectedNullZeroValue}
|
||||
selectedGraph={graphType}
|
||||
setSelectedTime={setSelectedTime}
|
||||
selectedTime={selectedTime}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
setDecimalPrecision={setDecimalPrecision}
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
selectedWidget={selectedWidget}
|
||||
isFillSpans={isFillSpans}
|
||||
setIsFillSpans={setIsFillSpans}
|
||||
isLogScale={isLogScale}
|
||||
setIsLogScale={setIsLogScale}
|
||||
legendPosition={legendPosition}
|
||||
setLegendPosition={setLegendPosition}
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
softMin={softMin}
|
||||
setSoftMin={setSoftMin}
|
||||
softMax={softMax}
|
||||
setSoftMax={setSoftMax}
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</RightContainerWrapper>
|
||||
</PanelContainer>
|
||||
<Modal
|
||||
|
||||
@@ -15,14 +15,7 @@ export const RightContainerWrapper = styled(Col)`
|
||||
overflow-y: auto;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(136, 136, 136);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
width: 0rem;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
PANEL_TYPES_INITIAL_QUERY,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
listViewInitialLogQuery,
|
||||
PANEL_TYPES_INITIAL_QUERY,
|
||||
} from 'container/DashboardContainer/ComponentsSlider/constants';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
@@ -546,7 +549,10 @@ export const getDefaultWidgetData = (
|
||||
nullZeroValues: '',
|
||||
opacity: '',
|
||||
panelTypes: name,
|
||||
query: PANEL_TYPES_INITIAL_QUERY[name],
|
||||
query:
|
||||
name === PANEL_TYPES.LIST
|
||||
? listViewInitialLogQuery
|
||||
: PANEL_TYPES_INITIAL_QUERY[name],
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
usePatchRole,
|
||||
} from 'api/generated/services/role';
|
||||
import {
|
||||
AuthtypesPostableRoleDTO,
|
||||
RenderErrorResponseDTO,
|
||||
RoletypesPostableRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -114,7 +114,7 @@ function CreateRoleModal({
|
||||
data: { description: values.description || '' },
|
||||
});
|
||||
} else {
|
||||
const data: AuthtypesPostableRoleDTO = {
|
||||
const data: RoletypesPostableRoleDTO = {
|
||||
name: values.name,
|
||||
...(values.description ? { description: values.description } : {}),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -20,7 +20,7 @@ const PAGE_SIZE = 20;
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'section'; label: string; count?: number }
|
||||
| { type: 'role'; role: AuthtypesRoleDTO };
|
||||
| { type: 'role'; role: RoletypesRoleDTO };
|
||||
|
||||
interface RolesListingTableProps {
|
||||
searchQuery: string;
|
||||
@@ -187,7 +187,7 @@ function RolesListingTable({
|
||||
};
|
||||
|
||||
// todo: use table from periscope when its available for consumption
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
const renderRow = (role: RoletypesRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`roles-table-row ${
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
.sa-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--label-large-500-font-size);
|
||||
font-weight: var(--label-large-500-font-weight);
|
||||
color: var(--text-base-white);
|
||||
letter-spacing: -0.09px;
|
||||
line-height: var(--line-height-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-status-badge {
|
||||
color: var(--l3-foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.sa-settings-filter-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background);
|
||||
|
||||
> span {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-dropdown {
|
||||
.ant-dropdown-menu {
|
||||
padding: var(--padding-3) 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--l2-background);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
background: transparent !important;
|
||||
padding: var(--padding-1) 0 !important;
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.14px;
|
||||
min-width: 170px;
|
||||
|
||||
&:hover {
|
||||
color: var(--card-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-search-input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-settings {
|
||||
&__title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-option {
|
||||
&:hover {
|
||||
color: var(--bg-neutral-light-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable, {
|
||||
PAGE_SIZE,
|
||||
} from 'components/ServiceAccountsTable/ServiceAccountsTable';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import { toISOString } from 'utils/app';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { FilterMode, ServiceAccountRow, ServiceAccountStatus } from './utils';
|
||||
|
||||
import './ServiceAccountsSettings.styles.scss';
|
||||
|
||||
function ServiceAccountsSettings(): JSX.Element {
|
||||
const [currentPage, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'search',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const [filterMode, setFilterMode] = useQueryState(
|
||||
'filter',
|
||||
parseAsStringEnum<FilterMode>(Object.values(FilterMode)).withDefault(
|
||||
FilterMode.All,
|
||||
),
|
||||
);
|
||||
const [selectedAccountId, setSelectedAccountId] = useQueryState('account');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts();
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
(serviceAccountsData?.data ?? []).map((sa) => ({
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
email: sa.email,
|
||||
roles: sa.roles,
|
||||
status: sa.status,
|
||||
createdAt: toISOString(sa.createdAt),
|
||||
updatedAt: toISOString(sa.updatedAt),
|
||||
})),
|
||||
[serviceAccountsData],
|
||||
);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const disabledCount = useMemo(
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const filteredAccounts = useMemo((): ServiceAccountRow[] => {
|
||||
let result = allAccounts;
|
||||
|
||||
if (filterMode === FilterMode.Active) {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
);
|
||||
} else if (filterMode === FilterMode.Disabled) {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allAccounts, filterMode, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredAccounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
|
||||
if (currentPage > maxPage) {
|
||||
setPage(maxPage);
|
||||
} else if (currentPage < 1) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [filteredAccounts.length, currentPage, setPage]);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => allAccounts.find((a) => a.id === selectedAccountId) ?? null,
|
||||
[allAccounts, selectedAccountId],
|
||||
);
|
||||
|
||||
const totalCount = allAccounts.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>All accounts ⎯ {totalCount}</span>
|
||||
{filterMode === FilterMode.All && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.All);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Active,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Active ⎯ {activeCount}</span>
|
||||
{filterMode === FilterMode.Active && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Active);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Disabled,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Disabled ⎯ {disabledCount}</span>
|
||||
{filterMode === FilterMode.Disabled && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Disabled);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function getFilterLabel(): string {
|
||||
switch (filterMode) {
|
||||
case FilterMode.Active:
|
||||
return `Active ⎯ ${activeCount}`;
|
||||
case FilterMode.Disabled:
|
||||
return `Disabled ⎯ ${disabledCount}`;
|
||||
default:
|
||||
return `All accounts ⎯ ${totalCount}`;
|
||||
}
|
||||
}
|
||||
const filterLabel = getFilterLabel();
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ServiceAccountRow): void => {
|
||||
setSelectedAccountId(row.id);
|
||||
},
|
||||
[setSelectedAccountId],
|
||||
);
|
||||
|
||||
const handleDrawerSuccess = useCallback(
|
||||
(options?: { closeDrawer?: boolean }): void => {
|
||||
if (options?.closeDrawer) {
|
||||
setSelectedAccountId(null);
|
||||
}
|
||||
handleCreateSuccess();
|
||||
},
|
||||
[handleCreateSuccess, setSelectedAccountId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sa-settings">
|
||||
<div className="sa-settings__header">
|
||||
<h1 className="sa-settings__title">Service Accounts</h1>
|
||||
<p className="sa-settings__subtitle">
|
||||
Overview of service accounts added to this workspace.{' '}
|
||||
{/* Todo: to add doc links */}
|
||||
{/* <a
|
||||
href="https://signoz.io/docs/service-accounts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sa-settings__learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a> */}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
color="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching service accounts.',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ServiceAccountsTable
|
||||
data={filteredAccounts}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateServiceAccountModal
|
||||
open={isCreateModalOpen}
|
||||
onClose={(): void => setIsCreateModalOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<ServiceAccountDrawer
|
||||
account={selectedAccount}
|
||||
onSuccess={handleDrawerSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsSettings;
|
||||
@@ -0,0 +1,191 @@
|
||||
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 ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockServiceAccountsAPI = [
|
||||
{
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1700000001,
|
||||
},
|
||||
{
|
||||
id: 'sa-2',
|
||||
name: 'Monitoring Agent',
|
||||
email: 'monitor@signoz.io',
|
||||
roles: ['signoz-viewer'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: 1700000002,
|
||||
updatedAt: 1700000003,
|
||||
},
|
||||
{
|
||||
id: 'sa-3',
|
||||
name: 'Legacy Bot',
|
||||
email: 'legacy@signoz.io',
|
||||
roles: ['signoz-editor'],
|
||||
status: 'DISABLED',
|
||||
createdAt: 1700000004,
|
||||
updatedAt: 1700000005,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ServiceAccountsSettings (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
|
||||
),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('loads and displays all accounts with correct ACTIVE and DISABLED badges', async () => {
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.getByText('Monitoring Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('legacy@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
expect(screen.getByText('DISABLED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
await user.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search by name filters accounts in real-time', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'legacy',
|
||||
);
|
||||
|
||||
await screen.findByText('Legacy Bot');
|
||||
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitoring Agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a row opens the drawer with account details visible', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /Disable Service Account/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
);
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when API fails', async () => {
|
||||
server.use(
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ message: 'Internal Server Error' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/An unexpected error occurred while fetching service accounts/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
1
frontend/src/container/ServiceAccountsSettings/config.ts
Normal file
1
frontend/src/container/ServiceAccountsSettings/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const IS_SERVICE_ACCOUNTS_ENABLED = false;
|
||||
20
frontend/src/container/ServiceAccountsSettings/utils.ts
Normal file
20
frontend/src/container/ServiceAccountsSettings/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
}
|
||||
|
||||
export enum ServiceAccountStatus {
|
||||
Active = 'ACTIVE',
|
||||
Disabled = 'DISABLED',
|
||||
}
|
||||
|
||||
export interface ServiceAccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BellDot,
|
||||
Binoculars,
|
||||
Book,
|
||||
Bot,
|
||||
Boxes,
|
||||
BugIcon,
|
||||
Building2,
|
||||
@@ -358,6 +359,13 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'members',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
label: 'Service Accounts',
|
||||
icon: <Bot size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'service-accounts',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
|
||||
@@ -154,6 +154,7 @@ export const routesToSkip = [
|
||||
ROUTES.ALL_DASHBOARD,
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
|
||||
@@ -30,7 +30,6 @@ import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -235,7 +234,20 @@ function DateTimeSelection({
|
||||
|
||||
const updateLocalStorageForRoutes = useCallback(
|
||||
(value: Time | string): void => {
|
||||
persistTimeDurationForRoute(location.pathname, String(value));
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
if (preRoutes !== null) {
|
||||
const preRoutesObject = JSON.parse(preRoutes);
|
||||
|
||||
const preRoute = {
|
||||
...preRoutesObject,
|
||||
};
|
||||
preRoute[location.pathname] = value;
|
||||
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
);
|
||||
}
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
@@ -726,7 +738,6 @@ function DateTimeSelection({
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
minTime={minTimeForDateTimePicker}
|
||||
maxTime={maxTimeForDateTimePicker}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
/>
|
||||
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { useZoomOut } from '../useZoomOut';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryToString = jest.fn(() => '');
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: <T>(selector: (state: MockAppState) => T): T => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: 15 * 60 * 1000 * 1e6, // 15 min in nanoseconds
|
||||
maxTime: 30 * 60 * 1000 * 1e6, // 30 min in nanoseconds (mock for getNextZoomOutRange)
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: typeof mockUrlQueryToString;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: mockUrlQueryToString,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetNextZoomOutRange = jest.fn();
|
||||
jest.mock('lib/zoomOutUtils', () => ({
|
||||
getNextZoomOutRange: (
|
||||
...args: unknown[]
|
||||
): ReturnType<typeof mockGetNextZoomOutRange> =>
|
||||
mockGetNextZoomOutRange(...args),
|
||||
}));
|
||||
|
||||
describe('useZoomOut', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQueryToString.mockReturnValue('relativeTime=45m');
|
||||
});
|
||||
|
||||
it('should do nothing when isDisabled is true', () => {
|
||||
const { result } = renderHook(() => useZoomOut({ isDisabled: true }));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when getNextZoomOutRange returns null', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispatch preset and update URL when result has preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom range and update URL when result has no preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000000, 2000000],
|
||||
preset: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(
|
||||
QueryParams.startTime,
|
||||
'1000000',
|
||||
);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.endTime, '2000000');
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.relativeTime);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete urlParamsToDelete when provided', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useZoomOut({
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
|
||||
jest.mock('hooks/dashboard/useVariablesFromUrl');
|
||||
|
||||
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
|
||||
typeof useDashboardVariablesFromLocalStorage
|
||||
>;
|
||||
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
|
||||
typeof useVariablesFromUrl
|
||||
>;
|
||||
|
||||
const makeVariable = (
|
||||
overrides: Partial<IDashboardVariable> = {},
|
||||
): IDashboardVariable => ({
|
||||
id: 'existing-id',
|
||||
name: 'env',
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
selectedValue: 'prod',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDashboard = (
|
||||
variables: Record<string, IDashboardVariable>,
|
||||
): Dashboard => ({
|
||||
id: 'dash-1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test',
|
||||
variables,
|
||||
},
|
||||
});
|
||||
|
||||
const setupHook = (
|
||||
currentDashboard: Record<string, any> = {},
|
||||
urlVariables: Record<string, any> = {},
|
||||
): ReturnType<typeof useTransformDashboardVariables> => {
|
||||
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables: jest.fn(),
|
||||
});
|
||||
mockUseVariablesFromUrl.mockReturnValue({
|
||||
getUrlVariables: () => urlVariables,
|
||||
setUrlVariables: jest.fn(),
|
||||
updateUrlVariable: jest.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
|
||||
return result.current;
|
||||
};
|
||||
|
||||
describe('useTransformDashboardVariables', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('order assignment', () => {
|
||||
it('assigns order starting from 0 to variables that have none', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
|
||||
it('preserves existing order values', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ID assignment', () => {
|
||||
it('assigns a UUID to variables that have no id', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const variable = makeVariable({ name: 'v1' });
|
||||
(variable as any).id = undefined;
|
||||
const dashboard = makeDashboard({ v1: variable });
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing IDs', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEXTBOX backward compatibility', () => {
|
||||
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'v1',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'hello',
|
||||
defaultValue: undefined,
|
||||
order: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'v1',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'old',
|
||||
defaultValue: 'keep',
|
||||
order: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage merge', () => {
|
||||
it('applies localStorage selectedValue over DB value', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: 'staging', allSelected: false },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: undefined, allSelected: true },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
allSelected: false,
|
||||
showALLOption: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL variable override', () => {
|
||||
it('sets allSelected=true when URL value is __ALL__', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: 'prod', allSelected: false } },
|
||||
{ env: '__ALL__' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: true,
|
||||
allSelected: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: undefined, allSelected: true } },
|
||||
{ env: 'dev' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: true,
|
||||
allSelected: true,
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: undefined, allSelected: true } },
|
||||
{ env: 'dev' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: false,
|
||||
allSelected: true,
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{},
|
||||
{ env: ['prod', 'dev'] },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
multiSelect: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{},
|
||||
{ 'var-uuid': 'fallback' },
|
||||
);
|
||||
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
|
||||
delete variable.name;
|
||||
const dashboard = makeDashboard({ v1: variable });
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns data unchanged when there are no variables', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables).toEqual({});
|
||||
});
|
||||
|
||||
it('does not mutate the original dashboard', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: 'staging', allSelected: false },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
|
||||
[id: string]: LocalStoreDashboardVariables;
|
||||
}
|
||||
|
||||
export interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
currentDashboard: LocalStoreDashboardVariables;
|
||||
updateLocalStorageDashboardVariables: (
|
||||
id: string,
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import {
|
||||
useDashboardVariablesFromLocalStorage,
|
||||
UseDashboardVariablesFromLocalStorageReturn,
|
||||
} from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import useVariablesFromUrl, {
|
||||
UseVariablesFromUrlReturn,
|
||||
} from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
export function useTransformDashboardVariables(
|
||||
dashboardId: string,
|
||||
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
|
||||
UseDashboardVariablesFromLocalStorageReturn & {
|
||||
transformDashboardVariables: (data: Dashboard) => Dashboard;
|
||||
} {
|
||||
const {
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||
|
||||
const mergeDBWithLocalStorage = (
|
||||
data: Dashboard,
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
// respect the url variable if it is set, override the others
|
||||
if (!isEmpty(urlVariable)) {
|
||||
if (urlVariable === ALL_SELECTED_VALUE) {
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
allSelected: true,
|
||||
};
|
||||
} else {
|
||||
// Normalize URL value to match variable's multiSelect configuration
|
||||
const normalizedValue = normalizeUrlValueForVariable(
|
||||
urlVariable,
|
||||
variableData,
|
||||
);
|
||||
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
selectedValue: normalizedValue,
|
||||
// Only set allSelected to false if showALLOption is available
|
||||
...(updatedVariable?.showALLOption && { allSelected: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updatedVariables[variable] = updatedVariable;
|
||||
});
|
||||
updatedData.data.variables = updatedVariables;
|
||||
}
|
||||
return updatedData;
|
||||
};
|
||||
|
||||
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const transformDashboardVariables = (data: Dashboard): Dashboard => {
|
||||
if (data && data.data && data.data.variables) {
|
||||
const clonedDashboardData = mergeDBWithLocalStorage(
|
||||
JSON.parse(JSON.stringify(data)),
|
||||
currentDashboard,
|
||||
);
|
||||
const { variables } = clonedDashboardData.data;
|
||||
const existingOrders: Set<number> = new Set();
|
||||
|
||||
for (const key in variables) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (variables.hasOwnProperty(key)) {
|
||||
const variable: IDashboardVariable = variables[key];
|
||||
|
||||
// Check if 'order' property doesn't exist or is undefined
|
||||
if (variable.order === undefined) {
|
||||
// Find a unique order starting from 0
|
||||
let order = 0;
|
||||
while (existingOrders.has(order)) {
|
||||
order += 1;
|
||||
}
|
||||
|
||||
variable.order = order;
|
||||
existingOrders.add(order);
|
||||
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
||||
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
||||
variable.defaultValue = variable.textboxValue || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.id === undefined) {
|
||||
variable.id = generateUUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clonedDashboardData;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
transformDashboardVariables,
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
|
||||
| IDashboardVariable['selectedValue'];
|
||||
}
|
||||
|
||||
export interface UseVariablesFromUrlReturn {
|
||||
interface UseVariablesFromUrlReturn {
|
||||
getUrlVariables: () => LocalStoreDashboardVariables;
|
||||
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
||||
updateUrlVariable: (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user