Compare commits

..

5 Commits

Author SHA1 Message Date
Srikanth Chekuri
dd02f14bc9 Merge branch 'main' into issue-5388 2026-06-30 23:04:44 +05:30
Srikanth Chekuri
87c56fb636 Merge branch 'main' into issue-5388 2026-06-30 18:28:28 +05:30
Srikanth Chekuri
ef2ba2a46a Merge branch 'main' into issue-5388 2026-06-25 22:21:01 +05:30
srikanthccv
b91e664e14 chore: address lint 2026-06-17 21:47:17 +05:30
srikanthccv
04ec681eda fix(alerts): surface individual validation errors in API response 2026-06-17 16:41:28 +05:30
382 changed files with 4757 additions and 13502 deletions

5
.github/CODEOWNERS vendored
View File

@@ -109,7 +109,10 @@ go.mod @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
/frontend/src/lib/authz/ @H4ad
/frontend/src/hooks/useAuthZ/ @H4ad
/frontend/src/components/GuardAuthZ/ @H4ad
/frontend/src/components/AuthZTooltip/ @H4ad
/frontend/src/components/createGuardedRoute/ @H4ad
/frontend/src/container/RolesSettings/ @H4ad
/frontend/src/components/RolesSelect/ @H4ad
/frontend/src/pages/MembersSettings/ @H4ad

View File

@@ -61,6 +61,13 @@ jobs:
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env

View File

@@ -67,6 +67,12 @@ jobs:
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env

View File

@@ -27,6 +27,13 @@ jobs:
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> .env
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
echo 'VITE_ENVIRONMENT="production"' >> .env
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env

View File

@@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
)
const permissionsTypePath = "frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts"
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz

View File

@@ -515,13 +515,6 @@ components:
url:
type: string
type: object
AuthtypesDeprecatedPostableUserRole:
properties:
id:
type: string
required:
- id
type: object
AuthtypesGettableAuthDomain:
properties:
authNProviderInfo:
@@ -625,6 +618,13 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesPostableAuthDomain:
properties:
config:
@@ -667,20 +667,17 @@ components:
type: string
userRoles:
items:
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
$ref: '#/components/schemas/AuthtypesPostableUserRole'
type: array
required:
- email
type: object
AuthtypesPostableUserRole:
properties:
roleId:
type: string
userId:
id:
type: string
required:
- userId
- roleId
- id
type: object
AuthtypesRelation:
enum:
@@ -2539,6 +2536,22 @@ components:
- resource
- selectors
type: object
CoretypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
required:
- additions
- deletions
type: object
CoretypesResourceRef:
properties:
kind:
@@ -2724,14 +2737,6 @@ components:
type: string
dashboardName:
type: string
filterBy:
items:
type: string
type: array
groupBy:
items:
type: string
type: array
panelId:
type: string
panelName:
@@ -3081,10 +3086,6 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3096,7 +3097,6 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardV2:
properties:
@@ -3104,10 +3104,6 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3119,7 +3115,6 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardView:
properties:
@@ -3591,7 +3586,7 @@ components:
- user
- system
- integration
type: string
type: object
DashboardtypesSpanGaps:
properties:
fillLessThan:
@@ -5417,9 +5412,6 @@ components:
type: string
id:
type: string
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
@@ -5432,9 +5424,9 @@ components:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
metricName:
type: string
retainedSamples:
minimum: 0
type: integer
reductionPercent:
format: double
type: number
retainedSeries:
minimum: 0
type: integer
@@ -5452,8 +5444,7 @@ components:
- active
- ingestedSeries
- retainedSeries
- ingestedSamples
- retainedSamples
- reductionPercent
type: object
MetricreductionruletypesGettableReductionRulePreview:
properties:
@@ -5496,23 +5487,15 @@ components:
estimatedMonthlySavingsUsd:
format: double
type: number
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
retainedSamples:
minimum: 0
type: integer
retainedSeries:
minimum: 0
type: integer
required:
- ingestedSeries
- retainedSeries
- ingestedSamples
- retainedSamples
- estimatedMonthlySavingsUsd
type: object
MetricreductionruletypesGettableReductionRules:
@@ -5578,6 +5561,7 @@ components:
- metric
- ingested_volume
- reduced_volume
- reduction
- last_updated
type: string
MetricreductionruletypesUpdatableReductionRule:
@@ -7410,13 +7394,6 @@ components:
enum:
- basic
type: string
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -7473,13 +7450,10 @@ components:
type: object
ServiceaccounttypesPostableServiceAccountRole:
properties:
roleId:
type: string
serviceAccountId:
id:
type: string
required:
- serviceAccountId
- roleId
- id
type: object
ServiceaccounttypesServiceAccount:
properties:
@@ -11851,6 +11825,68 @@ paths:
summary: Get role
tags:
- role
patch:
deprecated: true
description: This endpoint patches a role
operationId: PatchRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
@@ -11913,6 +11949,158 @@ paths:
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
description: Gets all objects connected to the specified role via a given relation
type
operationId: GetObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:read
- tokenizer:
- role:read
summary: Get objects for a role by relation
tags:
- role
patch:
deprecated: true
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch objects for a role by relation
tags:
- role
/api/v1/route_policies:
get:
deprecated: false
@@ -12188,188 +12376,6 @@ paths:
summary: Update route policy
tags:
- routepolicies
/api/v1/service_account_roles:
post:
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_account_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a service account
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
get:
deprecated: false
description: This endpoint gets an existing service account role
operationId: GetServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:read
- tokenizer:
- serviceaccount:read
summary: Get service account role
tags:
- serviceaccount
/api/v1/service_accounts:
get:
deprecated: false
@@ -12939,9 +12945,9 @@ paths:
tags:
- serviceaccount
post:
deprecated: true
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
operationId: CreateServiceAccountRole
parameters:
- in: path
name: id
@@ -12952,7 +12958,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
responses:
"201":
content:
@@ -13004,9 +13010,9 @@ paths:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
deprecated: false
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
@@ -14171,184 +14177,6 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v1/user_roles:
post:
deprecated: false
description: This endpoint assigns a role to a user
operationId: CreateUserRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create user role
tags:
- users
/api/v1/user_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a user
operationId: DeleteUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete user role
tags:
- users
get:
deprecated: false
description: This endpoint gets an existing user role
operationId: GetUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesUserRole'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get user role
tags:
- users
/api/v2/dashboard_views:
get:
deprecated: false
@@ -22607,7 +22435,7 @@ paths:
tags:
- users
post:
deprecated: true
deprecated: false
description: This endpoint assigns the role to the user roles by user id
operationId: SetRoleByUserID
parameters:
@@ -22658,7 +22486,7 @@ paths:
- users
/api/v2/users/{id}/roles/{roleId}:
delete:
deprecated: true
deprecated: false
description: This endpoint removes a role from the user by user id and role
id
operationId: RemoveUserRoleByUserIDAndRoleID

View File

@@ -260,6 +260,40 @@ func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID va
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, 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())
}
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
relation,
objectType,
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -290,6 +324,39 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.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, role)
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) 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())
}
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {

View File

@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
if err != nil {
return "", err
}
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return "", err
}

View File

@@ -286,7 +286,7 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return module.pkgDashboardModule.Get(ctx, orgID, id)
}
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}

View File

@@ -22,8 +22,6 @@ var (
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
const sampleBucketExpr = "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalMinute(10)))) * 1000 AS bucket"
type volumeRow struct {
MetricName string
Ingested uint64
@@ -291,9 +289,12 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
}
ctx = c.withThreads(ctx)
orderExpr := "ifNull(i.samples, 0)"
if orderBy == metricreductionruletypes.OrderByReducedVolume {
orderExpr = "if(ifNull(d.samples, 0) = 0 OR ifNull(d.samples, 0) > ifNull(i.samples, 0), ifNull(i.samples, 0), ifNull(d.samples, 0))"
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
@@ -309,17 +310,17 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt, count() AS samples FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt, sum(samples) AS samples FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
@@ -346,184 +347,120 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
return out, rows.Err()
}
func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
if len(metricNames) == 0 {
return map[string]volumeRow{}, nil
return 0, 0, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
return 0, 0, err
}
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
return 0, 0, err
}
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
return 0, 0, err
}
out := make(map[string]volumeRow, len(metricNames))
for _, name := range metricNames {
out[name] = volumeRow{MetricName: name, Ingested: ingested[name], Reduced: last[name] + sum[name]}
}
return out, nil
return ingested, min(last+sum, ingested), nil
}
func (c *clickhouse) countSamplesByMetric(ctx context.Context, table, countExpr string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", countExpr)
sb.Select("count()")
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count samples")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint)", "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var series, samples uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&series, &samples); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count total ingested volume")
}
return series, samples, nil
}
func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
ctx = c.withThreads(ctx)
ingested, err := c.totalSamplesByBucket(ctx, startMs, endMs)
if err != nil {
return nil, err
}
ruledIngested := make(map[int64]uint64)
ruledRetained := make(map[int64]uint64)
if len(ruledMetrics) > 0 {
ruledIngested, err = c.ruledIngestedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
ruledRetained, err = c.ruledRetainedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
}
retained := make(map[int64]uint64, len(ingested))
for ts, total := range ingested {
shed := uint64(0)
if ri := ruledIngested[ts]; ri > ruledRetained[ts] {
shed = ri - ruledRetained[ts]
}
if total > shed {
retained[ts] = total - shed
} else {
retained[ts] = 0
}
}
return mergeVolumePoints(ingested, retained), nil
}
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
}
return count, nil
}
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
sb := sqlbuilder.NewSelectBuilder()
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
sb.Select("uniq(reduced_fingerprint, unix_milli)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
counts, err := c.scanBuckets(ctx, sb)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
}
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
for ts, count := range reduced {
retained[ts] += count
}
}
return out, nil
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
}
return mergeVolumePoints(ingested, retained), nil
}
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
@@ -551,6 +488,60 @@ func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)

View File

@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -27,16 +28,9 @@ import (
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (~2m worst case,
// see signoz-otel-collector#839).
effectiveFromMargin = 2 * time.Minute
// uiActivationDelay keeps a rule shown as "pending" in the UI for a while after it goes live to
// the collector, so the user doesn't see "active" before reduced data is actually flowing. The
// user-facing pending window is effectiveFromMargin + uiActivationDelay (~5m).
uiActivationDelay = 3 * time.Minute
defaultPreviewLookback = 1 * time.Hour
statsLookback = 1 * time.Hour
timeseriesLookback = 6 * time.Hour
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
@@ -86,7 +80,7 @@ func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricredu
}
now := time.Now()
startMs := now.Add(-statsLookback).UnixMilli()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
@@ -113,14 +107,10 @@ func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, para
if err != nil {
return nil, err
}
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName], sampleVolumes[rule.MetricName]))
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
@@ -149,24 +139,13 @@ func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, para
return nil, err
}
pageMetricNames := make([]string, 0, len(ranked))
for _, row := range ranked {
pageMetricNames = append(pageMetricNames, row.MetricName)
}
// TODO(srikanthccv): do we need to run this query? can we just get the same from RankByVolume?
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, pageMetricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row, sampleVolumes[row.MetricName]))
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
@@ -309,17 +288,20 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
}
now := time.Now()
startMs := now.Add(-statsLookback).UnixMilli()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
rules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
}
metricNames := make([]string, len(rules))
effectiveFrom := make(map[string]int64, len(rules))
for i, rule := range rules {
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
@@ -328,43 +310,31 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
if err != nil {
return nil, err
}
var ruledIngestedSeries, ruledRetainedSeries uint64
for _, volume := range volumes {
ruledIngestedSeries += volume.Ingested
ruledRetainedSeries += effectiveRetained(volume.Ingested, volume.Reduced)
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
}
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
var ruledIngestedSamples, ruledRetainedSamples uint64
for _, sv := range sampleVolumes {
ruledIngestedSamples += sv.Ingested
ruledRetainedSamples += effectiveRetained(sv.Ingested, sv.Reduced)
}
totalSeries, totalSamples, err := m.ch.TotalVolume(ctx, startMs, endMs)
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: totalSeries,
RetainedSeries: clampSub(totalSeries, ruledIngestedSeries-ruledRetainedSeries),
IngestedSamples: totalSamples,
RetainedSamples: clampSub(totalSamples, ruledIngestedSamples-ruledRetainedSamples),
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ruledIngestedSamples, ruledRetainedSamples, startMs, endMs),
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
}, nil
}
func clampSub(a, b uint64) uint64 {
if a < b {
return 0
}
return a - b
}
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
@@ -382,7 +352,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
}
now := time.Now()
startMs := now.Add(-timeseriesLookback).UnixMilli()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
@@ -396,7 +366,18 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
points, err := m.ch.SampleTimeseries(ctx, metricNames, effectiveFrom, startMs, endMs)
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
@@ -433,7 +414,7 @@ func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRange
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, orgID, metricName)
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
if err != nil {
return err
}
@@ -466,12 +447,12 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
usedLabels := append(append([]string{}, item.GroupBy...), item.FilterBy...)
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item.DashboardID,
Name: item.DashboardName,
Widget: &metricreductionruletypes.AffectedWidget{ID: item.PanelID, Name: item.PanelName},
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
@@ -501,7 +482,7 @@ func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metri
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
Active: !rule.EffectiveFrom.Add(uiActivationDelay).After(time.Now()),
Active: !rule.EffectiveFrom.After(time.Now()),
}
}
@@ -512,11 +493,12 @@ func effectiveRetained(ingested, reduced uint64) uint64 {
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, series volumeRow, samples volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = series.Ingested
rule.RetainedSeries = effectiveRetained(series.Ingested, series.Reduced)
rule.IngestedSamples = samples.Ingested
rule.RetainedSamples = effectiveRetained(samples.Ingested, samples.Reduced)
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
}
return rule
}
@@ -536,6 +518,13 @@ func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {

View File

@@ -35,7 +35,7 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Search != "" {
query = query.Where("metric_name LIKE ? ESCAPE '\\'", "%"+s.sqlstore.Formatter().EscapeLikePattern(params.Search)+"%")
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)

View File

@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}

View File

@@ -64,7 +64,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
signoz.Cache,
signoz.Flagger,
nil,
)

View File

@@ -328,11 +328,6 @@
{
"name": "immer",
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
},
{
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
}
]
}

View File

@@ -1,116 +1,105 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../docs/readme-assets/signoz-hero-dark.png" width="700">
<source media="(prefers-color-scheme: light)" srcset="../docs/readme-assets/signoz-hero-light.png" width="700">
<img alt="SigNoz - Observability on Your Terms" src="../docs/readme-assets/signoz-hero-light.png" width="700">
</picture>
</p>
# Configuring Over Local
1. Docker
1. Without Docker
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
</p>
## With Docker
# SigNoz Frontend
**Building image**
React-based web interface for [SigNoz](https://signoz.io), the open-source observability platform.
``docker compose up`
/ This will also run
## Tech Stack
or
`docker build . -t tagname`
- **Framework:** React 18 + TypeScript
- **Build:** Vite
- **State:** React Query, Zustand, Redux Toolkit (legacy)
- **Styling:** CSS Modules, Ant Design (legacy)
- **Charts:** uPlot
- **Testing:** Jest
## Local Development Setup
1. Run SigNoz backend locally — see [Self-Host Docs](https://signoz.io/docs/install/self-host/)
2. Configure environment:
```bash
cp example.env .env
```
Key variables in `.env`:
```bash
# Backend API endpoint (required)
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
# Enable bundle analyzer (optional)
BUNDLE_ANALYSER="true"
```
3. Install and run:
```bash
pnpm install
pnpm dev
```
## Development
```bash
pnpm dev
```
Opens [http://localhost:3301](http://localhost:3301).
## Build
```bash
pnpm build
```
Output in `build/` folder.
## Bundle Size Analysis
Set in `.env`:
```bash
BUNDLE_ANALYSER="true"
```
Then run build:
```bash
pnpm build
```
Opens bundle analyzer visualization automatically.
## Testing
```bash
# Unit tests
pnpm test
# Type checking
pnpm tsgo --noEmit
```
## Linting
```bash
# Run all linters (oxlint + stylelint)
pnpm lint
```
## Project Structure
**Tag to remote url- Introduce versioning later on**
```
src/
├── api/ # API clients and react-query hooks
├── components/ # Shared UI components
├── container/ # Page-level containers
├── hooks/ # Custom React hooks
├── pages/ # Route pages
├── providers/ # React context providers
├── store/ # Redux store
└── types/ # TypeScript definitions
docker tag signoz/frontend:latest 7296823551/signoz:latest
```
## Contributing
```
docker compose up
```
See [CONTRIBUTING.md](../CONTRIBUTING.md) in the root repo.
## Without Docker
Follow the steps below
Questions? Join our [Slack community](https://signoz.io/slack).
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
1. ```pnpm install```
1. ```pnpm dev```
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `pnpm start`
Runs the app in the development mode.\
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `pnpm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `pnpm build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `pnpm eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `pnpm build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@@ -1,38 +1,8 @@
NODE_ENV="development"
BUNDLE_ANALYSER="true"
CI="1"
# API
VITE_BASE_PATH=""
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
VITE_WEBSOCKET_API_ENDPOINT=""
VITE_PYLON_APP_ID="pylon-app-id"
VITE_APPCUES_APP_ID="appcess-app-id"
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
# Pylon
VITE_PYLON_ENABLED="false"
VITE_PYLON_APP_ID=""
VITE_PYLON_IDENTITY_SECRET=""
# Appcues
VITE_APPCUES_ENABLED="false"
VITE_APPCUES_APP_ID=""
# PostHog
VITE_POSTHOG_ENABLED="false"
VITE_POSTHOG_API_HOST=""
VITE_POSTHOG_KEY=""
VITE_POSTHOG_UI_HOST=""
# Sentry
VITE_SENTRY_ENABLED="false"
VITE_SENTRY_AUTH_TOKEN=""
VITE_SENTRY_ORG=""
VITE_SENTRY_PROJECT_ID=""
VITE_SENTRY_TUNNEL=""
VITE_SENTRY_DSN=""
# Docs
VITE_DOCS_BASE_URL="https://signoz.io"
# Build info
VITE_ENVIRONMENT="development"
VITE_VERSION=""
CI="1"

View File

@@ -111,10 +111,11 @@
<div id="root"></div>
<script>
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
var pylonSettings =
((window.signozBootData || {}).settings || {}).pylon || {};
var pylonEnabled = pylonSettings.enabled === true;
if (pylonSettings.appId && pylonEnabled) {
var pylonEnabled = pylonSettings.enabled !== false;
if (PYLON_APP_ID && pylonEnabled) {
(function () {
var e = window;
var t = document;
@@ -132,7 +133,7 @@
e.setAttribute('async', 'true');
e.setAttribute(
'src',
'https://widget.usepylon.com/widget/' + pylonSettings.appId,
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
);
var n = t.getElementsByTagName('script')[0];
n.parentNode.insertBefore(e, n);
@@ -149,14 +150,15 @@
window.AppcuesSettings = { enableURLDetection: true };
</script>
<script>
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
var appcuesSettings =
((window.signozBootData || {}).settings || {}).appcues || {};
var appcuesEnabled = appcuesSettings.enabled === true;
if (appcuesSettings.appId && appcuesEnabled) {
var appcuesEnabled = appcuesSettings.enabled !== false;
if (APPCUES_APP_ID && appcuesEnabled) {
(function (d, t) {
var a = d.createElement(t);
a.async = 1;
a.src = '//fast.appcues.com/' + appcuesSettings.appId + '.js';
a.src = '//fast.appcues.com/' + APPCUES_APP_ID + '.js';
var s = d.getElementsByTagName(t)[0];
s.parentNode.insertBefore(a, s);
})(document, 'script');

View File

@@ -3,6 +3,7 @@
"project": ["src/**/*.ts", "src/**/*.tsx"],
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"],
"ignoreDependencies": [
"http-proxy-middleware",
"@typescript/native-preview"
]
}

View File

@@ -79,6 +79,7 @@
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"http-proxy-middleware": "4.1.1",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",

View File

@@ -164,6 +164,9 @@ importers:
history:
specifier: 4.10.1
version: 4.10.1
http-proxy-middleware:
specifier: 4.1.1
version: 4.1.1
http-status-codes:
specifier: 2.3.0
version: 2.3.0
@@ -5458,6 +5461,10 @@ packages:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
http-proxy-middleware@4.1.1:
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
@@ -5465,6 +5472,9 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -14505,6 +14515,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
http-proxy-middleware@4.1.1:
dependencies:
debug: 4.3.4(supports-color@5.5.0)
httpxy: 0.5.3
is-glob: 4.0.3
is-plain-obj: 4.1.0
micromatch: 4.0.8
transitivePeerDependencies:
- supports-color
http-status-codes@2.3.0: {}
https-proxy-agent@5.0.1:
@@ -14514,6 +14534,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
httpxy@0.5.3: {}
human-signals@2.1.0: {}
human-signals@8.0.1: {}

View File

@@ -61,7 +61,5 @@
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
}

View File

@@ -86,7 +86,5 @@
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
}

View File

@@ -292,10 +292,10 @@ function App(): JSX.Element {
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) &&
window.signozBootData?.settings?.pylon?.enabled
(window.signozBootData?.settings?.pylon.enabled ?? true)
) {
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
const secret = process.env.PYLON_IDENTITY_SECRET || '';
let emailHash = '';
if (email && secret) {
@@ -304,7 +304,7 @@ function App(): JSX.Element {
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
app_id: process.env.PYLON_APP_ID,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
@@ -335,23 +335,22 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (
window.signozBootData?.settings?.posthog?.enabled &&
window.signozBootData?.settings?.posthog?.key
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
) {
posthog.init(window.signozBootData.settings.posthog.key, {
api_host: window.signozBootData.settings.posthog.apiHost,
ui_host: window.signozBootData.settings.posthog.uiHost,
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
});
}
if (
!isSentryInitialized &&
window.signozBootData?.settings?.sentry?.enabled
(window.signozBootData?.settings?.sentry.enabled ?? true)
) {
Sentry.init({
dsn: window.signozBootData.settings.sentry.dsn,
tunnel: window.signozBootData.settings.sentry.tunnel,
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
integrations: [

View File

@@ -18,13 +18,19 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
GetObjectsPathParameters,
GetRole200,
GetRolePathParameters,
ListRoles200,
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
@@ -359,6 +365,107 @@ export const invalidateGetRole = async (
return queryClient;
};
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
{ id }: PatchRolePathParameters,
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: authtypesPatchableRoleDTO,
signal,
});
};
export const getPatchRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
const mutationKey = ['patchRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchRole>>,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof patchRole>>
>;
export type PatchRoleMutationBody =
| BodyType<AuthtypesPatchableRoleDTO>
| undefined;
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
@@ -458,3 +565,205 @@ export const useUpdateRole = <
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
* @summary Get objects for a role by relation
*/
export const getObjects = (
{ id, relation }: GetObjectsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetObjects200>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'GET',
signal,
});
};
export const getGetObjectsQueryKey = ({
id,
relation,
}: GetObjectsPathParameters) => {
return [`/api/v1/roles/${id}/relations/${relation}/objects`] as const;
};
export const getGetObjectsQueryOptions = <
TData = Awaited<ReturnType<typeof getObjects>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, relation }: GetObjectsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getObjects>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetObjectsQueryKey({ id, relation });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getObjects>>> = ({
signal,
}) => getObjects({ id, relation }, signal);
return {
queryKey,
queryFn,
enabled: !!(id && relation),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getObjects>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetObjectsQueryResult = NonNullable<
Awaited<ReturnType<typeof getObjects>>
>;
export type GetObjectsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get objects for a role by relation
*/
export function useGetObjects<
TData = Awaited<ReturnType<typeof getObjects>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, relation }: GetObjectsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getObjects>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetObjectsQueryOptions({ id, relation }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get objects for a role by relation
*/
export const invalidateGetObjects = async (
queryClient: QueryClient,
{ id, relation }: GetObjectsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetObjectsQueryKey({ id, relation }) },
options,
);
return queryClient;
};
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
{ id, relation }: PatchObjectsPathParameters,
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: coretypesPatchableObjectsDTO,
signal,
});
};
export const getPatchObjectsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
const mutationKey = ['patchObjects'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchObjects>>,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchObjects(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchObjectsMutationResult = NonNullable<
Awaited<ReturnType<typeof patchObjects>>
>;
export type PatchObjectsMutationBody =
| BodyType<CoretypesPatchableObjectsDTO>
| undefined;
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
return useMutation(getPatchObjectsMutationOptions(options));
};

View File

@@ -22,16 +22,12 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
CreateServiceAccountRolePathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
GetServiceAccountPathParameters,
GetServiceAccountRole200,
GetServiceAccountRolePathParameters,
GetServiceAccountRoles200,
GetServiceAccountRolesPathParameters,
ListServiceAccountKeys200,
@@ -39,7 +35,6 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -51,272 +46,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint assigns a role to a service account
* @summary Create service account role
*/
export const createServiceAccountRole = (
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_account_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
const mutationKey = ['createServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRole>>,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createServiceAccountRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create service account role
*/
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a service account
* @summary Delete service account role
*/
export const deleteServiceAccountRole = (
{ id }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_account_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete service account role
*/
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets an existing service account role
* @summary Get service account role
*/
export const getServiceAccountRole = (
{ id }: GetServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetServiceAccountRole200>({
url: `/api/v1/service_account_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetServiceAccountRoleQueryKey = ({
id,
}: GetServiceAccountRolePathParameters) => {
return [`/api/v1/service_account_roles/${id}`] as const;
};
export const getGetServiceAccountRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getServiceAccountRole>>
> = ({ signal }) => getServiceAccountRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetServiceAccountRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getServiceAccountRole>>
>;
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service account role
*/
export function useGetServiceAccountRole<
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get service account role
*/
export const invalidateGetServiceAccountRole = async (
queryClient: QueryClient,
{ id }: GetServiceAccountRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists the service accounts for an organisation
* @summary List service accounts
@@ -1255,46 +984,45 @@ export const invalidateGetServiceAccountRoles = async (
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
export const createServiceAccountRole = (
{ id }: CreateServiceAccountRolePathParameters,
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const mutationKey = ['createServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1304,66 +1032,62 @@ export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
return createServiceAccountRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
@@ -1373,23 +1097,23 @@ export const deleteServiceAccountRoleDeprecated = (
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const mutationKey = ['deleteServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1399,47 +1123,44 @@ export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets my service account

View File

@@ -2048,13 +2048,6 @@ export interface AuthtypesAuthNSupportDTO {
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
}
export interface AuthtypesDeprecatedPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2237,6 +2230,13 @@ export interface AuthtypesOrgSessionContextDTO {
warning?: ErrorsJSONDTO;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
/**
@@ -2294,6 +2294,13 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesPostableUserDTO {
/**
* @type string
@@ -2310,18 +2317,7 @@ export interface AuthtypesPostableUserDTO {
/**
* @type array
*/
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
userId: string;
userRoles?: AuthtypesPostableUserRoleDTO[];
}
export interface AuthtypesRoleDTO {
@@ -3253,6 +3249,17 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
*/
additions: CoretypesObjectGroupDTO[] | null;
/**
* @type array,null
*/
deletions: CoretypesObjectGroupDTO[] | null;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -3988,14 +3995,6 @@ export interface DashboardtypesDashboardPanelRefDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
filterBy?: string[];
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -5042,10 +5041,6 @@ export interface DashboardtypesListableDashboardForUserV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
@@ -5113,10 +5108,6 @@ export interface DashboardtypesListableDashboardV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
@@ -6928,11 +6919,6 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
* @type string
*/
id: string;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6948,10 +6934,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
*/
metricName: string;
/**
* @type integer
* @minimum 0
* @type number
* @format double
*/
retainedSamples: number;
reductionPercent: number;
/**
* @type integer
* @minimum 0
@@ -7010,21 +6996,11 @@ export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
* @format double
*/
estimatedMonthlySavingsUsd: number;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -7080,6 +7056,7 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
metric = 'metric',
ingested_volume = 'ingested_volume',
reduced_volume = 'reduced_volume',
reduction = 'reduction',
last_updated = 'last_updated',
}
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
@@ -8491,13 +8468,6 @@ export interface RuletypesRuleDTO {
export enum RuletypesThresholdKindDTO {
basic = 'basic',
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -8567,11 +8537,7 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
serviceAccountId: string;
id: string;
}
export interface ServiceaccounttypesServiceAccountDTO {
@@ -10282,9 +10248,31 @@ export type GetRole200 = {
status: string;
};
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;
};
export type GetObjects200 = {
/**
* @type array
*/
data: CoretypesObjectGroupDTO[];
/**
* @type string
*/
status: string;
};
export type PatchObjectsPathParameters = {
id: string;
relation: string;
};
export type GetAllRoutePolicies200 = {
/**
* @type array
@@ -10329,28 +10317,6 @@ export type UpdateRoutePolicy200 = {
status: string;
};
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRole200 = {
data: ServiceaccounttypesServiceAccountRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListServiceAccounts200 = {
/**
* @type array
@@ -10434,10 +10400,10 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
export type CreateServiceAccountRolePathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
@@ -10445,7 +10411,7 @@ export type CreateServiceAccountRoleDeprecated201 = {
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
export type DeleteServiceAccountRolePathParameters = {
id: string;
rid: string;
};
@@ -10610,28 +10576,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type CreateUserRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteUserRolePathParameters = {
id: string;
};
export type GetUserRolePathParameters = {
id: string;
};
export type GetUserRole200 = {
data: AuthtypesUserRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**

View File

@@ -19,15 +19,12 @@ import type {
import type {
AuthtypesPostableUserDTO,
AuthtypesPostableUserRoleDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
CreateUserRole201,
DeleteUserDeprecatedPathParameters,
DeleteUserPathParameters,
DeleteUserRolePathParameters,
GetMyUser200,
GetMyUserDeprecated200,
GetResetPasswordToken200,
@@ -40,8 +37,6 @@ import type {
GetUserDeprecated200,
GetUserDeprecatedPathParameters,
GetUserPathParameters,
GetUserRole200,
GetUserRolePathParameters,
GetUsersByRoleID200,
GetUsersByRoleIDPathParameters,
ListUsers200,
@@ -891,267 +886,6 @@ export const invalidateGetMyUserDeprecated = async (
return queryClient;
};
/**
* This endpoint assigns a role to a user
* @summary Create user role
*/
export const createUserRole = (
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateUserRole201>({
url: `/api/v1/user_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableUserRoleDTO,
signal,
});
};
export const getCreateUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
const mutationKey = ['createUserRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createUserRole>>,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createUserRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createUserRole>>
>;
export type CreateUserRoleMutationBody =
| BodyType<AuthtypesPostableUserRoleDTO>
| undefined;
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create user role
*/
export const useCreateUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
return useMutation(getCreateUserRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a user
* @summary Delete user role
*/
export const deleteUserRole = (
{ id }: DeleteUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/user_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
const mutationKey = ['deleteUserRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteUserRole>>,
{ pathParams: DeleteUserRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteUserRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteUserRole>>
>;
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete user role
*/
export const useDeleteUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
return useMutation(getDeleteUserRoleMutationOptions(options));
};
/**
* This endpoint gets an existing user role
* @summary Get user role
*/
export const getUserRole = (
{ id }: GetUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetUserRole200>({
url: `/api/v1/user_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
return [`/api/v1/user_roles/${id}`] as const;
};
export const getGetUserRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
signal,
}) => getUserRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetUserRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getUserRole>>
>;
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get user role
*/
export function useGetUserRole<
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get user role
*/
export const invalidateGetUserRole = async (
queryClient: QueryClient,
{ id }: GetUserRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetUserRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint initiates the forgot password flow by sending a reset password email
* @summary Forgot password
@@ -2131,7 +1865,6 @@ export const invalidateGetRolesByUserID = async (
/**
* This endpoint assigns the role to the user roles by user id
* @deprecated
* @summary Set user roles
*/
export const setRoleByUserID = (
@@ -2203,7 +1936,6 @@ export type SetRoleByUserIDMutationBody =
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Set user roles
*/
export const useSetRoleByUserID = <
@@ -2232,7 +1964,6 @@ export const useSetRoleByUserID = <
};
/**
* This endpoint removes a role from the user by user id and role id
* @deprecated
* @summary Remove a role from user
*/
export const removeUserRoleByUserIDAndRoleID = (
@@ -2291,7 +2022,6 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Remove a role from user
*/
export const useRemoveUserRoleByUserIDAndRoleID = <

View File

@@ -1,14 +1,11 @@
import { ReactElement } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import type {
AuthZObject,
BrandedPermission,
} from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'hooks/useAuthZ/utils';
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import AuthZTooltip from './AuthZTooltip';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const noPermissions = {

View File

@@ -5,9 +5,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
import type { BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { formatPermission } from 'hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import styles from './AuthZTooltip.module.scss';

View File

@@ -2,8 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';

View File

@@ -11,7 +11,7 @@ import {
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -1,14 +1,5 @@
import { MutableRefObject } from 'react';
import {
ActiveElement,
Chart,
ChartConfiguration,
ChartData,
ChartEvent,
ChartType,
Color,
TooltipItem,
} from 'chart.js';
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -69,189 +60,184 @@ export const getGraphOptions = (
minTime?: number,
maxTime?: number,
// eslint-disable-next-line sonarjs/cognitive-complexity
): CustomChartOptions =>
({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
): CustomChartOptions => ({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
],
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context: TooltipItem<'line'>[]): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
],
},
label(context: TooltipItem<'line'>): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData: TooltipItem<'line'>): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
},
},
position: 'custom',
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
return item2.parsed.y - item1.parsed.y;
},
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
grid: {
display: true,
color: getGridColor(),
},
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value: number | string): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
},
},
},
},
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
},
hoverRadius: 5,
},
},
onClick: (
event: ChartEvent,
element: ActiveElement[],
chart: Chart,
): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
},
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
}
}
: {}),
title: {
display: title !== undefined,
text: title,
},
}) as CustomChartOptions;
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
},
},
position: 'custom',
itemSort(item1, item2): number {
return item2.parsed.y - item1.parsed.y;
},
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
grid: {
display: true,
color: getGridColor(),
},
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
},
},
},
},
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
},
hoverRadius: 5,
},
},
onClick: (event, element, chart): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
},
onHover: (event, _, chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
}
}
},
});
declare module 'chart.js' {
interface TooltipPositionerMap {

View File

@@ -1,13 +1,10 @@
import { ReactElement } from 'react';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { BrandedPermission } from 'hooks/useAuthZ/types';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { GuardAuthZ } from './GuardAuthZ';

View File

@@ -3,9 +3,9 @@ import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
} from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'hooks/useAuthZ/utils';
export type GuardAuthZProps<R extends AuthZRelation> = {
children: ReactElement;

View File

@@ -3,6 +3,7 @@
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}

View File

@@ -4,11 +4,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';

View File

@@ -1,8 +1,8 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';

View File

@@ -7,12 +7,12 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
buildSADetachPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';

View File

@@ -16,8 +16,8 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { buildAPIKeyUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';

View File

@@ -4,13 +4,13 @@ import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';

View File

@@ -5,11 +5,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';

View File

@@ -1,11 +1,11 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';

View File

@@ -16,7 +16,7 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -35,8 +35,8 @@ import {
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -47,7 +47,7 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -7,11 +7,11 @@ import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
} from 'tests/authz-test-utils';
import {
APIKeyListPermission,
buildSADeletePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import ServiceAccountDrawer from '../ServiceAccountDrawer';

View File

@@ -3,7 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { SolidAlertTriangle } from '@signozhq/icons';
import { Select, Tooltip } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import classNames from 'classnames';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
@@ -72,7 +72,9 @@ function YAxisUnitSelector({
}, [categoriesOverride, source]);
return (
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
<div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<Select
showSearch
value={universalUnit}
@@ -82,17 +84,12 @@ function YAxisUnitSelector({
loading={loading}
suffixIcon={
incompatibleUnitMessage ? (
<Tooltip
title={incompatibleUnitMessage}
overlayClassName="y-axis-unit-warning-tooltip"
>
<span className="y-axis-unit-warning" role="img" aria-label="warning">
<SolidAlertTriangle size="md" />
</span>
<Tooltip title={incompatibleUnitMessage}>
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
</Tooltip>
) : undefined
}
className={cx({
className={classNames({
'warning-state': incompatibleUnitMessage,
})}
data-testid={dataTestId}

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
@@ -7,13 +6,9 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
const mockOnChange = jest.fn();
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
mockOnChange.mockClear();
user = userEvent.setup({ pointerEventsCheck: 0 });
});
it('renders with default placeholder', () => {
@@ -39,7 +34,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
});
it('calls onChange when a value is selected', async () => {
it('calls onChange when a value is selected', () => {
render(
<YAxisUnitSelector
value=""
@@ -49,8 +44,9 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
await user.click(select);
await user.click(screen.getByText('Bytes (B)'));
fireEvent.mouseDown(select);
const option = screen.getByText('Bytes (B)');
fireEvent.click(option);
expect(mockOnChange).toHaveBeenCalledWith('By', {
children: 'Bytes (B)',
@@ -59,7 +55,7 @@ describe('YAxisUnitSelector', () => {
});
});
it('filters options based on search input', async () => {
it('filters options based on search input', () => {
render(
<YAxisUnitSelector
value=""
@@ -69,13 +65,14 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
await user.click(select);
await user.type(select, 'bytes/sec');
fireEvent.mouseDown(select);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'bytes/sec' } });
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
});
it('shows all categories and their units', async () => {
it('shows all categories and their units', () => {
render(
<YAxisUnitSelector
value=""
@@ -83,8 +80,9 @@ describe('YAxisUnitSelector', () => {
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
await user.click(screen.getByRole('combobox'));
fireEvent.mouseDown(select);
// Check for category headers
expect(screen.getByText('Data')).toBeInTheDocument();
@@ -95,7 +93,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
});
it('shows warning message when incompatible unit is selected', async () => {
it('shows warning message when incompatible unit is selected', () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
@@ -106,12 +104,12 @@ describe('YAxisUnitSelector', () => {
);
const warningIcon = screen.getByLabelText('warning');
expect(warningIcon).toBeInTheDocument();
await user.hover(warningIcon);
await expect(
screen.findByText(
fireEvent.mouseOver(warningIcon);
return screen
.findByText(
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
),
).resolves.toBeInTheDocument();
)
.then((el) => expect(el).toBeInTheDocument());
});
it('does not show warning message when compatible unit is selected', () => {
@@ -127,7 +125,7 @@ describe('YAxisUnitSelector', () => {
expect(warningIcon).not.toBeInTheDocument();
});
it('uses categories override to render custom units', async () => {
it('uses categories override to render custom units', () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
@@ -149,7 +147,9 @@ describe('YAxisUnitSelector', () => {
/>,
);
await user.click(screen.getByRole('combobox'));
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();

View File

@@ -680,13 +680,6 @@ describe('formatUniversalUnit', () => {
});
describe('Datetime', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
});
afterAll(() => {
jest.useRealTimers();
});
it('formats datetime units', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
'56 years ago',

View File

@@ -4,13 +4,6 @@
}
}
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
// `pointer-events: none`, which would otherwise suppress the tooltip.
.y-axis-unit-warning {
display: inline-flex;
pointer-events: auto;
}
.warning-state {
.ant-select-selector {
border-color: var(--bg-amber-400) !important;
@@ -24,7 +17,3 @@
right: 28px;
}
}
.y-axis-unit-warning-tooltip {
max-width: 240px;
}

View File

@@ -7,10 +7,7 @@ import type {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { createGuardedRoute } from './createGuardedRoute';

View File

@@ -4,13 +4,13 @@ import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'lib/authz/hooks/useAuthZ/types';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
} from 'hooks/useAuthZ/types';
import { formatPermission } from 'hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import noDataUrl from 'assets/Icons/no-data.svg';
import noDataUrl from '@/assets/Icons/no-data.svg';
import AppLoading from '../../../../components/AppLoading/AppLoading';
import AppLoading from '../AppLoading/AppLoading';
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
import './createGuardedRoute.styles.scss';

View File

@@ -2,9 +2,8 @@ import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -14,13 +13,13 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useTimezone } from 'providers/Timezone';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData, Options } from 'uplot';
import { buildEntityMetricsChartConfig } from './configBuilder';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { useEntityMetrics } from './hooks';
import { isKeyNotFoundError } from '../utils';
@@ -71,7 +70,7 @@ function EntityMetrics<T>({
{ threshold: 0.1 },
);
const { queries, chartData, tableData, queryPayloads } = useEntityMetrics({
const { queries, chartData, queryPayloads } = useEntityMetrics({
queryKey,
timeRange,
entity,
@@ -81,10 +80,16 @@ function EntityMetrics<T>({
});
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const onDragSelect = useCallback(
(start: number, end: number): void => {
@@ -96,39 +101,43 @@ function EntityMetrics<T>({
[handleTimeChange],
);
const configs = useMemo(
const options = useMemo(
() =>
queries.map(({ data }, idx) => {
const panelType = queryPayloads[idx]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
const widgetTitle = entityWidgetInfo[idx].title
.toLowerCase()
.replace(/\s+/g, '-');
return buildEntityMetricsChartConfig({
id: `${category}-${widgetTitle}`,
isDarkMode,
currentQuery,
onDragSelect,
return getUPlotChartOptions({
apiResponse: data?.payload,
timezone,
isDarkMode,
dimensions,
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: timeRange.startTime,
maxTimeScale: timeRange.endTime,
onDragSelect,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}): void => {
legendScrollPositionRef.current = position;
},
});
}),
[
queries,
queryPayloads,
category,
isDarkMode,
currentQuery,
onDragSelect,
timezone,
dimensions,
entityWidgetInfo,
timeRange.startTime,
timeRange.endTime,
onDragSelect,
currentQuery,
],
);
@@ -161,22 +170,14 @@ function EntityMetrics<T>({
>
{panelType === PANEL_TYPES.TABLE ? (
<MetricsTable
rows={tableData[idx]?.[0]?.rows ?? []}
columns={tableData[idx]?.[0]?.columns ?? []}
rows={chartData[idx]?.[0]?.rows ?? []}
columns={chartData[idx]?.[0]?.columns ?? []}
/>
) : (
configs[idx] &&
chartData[idx] && (
<TimeSeries
config={configs[idx]}
data={chartData[idx]}
legendConfig={{ position: LegendPosition.BOTTOM }}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
/>
)
<Uplot
options={options[idx] as Options}
data={chartData[idx] as AlignedData}
/>
)}
</div>
);

View File

@@ -3,7 +3,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import uPlot from 'uplot';
import EntityMetrics from '../EntityMetrics';
import { useEntityMetrics } from '../hooks';
@@ -16,15 +15,12 @@ const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
typeof useEntityMetrics
>;
jest.mock('../configBuilder', () => ({
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
getId: jest.fn().mockReturnValue('mock-id'),
}),
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
}));
jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
prepareChartData: jest.fn().mockReturnValue([]),
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: jest.fn().mockReturnValue([]),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
@@ -34,20 +30,9 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="uplot-chart">TimeSeries Chart</div>
),
}),
);
jest.mock('providers/Timezone', () => ({
useTimezone: (): { timezone: { value: string } } => ({
timezone: { value: 'UTC' },
}),
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart">Uplot Chart</div>,
}));
jest.mock('../MetricsTable', () => ({
@@ -309,28 +294,20 @@ const renderEntityMetrics = (overrides = {}): any => {
);
};
const mockChartData: (uPlot.AlignedData | null)[] = [
[
[1705315200, 1705318800],
[42.5, 43.2],
], // time_series chart data (AlignedData)
null, // table uses tableData
];
const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
null, // time_series uses chartData
const mockChartData = [
[], // time_series chart data (uplot handles empty array)
[
{
rows: [
{ timestamp: '2024-01-15T10:00:00Z', value: '1024' },
{ timestamp: '2024-01-15T10:01:00Z', value: '1028' },
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
], // table data
], // table chart data
];
const mockQueryPayloads = [
@@ -344,7 +321,6 @@ describe('EntityMetrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
tableData: mockTableData,
queryPayloads: mockQueryPayloads as any,
});
mockUseQuery.mockReturnValue({
@@ -375,8 +351,7 @@ describe('EntityMetrics', () => {
it('renders loading state when fetching metrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockLoadingQueries as any,
chartData: [null, null],
tableData: [null, null],
chartData: [[], []],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
@@ -387,8 +362,7 @@ describe('EntityMetrics', () => {
it('renders error state when query fails', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockErrorQueries as any,
chartData: [null, null],
tableData: [null, null],
chartData: [[], []],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
@@ -399,9 +373,8 @@ describe('EntityMetrics', () => {
it('renders empty state when no metrics data', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockEmptyQueries as any,
chartData: [[[]], null],
tableData: [
null,
chartData: [
[],
[
{
rows: [],

View File

@@ -1,122 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
export interface EntityMetricsChartConfigProps {
id: string;
isDarkMode: boolean;
currentQuery: Query;
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse?: MetricRangePayloadProps;
timezone: Timezone;
yAxisUnit: string;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildEntityMetricsChartConfig({
id,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit,
minTimeScale,
maxTimeScale,
}: EntityMetricsChartConfigProps): UPlotConfigBuilder {
const stepIntervals = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
) as Record<string, number>;
const minStepInterval = Object.keys(stepIntervals).length
? Math.min(...Object.values(stepIntervals))
: undefined;
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
tzDate,
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
stepInterval: minStepInterval,
});
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
});
builder.addScale({
scaleKey: 'y',
time: false,
});
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {
return builder;
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = getLegend(series, currentQuery, baseLabelName);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
}

View File

@@ -8,14 +8,13 @@ import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { getMetricsTableData, MetricsTableData } from './utils';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { getMetricsTableData } from './utils';
export interface UseEntityMetricsParams<T> {
queryKey: string;
@@ -33,8 +32,10 @@ export interface UseEntityMetricsParams<T> {
export interface UseEntityMetricsResult {
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
chartData: (uPlot.AlignedData | null)[];
tableData: (MetricsTableData[] | null)[];
chartData: (
| ReturnType<typeof getUPlotChartData>
| ReturnType<typeof getMetricsTableData>
)[];
queryPayloads: GetQueryResultsProps[];
}
@@ -92,22 +93,9 @@ export function useEntityMetrics<T>({
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
return data?.payload ? prepareChartData(data.payload) : null;
}),
[queries, queryPayloads],
);
const tableData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType !== PANEL_TYPES.TABLE) {
return null;
}
return getMetricsTableData(data);
return panelType === PANEL_TYPES.TABLE
? getMetricsTableData(data)
: getUPlotChartData(data?.payload);
}),
[queries, queryPayloads],
);
@@ -115,7 +103,6 @@ export function useEntityMetrics<T>({
return {
queries,
chartData,
tableData,
queryPayloads,
};
}

View File

@@ -1,28 +1,7 @@
.filtersBar {
display: flex;
gap: var(--spacing-6);
align-items: center;
justify-content: space-between;
}
.filtersBarLeft {
display: flex;
gap: var(--spacing-6);
align-items: center;
}
.filtersBarSearch {
width: 280px;
}
.filtersBarSource {
width: 160px;
}
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--accent-cherry) 8%, transparent);
color: var(--accent-cherry);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,164 +1,52 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Search, X } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { Typography } from '@signozhq/ui/typography';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SEARCH_DEBOUNCE_MS,
SEARCH_KEY,
SOURCE_FILTER_OPTIONS,
SOURCE_FILTER_TO_IS_OVERRIDE,
SOURCE_KEY,
type SourceFilter,
} from '../constants';
import type { PricingRule } from '../types';
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
import ModelCostDrawer, {
useModelCostDrawer,
} from './components/ModelCostDrawer';
import ModelCostsTable from './components/ModelCostsTable';
import { useModelCostDelete } from './hooks/useModelCostDelete';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
// "Model costs" tab: the priced-model listing, search + source filter, the add/
// edit drawer, and pagination. Page and page size live in the URL (shareable/
// reload-safe) and are owned by TanStackTable via enableQueryParams — this tab
// reads them back through the same useTableParams hook so the two stay in lockstep.
function ModelCostTabPanel(): JSX.Element {
const { page, limit, setPage } = useTableParams(
const { page, limit } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
const [search, setSearch] = useQueryState(
SEARCH_KEY,
parseAsString.withDefault(''),
);
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const [source, setSource] = useQueryState(
SOURCE_KEY,
parseAsStringEnum<SourceFilter>(
SOURCE_FILTER_OPTIONS.map((option) => option.value),
).withDefault('all'),
);
const handleSearchChange = (
event: React.ChangeEvent<HTMLInputElement>,
): void => {
void setSearch(event.target.value || null);
setPage(1);
};
const clearSearch = (): void => {
void setSearch(null);
setPage(1);
};
const handleSourceChange = (value: string | string[]): void => {
void setSource(value as SourceFilter);
setPage(1);
};
const isOverride = SOURCE_FILTER_TO_IS_OVERRIDE[source];
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
...(debouncedSearch ? { q: debouncedSearch } : {}),
...(isOverride !== undefined ? { isOverride } : {}),
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
query: {
enabled: search === debouncedSearch,
},
});
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const drawer = useModelCostDrawer();
const deletion = useModelCostDelete();
return (
<>
<div className={styles.filtersBar}>
<div className={styles.filtersBarLeft}>
<Input
className={styles.filtersBarSearch}
placeholder="Search by model or provider"
value={search}
onChange={handleSearchChange}
prefix={<Search size={14} />}
suffix={
search ? (
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<X size={14} />}
onClick={clearSearch}
aria-label="Clear search"
testId="model-cost-search-clear"
/>
) : undefined
}
testId="model-cost-search"
/>
<SelectSimple
className={styles.filtersBarSource}
items={SOURCE_FILTER_OPTIONS}
value={source}
onChange={handleSourceChange}
testId="source-filter"
/>
</div>
{canManagePricing && (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
onDelete={deletion.requestDelete}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
/>
<footer>
@@ -166,29 +54,6 @@ function ModelCostTabPanel(): JSX.Element {
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
{deletion.pendingDelete && (
<DeleteConfirmDialog
open
modelName={deletion.pendingDelete.modelName}
isDeleting={deletion.isDeleting}
onConfirm={deletion.confirmDelete}
onCancel={deletion.cancelDelete}
/>
)}
</>
);
}

View File

@@ -1,64 +0,0 @@
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Trash2, X } from '@signozhq/icons';
interface DeleteConfirmDialogProps {
open: boolean;
modelName: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Confirmation step before deleting a model cost — deletion is irreversible, so
// the destructive action is gated behind an explicit confirm. AlertDialog blocks
// outside-click dismissal and hides the close button to force an explicit choice.
function DeleteConfirmDialog({
open,
modelName,
isDeleting,
onConfirm,
onCancel,
}: DeleteConfirmDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Delete Model Cost Data "
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="drawer-delete-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="drawer-delete-confirm-btn"
>
Delete
</Button>
</>
}
>
Are you sure you want to delete <strong>{modelName}</strong>? Once deleted,
this action cannot be undone.
</AlertDialog>
);
}
export default DeleteConfirmDialog;

View File

@@ -1,58 +0,0 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.required {
composes: required from './shared.module.scss';
}
.modelCostDrawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
display: flex;
overflow-y: auto;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
padding: var(--spacing-10) var(--spacing-12);
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
p {
margin: var(--spacing-2) 0 0;
color: var(--l3-foreground);
font-size: 12px;
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
}

View File

@@ -1,238 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Controller, useForm } from 'react-hook-form';
import PatternEditor from './components/PatternEditor';
import PricingFields from './components/PricingFields';
import SourceSelector from './components/SourceSelector';
import { PROVIDER_OPTIONS } from '../../../constants';
import styles from './ModelCostDrawer.module.scss';
import {
validateModelName,
validatePricing,
validateProvider,
} from '../../../utils';
import type { DrawerDraft, DrawerMode } from '../../../types';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
onClose: () => void;
onSave: (draft: DrawerDraft) => void;
isSaving: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
initialDraft,
onClose,
onSave,
isSaving,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Default mode validates on submit, then re-validates on change — so we don't
// flag empty fields before the user has tried to save, but errors clear live
// once they start fixing them.
const {
control,
handleSubmit,
watch,
formState: { isDirty },
} = useForm<DrawerDraft>({
defaultValues: initialDraft,
});
const isOverride = watch('isOverride');
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !isOverride;
// Non-managers can only view (write APIs are Admin-only), so the drawer is a
// read-only "View" rather than "Edit"/"Add".
let drawerTitle = 'Add model cost';
if (!canManage) {
drawerTitle = 'View model cost';
} else if (mode === 'edit') {
drawerTitle = 'Edit model cost';
}
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<Button
variant="solid"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!isDirty}
loading={isSaving}
testId="drawer-save-btn"
>
Save
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className={styles.modelCostDrawer}
footer={footer}
title={drawerTitle}
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
Billing model ID{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Controller
name="modelName"
control={control}
rules={{
validate: (value): true | string => validateModelName(value, mode),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
required
value={field.value}
disabled={mode === 'edit' || metadataReadOnly}
aria-invalid={!!fieldState.error}
onChange={(e): void => field.onChange(e.target.value)}
testId="drawer-model-id-input"
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select">Provider</label>
<Controller
name="provider"
control={control}
rules={{ validate: validateProvider }}
render={({ field, fieldState }): JSX.Element => (
<>
<SelectSimple
id="provider-select"
value={field.value}
onChange={(value): void => field.onChange(value as string)}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-provider-select"
/>
{fieldState.error && (
<Typography.Text size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
name="patterns"
control={control}
render={({ field }): JSX.Element => (
<PatternEditor
patterns={field.value}
isReadOnly={metadataReadOnly}
onChange={field.onChange}
/>
)}
/>
{/* Source is auto vs. override — a choice only a manager can make, so
there's nothing to show a read-only viewer. */}
{canManage && (
<Controller
name="isOverride"
control={control}
// Pricing requirements depend on this toggle, so re-validate pricing
// whenever the source changes (clears/sets the pricing error).
rules={{ deps: ['pricing'] }}
render={({ field }): JSX.Element => (
<SourceSelector
isOverride={field.value}
isReadOnly={metadataReadOnly}
disableAuto={mode === 'add' || !initialDraft.sourceId}
onChange={field.onChange}
/>
)}
/>
)}
<Controller
name="pricing"
control={control}
rules={{
validate: (value, values): true | string =>
validatePricing(value, values.isOverride),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<PricingFields
pricing={field.value}
isReadOnly={pricingReadOnly}
onChange={(patch): void => field.onChange({ ...field.value, ...patch })}
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
{saveError && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{saveError}
</Typography.Text>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -1,69 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.cacheModeField {
margin-top: var(--spacing-5);
}
.extraBucketsSection {
margin-top: var(--spacing-7);
gap: var(--spacing-5);
}
.extraBucketsSectionHead {
display: flex;
align-items: center;
justify-content: space-between;
}
.bucketRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
input {
flex: 1 auto auto;
min-width: 0;
}
}
.bucketRowName {
flex: 0 0 110px;
}
.bucketAddBtn {
width: 100%;
}
.bucketPicker {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.bucketPickerTitle {
font-size: var(--periscope-font-size-small);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.bucketPickerChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}

View File

@@ -1,179 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Trash2 } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from '../../../../../constants';
import styles from './ExtraPricingBuckets.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { CacheBucketKey, DrawerDraft } from '../../../../../types';
import { Tooltip } from 'antd';
type Pricing = DrawerDraft['pricing'];
interface ExtraPricingBucketsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function ExtraPricingBuckets({
pricing,
isReadOnly,
onChange,
}: ExtraPricingBucketsProps): JSX.Element {
const [isExtraPricingBucketOpen, setIsExtraPricingBucketOpen] =
useState<boolean>(false);
// Track which buckets are shown separately from their value, so a freshly
// added bucket can start blank (value null) instead of being seeded to 0.
// Seeded from buckets that already carry a value (edit mode).
const [addedKeys, setAddedKeys] = useState<Set<CacheBucketKey>>(
() =>
new Set(
CACHE_BUCKETS.filter((b) => pricing[b.key] !== null).map((b) => b.key),
),
);
const addedBuckets = CACHE_BUCKETS.filter((b) => addedKeys.has(b.key));
const availableBuckets = CACHE_BUCKETS.filter((b) => !addedKeys.has(b.key));
const patchBucket = (key: CacheBucketKey, value: number | null): void => {
const patch: Partial<Pricing> = { [key]: value };
onChange(patch);
};
const addBucket = (key: CacheBucketKey): void => {
// Leave the value null so the field renders blank until the user types.
setAddedKeys((prev) => new Set(prev).add(key));
// Close the picker once nothing is left to add.
if (availableBuckets.length <= 1) {
setIsExtraPricingBucketOpen(false);
}
};
const removeBucket = (key: CacheBucketKey): void => {
setAddedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
patchBucket(key, null);
};
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra pricing buckets
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
optional
</Typography.Text>
</div>
{addedBuckets.map((bucket) => (
<div className={styles.bucketRow} key={bucket.key}>
<Typography.Text as="span" className={styles.bucketRowName}>
{bucket.label}
</Typography.Text>
<Input
type="number"
min={0}
step={0.01}
value={pricing[bucket.key] ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
// Clearing the field is allowed — the row stays mounted because
// presence is tracked in `addedKeys`, not the value. Removal is
// explicit via the trash button.
patchBucket(bucket.key, parsePricingAmount(e.target.value))
}
testId={`drawer-${bucket.testId}-cost`}
/>
<Tooltip title="Pricing per 1M tokens" placement="left">
<Typography.Text size="xs" color="muted">
1M
</Typography.Text>
</Tooltip>
{!isReadOnly && (
<Button
size="icon"
variant="ghost"
color="destructive"
onClick={(): void => removeBucket(bucket.key)}
aria-label={`Remove ${bucket.label}`}
data-testid={`drawer-remove-${bucket.testId}`}
prefix={<Trash2 size={14} />}
/>
)}
</div>
))}
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
{!isReadOnly && !isExtraPricingBucketOpen && availableBuckets.length > 0 && (
<Button
variant="dashed"
color="secondary"
className={styles.bucketAddBtn}
prefix={<Plus size={14} />}
onClick={(): void => setIsExtraPricingBucketOpen(true)}
testId="drawer-add-bucket-btn"
>
Add pricing bucket
</Button>
)}
{!isReadOnly && isExtraPricingBucketOpen && (
<div className={styles.bucketPicker} data-testid="drawer-bucket-picker">
<div className={styles.bucketPickerTitle}>Add a pricing bucket</div>
<div className={styles.bucketPickerChips}>
{availableBuckets.map((bucket) => (
<Button
key={bucket.key}
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => addBucket(bucket.key)}
testId={`drawer-add-bucket-${bucket.testId}`}
>
{bucket.label}
</Button>
))}
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={(): void => setIsExtraPricingBucketOpen(false)}
testId="drawer-add-bucket-cancel"
>
Cancel
</Button>
</div>
)}
</div>
);
}
export default ExtraPricingBuckets;

View File

@@ -1,49 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.help {
composes: help from '../../shared.module.scss';
}
.patternBox {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.patternChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
min-height: 28px;
}
.patternChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.patternChipRemove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--accent-cherry);
}
}
.patternAdd {
display: flex;
gap: var(--spacing-3);
}

View File

@@ -1,102 +0,0 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import styles from './PatternEditor.module.scss';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className={styles.drawerSection}>
<Typography.Text as="span">
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</Typography.Text>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className={styles.patternChip}
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className={styles.patternChipRemove}
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className={styles.patternAdd}>
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<Typography.Text as="p" size="small" color="muted">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</Typography.Text>
</div>
);
}
export default PatternEditor;

View File

@@ -1,31 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.required {
composes: required from '../../shared.module.scss';
}
.pricingGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
}

View File

@@ -1,91 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { Lock } from '@signozhq/icons';
import cx from 'classnames';
import ExtraPricingBuckets from '../ExtraPricingBuckets';
import styles from './PricingFields.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { DrawerDraft } from '../../../../../types';
import { Typography } from '@signozhq/ui/typography';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
<Lock size={12} />
<Typography.Text color="muted">Read-only</Typography.Text>
</span>
)}
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
Input cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="input-cost"
type="number"
step={0.01}
required
value={pricing.input ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parsePricingAmount(e.target.value) })
}
testId="drawer-input-cost"
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
Output cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="output-cost"
type="number"
step={0.01}
required
value={pricing.output ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parsePricingAmount(e.target.value) })
}
testId="drawer-output-cost"
/>
</div>
</div>
<ExtraPricingBuckets
pricing={pricing}
isReadOnly={isReadOnly}
onChange={onChange}
/>
</div>
);
}
export default PricingFields;

View File

@@ -1,115 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
composes: managedLabel from '../../shared.module.scss';
}
.sourceRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.sourceRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid transparent;
background: var(--l3-background);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with the title
// baseline (margin-top compensates for align-items: flex-start vs the
// title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawerSection label typography leak
// (set earlier in this file) so the title/desc divs use their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.sourceRadioAuto:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&.sourceRadioOverride:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
}
}
.sourceRadioTitle {
font-weight: var(--font-weight-semibold);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.sourceRadioDesc {
margin-top: 2px;
font-size: 12px;
color: var(--l3-foreground);
}
.resetConfirm {
margin-top: var(--spacing-6);
padding: var(--spacing-6);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--accent-primary) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, transparent);
p {
margin: 0 0 var(--spacing-5);
font-size: 12px;
}
}
.resetConfirmActions {
display: flex;
gap: var(--spacing-4);
justify-content: flex-end;
}

View File

@@ -1,115 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
disableAuto?: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
disableAuto = false,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className={styles.sourceRadioGroup}
>
<RadioGroupItem
value="auto"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
testId="drawer-source-auto"
disabled={disableAuto}
>
<div className={styles.sourceRadioTitle}>Auto-populated</div>
<div className={styles.sourceRadioDesc}>
{disableAuto
? 'Available once SigNoz has default pricing for this model.'
: 'Default pricing from SigNoz.'}
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
testId="drawer-source-override"
>
<div className={styles.sourceRadioTitle}>User override</div>
<div className={styles.sourceRadioDesc}>
Custom pricing. Takes precedence.
</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div className={styles.resetConfirm} aria-label="Reset to default pricing">
<p>
Reset to default pricing? Custom values will be discarded. It might take
24 hours for changes to take effect.
</p>
<div className={styles.resetConfirmActions}>
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -1,100 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: (draft: DrawerDraft) => Promise<void>;
isSaving: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [initialDraft, setInitialDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: '',
patterns: [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setInitialDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(
async (draft: DrawerDraft): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
},
[createOrUpdate, invalidateList, mode],
);
return {
isOpen,
mode,
initialDraft,
openForAdd,
openForEdit,
close,
save,
isSaving,
saveError,
selectedRuleId,
};
}

View File

@@ -1,2 +0,0 @@
export { default } from './ModelCostDrawer';
export { useModelCostDrawer } from './hooks/useModelCostDrawer';

View File

@@ -1,59 +0,0 @@
/* Shared drawer selectors used by 2+ of the model-cost drawer components. */
/* Components pull these in via CSS-modules `composes` from their own module so */
/* the authored class names in the TSX stay identical. */
/* NOTE: this file is a `composes` target, so it is parsed as plain CSS (no SCSS */
/* preprocessing). Keep it flat — no nesting, no slash-slash comments. */
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection .help,
.help {
margin: 0;
}
.help code {
padding: 1px var(--spacing-2);
border-radius: 3px;
background: var(--l3-background);
font-size: 10px;
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.managedLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.required {
color: var(--accent-cherry);
}
.pricingField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.pricingField input {
width: 100%;
}

View File

@@ -15,6 +15,6 @@
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--l3-foreground);
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,66 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import type { PricingRule } from '../../types';
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
// and the model name to show in the confirmation copy.
type PendingDelete = Pick<PricingRule, 'id' | 'modelName'>;
interface UseModelCostDeleteResult {
requestDelete: (rule: PendingDelete) => void;
confirmDelete: () => Promise<void>;
cancelDelete: () => void;
pendingDelete: PendingDelete | null;
isDeleting: boolean;
}
// Owns the confirm-then-delete flow for a pricing rule, independent of the
// add/edit drawer — delete is triggered from the table row menu, so this state
// lives at the panel level rather than inside useModelCostDrawer.
export function useModelCostDelete(): UseModelCostDeleteResult {
const queryClient = useQueryClient();
// The rule queued for deletion. Non-null drives the confirm dialog open.
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const requestDelete = useCallback((rule: PendingDelete): void => {
setPendingDelete({ id: rule.id, modelName: rule.modelName });
}, []);
const cancelDelete = useCallback((): void => {
setPendingDelete(null);
}, []);
const confirmDelete = useCallback(async (): Promise<void> => {
if (!pendingDelete) {
return;
}
try {
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
setPendingDelete(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
toast.error(message);
}
}, [deleteRuleApi, pendingDelete, queryClient]);
return {
requestDelete,
confirmDelete,
cancelDelete,
pendingDelete,
isDeleting,
};
}

View File

@@ -1,68 +1,6 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { CacheBucketDef, DrawerDraft } from './types';
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SEARCH_KEY = 'search';
export const SEARCH_DEBOUNCE_MS = 300;
export const SOURCE_KEY = 'source';
export type SourceFilter = 'all' | 'override' | 'auto';
export const SOURCE_FILTER_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'All sources' },
{ value: 'override', label: 'User override' },
{ value: 'auto', label: 'Auto' },
];
export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
SourceFilter,
boolean | undefined
> = {
all: undefined,
override: true,
auto: false,
};
// Match the page size so the skeleton reserves the same number of rows the
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
// https://app.notion.com/p/signoz/LLM-Tokens-Cost-Calculation-330fcc6bcd19805283ccc841d596358e?source=copy_link#33efcc6bcd1980e6a187e442c6ba5996
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
export const CACHE_BUCKETS: CacheBucketDef[] = [
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: null,
output: null,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,39 +1,4 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
// Optional pricing buckets the user can add/remove. Keyed by the matching
// DrawerDraft['pricing'] field.
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
export interface CacheBucketDef {
key: CacheBucketKey;
label: string;
testId: string;
}
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number | null;
output: number | null;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}

View File

@@ -1,19 +1,8 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
} from './types';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
@@ -24,19 +13,6 @@ const getRelativeTime = (
return parsed?.isValid() ? parsed.fromNow() : '—';
};
const hasCacheValue = (value: number | null | undefined): value is number =>
typeof value === 'number' && value > 0;
// ─── Input helpers ───────────────────────────────────────────────────────────
export const parsePricingAmount = (raw: string): number | null => {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
@@ -47,117 +23,38 @@ export const formatPricePerMillion = (value: number | undefined): string => {
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (hasCacheValue(cache.read)) {
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (hasCacheValue(cache.write)) {
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getRelativeLastSeen = (rule: PricingRule): string =>
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (rule: PricingRule): string => {
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
const buildCacheCosts = (
pricing: DrawerDraft['pricing'],
): LlmpricingruletypesLLMPricingCacheCostsDTO | undefined => {
const { cacheMode, cacheRead, cacheWrite } = pricing;
if (!hasCacheValue(cacheRead) && !hasCacheValue(cacheWrite)) {
return undefined;
}
return {
mode: cacheMode,
...(hasCacheValue(cacheRead) && { read: cacheRead }),
...(hasCacheValue(cacheWrite) && { write: cacheWrite }),
};
};
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const cache = buildCacheCosts(draft.pricing);
return {
input: draft.pricing.input ?? 0,
output: draft.pricing.output ?? 0,
...(cache && { cache }),
};
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateModelName = (
modelName: string,
mode: DrawerMode,
): true | string =>
mode === 'add' && !modelName.trim() ? 'Billing model ID is required.' : true;
export const validateProvider = (provider: string): true | string =>
provider.trim() ? true : 'Provider is required.';
export const validatePricing = (
pricing: DrawerDraft['pricing'],
isOverride: boolean,
): true | string => {
if (!isOverride) {
return true;
}
if (pricing.input === null || pricing.input <= 0) {
return 'Input cost must be greater than 0.';
}
if (pricing.output === null || pricing.output <= 0) {
return 'Output cost must be greater than 0.';
}
if ((pricing.cacheRead ?? 0) < 0 || (pricing.cacheWrite ?? 0) < 0) {
return 'Cache costs must be non-negative.';
}
return true;
};

View File

@@ -1,14 +1,9 @@
@use '../../styles/scrollbar' as *;
.members-settings-page {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
overflow-y: auto;
@include custom-scrollbar;
}
.members-settings {

View File

@@ -1,8 +0,0 @@
.emptyMeterSearch {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

View File

@@ -1,26 +0,0 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './EmptyMeterSearch.module.scss';
interface EmptyMeterSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMeterSearch({
hasQueryResult,
}: EmptyMeterSearchProps): JSX.Element {
return (
<div className={styles.emptyMeterSearch}>
<Empty
description={
<Typography.Title level={5}>
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
</Typography.Title>
}
/>
</div>
);
}

View File

@@ -73,6 +73,34 @@
margin-top: 10px;
margin-bottom: 20px;
}
.empty-meter-search {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.time-series-view-panel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 8px !important;
margin: 8px;
}
.time-series-container {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(100%, calc(50% - 8px)), 1fr)
);
gap: 16px;
width: 100%;
height: fit-content;
}
}
}
@@ -85,6 +113,22 @@
padding-bottom: 80px;
}
.meter-time-series-container {
display: flex;
flex-direction: column;
gap: 10px;
.builder-units-filter {
padding: 0 8px;
margin-bottom: 0px !important;
.builder-units-filter-label {
margin-bottom: 0px !important;
font-size: 12px;
}
}
}
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;

View File

@@ -35,6 +35,7 @@ function Explorer(): JSX.Element {
handleRunQuery,
stagedQuery,
updateAllQueriesOperators,
handleSetQueryData,
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
@@ -66,6 +67,15 @@ function Explorer(): JSX.Element {
[updateAllQueriesOperators],
);
useEffect(() => {
handleSetQueryData(0, {
...initialQueryMeterWithType.builder.queryData[0],
source: 'meter',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(

View File

@@ -1,18 +0,0 @@
.loadingMeter {
display: flex;
justify-content: center;
align-items: flex-start;
height: 240px;
padding: var(--spacing-12) 0;
}
.loadingMeterContent {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.loadingGif {
height: 72px;
margin-left: calc(-1 * var(--spacing-12));
}

View File

@@ -1,17 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import styles from './MeterLoading.module.scss';
export default function MeterLoading(): JSX.Element {
return (
<div className={styles.loadingMeter}>
<div className={styles.loadingMeterContent}>
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
<Typography>Retrieving your {DataSource.METRICS}</Typography>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
.meterTimeSeriesContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-5);
width: 100%;
:global(.builder-units-filter) {
padding: 0 var(--spacing-4);
margin-bottom: 0 !important;
}
:global(.builder-units-filter-label) {
margin-bottom: 0 !important;
font-size: 12px;
}
}
.timeSeriesContainer {
gap: var(--spacing-8);
width: 100%;
height: 50vh;
max-height: 50vh;
padding-right: 16px;
padding-left: 8px;
}
.timeSeriesViewPanel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
}

Some files were not shown because too many files have changed in this diff Show More