mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 04:40:37 +01:00
Compare commits
1 Commits
feat/authz
...
feat/llm-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75da4a59ea |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,11 +177,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return nil, err
|
||||
}
|
||||
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
|
||||
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
|
||||
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
|
||||
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
|
||||
}
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
|
||||
@@ -65,31 +65,15 @@ web:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: false
|
||||
# The PostHog project API key.
|
||||
key: ""
|
||||
# The PostHog API host. Defaults to https://us.i.posthog.com when empty.
|
||||
api_host: ""
|
||||
# The PostHog UI host. Used when api_host points at a reverse proxy.
|
||||
ui_host: ""
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: false
|
||||
# The Appcues account/app ID.
|
||||
app_id: ""
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: false
|
||||
# The Sentry DSN.
|
||||
dsn: ""
|
||||
# The Sentry tunnel URL.
|
||||
tunnel: ""
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: false
|
||||
# The Pylon app ID.
|
||||
app_id: ""
|
||||
# The Pylon identity verification secret.
|
||||
identity_secret: ""
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -618,6 +618,13 @@ components:
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesPatchableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- description
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
@@ -1017,8 +1024,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesAgentReport:
|
||||
nullable: true
|
||||
@@ -1164,8 +1169,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
|
||||
type: object
|
||||
CloudintegrationtypesCredentials:
|
||||
properties:
|
||||
@@ -1196,46 +1199,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesGCPAccountConfig:
|
||||
properties:
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionArtifact:
|
||||
type: object
|
||||
CloudintegrationtypesGCPIntegrationConfig:
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceConfig:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceLogsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceMetricsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||
properties:
|
||||
connectionArtifact:
|
||||
@@ -1368,8 +1331,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -1394,8 +1355,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
@@ -1440,8 +1399,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
@@ -1484,7 +1441,6 @@ components:
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -1546,8 +1502,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAzureAccountConfig:
|
||||
properties:
|
||||
@@ -1558,22 +1512,6 @@ components:
|
||||
required:
|
||||
- resourceGroups
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableGCPAccountConfig:
|
||||
properties:
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableService:
|
||||
properties:
|
||||
config:
|
||||
@@ -2529,6 +2467,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:
|
||||
@@ -2714,14 +2668,6 @@ components:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
filterBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
panelId:
|
||||
type: string
|
||||
panelName:
|
||||
@@ -3571,7 +3517,7 @@ components:
|
||||
- user
|
||||
- system
|
||||
- integration
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
@@ -5397,9 +5343,6 @@ components:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
ingestedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5412,9 +5355,9 @@ components:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
retainedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
retainedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5432,8 +5375,7 @@ components:
|
||||
- active
|
||||
- ingestedSeries
|
||||
- retainedSeries
|
||||
- ingestedSamples
|
||||
- retainedSamples
|
||||
- reductionPercent
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRulePreview:
|
||||
properties:
|
||||
@@ -5476,23 +5418,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:
|
||||
@@ -5558,6 +5492,7 @@ components:
|
||||
- metric
|
||||
- ingested_volume
|
||||
- reduced_volume
|
||||
- reduction
|
||||
- last_updated
|
||||
type: string
|
||||
MetricreductionruletypesUpdatableReductionRule:
|
||||
@@ -6277,25 +6212,6 @@ components:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
Querybuildertypesv5PreviewStatement:
|
||||
properties:
|
||||
db.statement.args:
|
||||
items: {}
|
||||
type: array
|
||||
db.statement.query:
|
||||
type: string
|
||||
estimate:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesEstimateEntry'
|
||||
type: array
|
||||
granules:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesGranules'
|
||||
required:
|
||||
- db.statement.query
|
||||
- db.statement.args
|
||||
- estimate
|
||||
- granules
|
||||
type: object
|
||||
Querybuildertypesv5PromQuery:
|
||||
properties:
|
||||
disabled:
|
||||
@@ -6616,40 +6532,6 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryPreview:
|
||||
properties:
|
||||
error: {}
|
||||
statements:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
|
||||
type: array
|
||||
valid:
|
||||
type: boolean
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- valid
|
||||
- error
|
||||
- warnings
|
||||
- statements
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangePreviewResponse:
|
||||
description: Response from the v5 query range preview (dry-run) endpoint. For
|
||||
each query in the composite query, returns the underlying ClickHouse statement(s)
|
||||
it renders to without executing them (one per PromQL metric selector; exactly
|
||||
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
|
||||
ESTIMATE and granule analysis attached per statement when requested.
|
||||
properties:
|
||||
compositeQuery:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
|
||||
nullable: true
|
||||
type: object
|
||||
required:
|
||||
- compositeQuery
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeRequest:
|
||||
description: Request body for the v5 query range endpoint. Supports builder
|
||||
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
|
||||
@@ -8000,96 +7882,6 @@ components:
|
||||
- key
|
||||
- value
|
||||
type: object
|
||||
TelemetrystoretypesEstimateEntry:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
marks:
|
||||
format: int64
|
||||
type: integer
|
||||
parts:
|
||||
format: int64
|
||||
type: integer
|
||||
rows:
|
||||
format: int64
|
||||
type: integer
|
||||
table:
|
||||
type: string
|
||||
required:
|
||||
- database
|
||||
- table
|
||||
- parts
|
||||
- rows
|
||||
- marks
|
||||
type: object
|
||||
TelemetrystoretypesGranules:
|
||||
nullable: true
|
||||
properties:
|
||||
initial:
|
||||
format: int64
|
||||
type: integer
|
||||
reads:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesMergeTreeRead'
|
||||
type: array
|
||||
selected:
|
||||
format: int64
|
||||
type: integer
|
||||
skipped:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- initial
|
||||
- selected
|
||||
- skipped
|
||||
- reads
|
||||
type: object
|
||||
TelemetrystoretypesIndexStep:
|
||||
properties:
|
||||
condition:
|
||||
type: string
|
||||
initialGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
initialParts:
|
||||
format: int64
|
||||
type: integer
|
||||
keys:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
selectedGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
selectedParts:
|
||||
format: int64
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- keys
|
||||
- condition
|
||||
- initialParts
|
||||
- selectedParts
|
||||
- initialGranules
|
||||
- selectedGranules
|
||||
type: object
|
||||
TelemetrystoretypesMergeTreeRead:
|
||||
properties:
|
||||
steps:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesIndexStep'
|
||||
type: array
|
||||
table:
|
||||
type: string
|
||||
required:
|
||||
- table
|
||||
- steps
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -11821,6 +11613,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
|
||||
@@ -11883,6 +11737,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
|
||||
@@ -23407,75 +23413,6 @@ paths:
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/query_range/preview:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Validate a composite query without executing it. Accepts the same
|
||||
payload as the query range endpoint. By default (verbose=true) returns, for
|
||||
each query, the rendered underlying ClickHouse statement(s) with each statement''s
|
||||
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
|
||||
granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight
|
||||
per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse
|
||||
round trips. Intended for agentic/dry-run consumption: per-query errors are
|
||||
reported in the response rather than failing the whole request.'
|
||||
operationId: QueryRangePreviewV5
|
||||
parameters:
|
||||
- in: query
|
||||
name: verbose
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/substitute_vars:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -101,10 +101,6 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRange(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRangePreview(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRawStream(rw, req)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
signoz.Cache,
|
||||
signoz.Flagger,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const IS_DEV = false;
|
||||
export const IS_PROD = true;
|
||||
export const MODE = 'test';
|
||||
@@ -29,7 +29,6 @@ const config: Config.InitialOptions = {
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
|
||||
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
|
||||
14
frontend/pnpm-lock.yaml
generated
14
frontend/pnpm-lock.yaml
generated
@@ -432,9 +432,6 @@ importers:
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260430.1
|
||||
version: 7.0.0-dev.20260430.1
|
||||
babel-plugin-transform-import-meta:
|
||||
specifier: ^2.3.3
|
||||
version: 2.3.3(@babel/core@7.29.0)
|
||||
eslint-plugin-sonarjs:
|
||||
specifier: 4.0.2
|
||||
version: 4.0.2(eslint@10.2.1(jiti@2.6.1))
|
||||
@@ -4092,11 +4089,6 @@ packages:
|
||||
babel-plugin-syntax-jsx@6.18.0:
|
||||
resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==}
|
||||
|
||||
babel-plugin-transform-import-meta@2.3.3:
|
||||
resolution: {integrity: sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q==}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.10.0
|
||||
|
||||
babel-preset-current-node-syntax@1.2.0:
|
||||
resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==}
|
||||
peerDependencies:
|
||||
@@ -13005,12 +12997,6 @@ snapshots:
|
||||
|
||||
babel-plugin-syntax-jsx@6.18.0: {}
|
||||
|
||||
babel-plugin-transform-import-meta@2.3.3(@babel/core@7.29.0):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/template': 7.28.6
|
||||
tslib: 2.8.1
|
||||
|
||||
babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -323,6 +323,13 @@ export const AIAssistantPage = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityAttributeMappingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityAttributeMappingPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
@@ -514,6 +515,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityAttributeMappingPage,
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
exact: true,
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
QueryRangePreviewV5200,
|
||||
QueryRangePreviewV5Params,
|
||||
QueryRangeV5200,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -106,107 +104,6 @@ export const useQueryRangeV5 = <
|
||||
> => {
|
||||
return useMutation(getQueryRangeV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const queryRangePreviewV5 = (
|
||||
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
|
||||
params?: QueryRangePreviewV5Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<QueryRangePreviewV5200>({
|
||||
url: `/api/v5/query_range/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: querybuildertypesv5QueryRangeRequestDTO,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getQueryRangePreviewV5MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['queryRangePreviewV5'];
|
||||
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 queryRangePreviewV5>>,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
}
|
||||
> = (props) => {
|
||||
const { data, params } = props ?? {};
|
||||
|
||||
return queryRangePreviewV5(data, params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>
|
||||
>;
|
||||
export type QueryRangePreviewV5MutationBody =
|
||||
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
|
||||
| undefined;
|
||||
export type QueryRangePreviewV5MutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const useQueryRangePreviewV5 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getQueryRangePreviewV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replace variables in a query
|
||||
* @summary Replace variables
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -2230,6 +2230,13 @@ export interface AuthtypesOrgSessionContextDTO {
|
||||
warning?: ErrorsJSONDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPatchableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
@@ -2623,25 +2630,9 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountDTO {
|
||||
@@ -2749,29 +2740,9 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
|
||||
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceConfigDTO {
|
||||
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
|
||||
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSServiceConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureServiceConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesServiceIDDTO {
|
||||
@@ -2802,7 +2773,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql = 'cloudsql',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -2867,14 +2837,9 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
|
||||
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesCredentialsDTO {
|
||||
@@ -2907,10 +2872,6 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
|
||||
/**
|
||||
@@ -3002,7 +2963,6 @@ export type CloudintegrationtypesIntegrationConfigDTO =
|
||||
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
@@ -3065,7 +3025,6 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -3195,25 +3154,9 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
projectIds: string[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountDTO {
|
||||
@@ -3242,6 +3185,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;
|
||||
/**
|
||||
@@ -3977,14 +3931,6 @@ export interface DashboardtypesDashboardPanelRefDTO {
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
filterBy?: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6909,11 +6855,6 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6929,10 +6870,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
retainedSamples: number;
|
||||
reductionPercent: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6991,21 +6932,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
|
||||
@@ -7061,6 +6992,7 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
|
||||
metric = 'metric',
|
||||
ingested_volume = 'ingested_volume',
|
||||
reduced_volume = 'reduced_volume',
|
||||
reduction = 'reduction',
|
||||
last_updated = 'last_updated',
|
||||
}
|
||||
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
|
||||
@@ -7623,126 +7555,6 @@ export interface Querybuildertypesv5FormatOptionsDTO {
|
||||
formatTableResultForUI?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesEstimateEntryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
database: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
marks: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
parts: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
rows: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table: string;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesIndexStepDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
condition: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialGranules: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialParts: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
keys: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedGranules: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedParts: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesMergeTreeReadDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
steps: TelemetrystoretypesIndexStepDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table: string;
|
||||
}
|
||||
|
||||
export type TelemetrystoretypesGranulesDTOAnyOf = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initial: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reads: TelemetrystoretypesMergeTreeReadDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selected: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TelemetrystoretypesGranulesDTO =
|
||||
TelemetrystoretypesGranulesDTOAnyOf | null;
|
||||
|
||||
export interface Querybuildertypesv5PreviewStatementDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
'db.statement.args': unknown[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
'db.statement.query': string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
estimate: TelemetrystoretypesEstimateEntryDTO[];
|
||||
granules: TelemetrystoretypesGranulesDTO | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -7824,41 +7636,6 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
|
||||
export interface Querybuildertypesv5QueryPreviewDTO {
|
||||
error: unknown;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
statements: Querybuildertypesv5PreviewStatementDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
valid: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
|
||||
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
|
||||
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
|
||||
|
||||
/**
|
||||
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
compositeQuery: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5VariableTypeDTO {
|
||||
query = 'query',
|
||||
dynamic = 'dynamic',
|
||||
@@ -10252,9 +10029,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
|
||||
@@ -11711,22 +11510,6 @@ export type QueryRangeV5200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
verbose?: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5200 = {
|
||||
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ReplaceVariables200 = {
|
||||
data: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
@@ -16,8 +13,6 @@ const noPermissions = {
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -167,7 +167,6 @@ describe('InviteMembers - Submission', () => {
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -244,7 +243,6 @@ describe('InviteMembers - Submission', () => {
|
||||
error: 'User already exists',
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -22,9 +22,9 @@ export interface FooterRenderProps {
|
||||
|
||||
export interface UseInviteMembersOptions {
|
||||
initialRowCount?: number;
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
@@ -56,9 +56,9 @@ export interface InviteMembersProps {
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -207,11 +207,11 @@ export function useInviteMembers(
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.(results, touched);
|
||||
onSuccess?.();
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results, touched);
|
||||
onPartialSuccess?.(results);
|
||||
} else {
|
||||
onAllFailed?.(results, touched);
|
||||
onAllFailed?.(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.invite-members-modal__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-members-modal__table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
.email-header {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
.role-header {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-member-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
> .email-cell {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
> .role-cell {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .action-cell {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--l1-border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.team-member-role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.email-error-message {
|
||||
display: block;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.invite-team-members-error-callout {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
}
|
||||
|
||||
.invite-members-modal__error-callout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes horizontal-shaking {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-members-modal__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.add-another-member-button {
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
border-style: dashed;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
interface InviteRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ROLES | '';
|
||||
}
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
|
||||
|
||||
const isRowTouched = (row: InviteRow): boolean =>
|
||||
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
|
||||
const resetAndClose = useCallback((): void => {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const validateAllUsers = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
|
||||
touchedRows.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (row.id) {
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
}
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
debouncedValidateEmail.cancel();
|
||||
}
|
||||
return (): void => {
|
||||
debouncedValidateEmail.cancel();
|
||||
};
|
||||
}, [open, debouncedValidateEmail]);
|
||||
|
||||
const updateEmail = (id: string, email: string): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.email = email;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = (id: string, role: ROLES): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.role = role;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((prev) => [...prev, EMPTY_ROW()]);
|
||||
};
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
if (!validateAllUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
if (touchedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (touchedRows.length === 1) {
|
||||
const row = touchedRows[0];
|
||||
await sendInvite({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role as ROLES,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
});
|
||||
} else {
|
||||
await inviteUsers({
|
||||
invites: touchedRows.map((row) => ({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
showErrorModal(err as APIError);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
resetAndClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
<div className="invite-members-modal__table-header">
|
||||
<div className="table-header-cell email-header">Email address</div>
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
<div className="invite-members-modal__container">
|
||||
{rows.map(
|
||||
(row): JSX.Element => (
|
||||
<div key={row.id} className="team-member-row">
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@signoz.io"
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={row.role || undefined}
|
||||
onChange={(role): void => updateRole(row.id, role as ROLES)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{rows.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<div className="invite-members-modal__error-callout">
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
title={getValidationErrorMessage()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="invite-members-modal__footer">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className="add-another-member-button"
|
||||
prefix={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
|
||||
<div className="invite-members-modal__footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -0,0 +1,276 @@
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode: code,
|
||||
error: { code: 'already_exists', message, url: '', errors: [] },
|
||||
});
|
||||
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/invite/bulk/create');
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
});
|
||||
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders 3 initial empty rows and disables the submit button', () => {
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('validation callout messages', () => {
|
||||
it('shows combined message when email is invalid and role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'not-an-email',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email-only message when email is invalid but role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'not-an-email');
|
||||
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role-only message when email is valid but role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'valid@signoz.io',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses sendInvite (single) when only one row is filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'single@signoz.io');
|
||||
|
||||
const roleSelects = screen.getAllByText('Select roles');
|
||||
await user.click(roleSelects[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
|
||||
);
|
||||
expect(mockInviteUsers).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
);
|
||||
mockInviteUsers.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'Internal server error',
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInviteUsers).toHaveBeenCalledWith({
|
||||
invites: expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
|
||||
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
|
||||
]),
|
||||
});
|
||||
expect(mockSendInvite).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,10 @@ export function useRoles(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role[valueField] ?? '',
|
||||
value: role.id ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -85,7 +82,6 @@ interface BaseProps {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
valueField?: 'id' | 'name';
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -117,7 +113,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles, props.valueField || 'id');
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { IS_DEV } from 'lib/env';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
@@ -31,33 +30,6 @@ import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
const AuthZDevModal = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
|
||||
default: m.AuthZDevModal,
|
||||
})),
|
||||
)
|
||||
: null;
|
||||
|
||||
const AuthZDevFloatingIndicator = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
|
||||
(m) => ({
|
||||
default: m.AuthZDevFloatingIndicator,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const openAuthZDevModal = IS_DEV
|
||||
? (): void => {
|
||||
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
|
||||
m.openAuthZDevModal();
|
||||
return m;
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -138,7 +110,6 @@ export function CmdKPalette({
|
||||
aiAssistant: isAIAssistantEnabled
|
||||
? { open: handleOpenAIAssistant }
|
||||
: undefined,
|
||||
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
|
||||
});
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
@@ -175,57 +146,37 @@ export function CmdKPalette({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
position="top"
|
||||
offset={110}
|
||||
>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span
|
||||
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'cmd-item-icon',
|
||||
it.id === 'ai-assistant' && 'noz-icon',
|
||||
)}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
{IS_DEV && AuthZDevModal && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevModal />
|
||||
</React.Suspense>
|
||||
)}
|
||||
{IS_DEV && AuthZDevFloatingIndicator && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevFloatingIndicator />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -89,6 +89,8 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
|
||||
'/llm-observability/settings/attribute-mapping',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
} as const;
|
||||
|
||||
@@ -43,17 +43,10 @@ type ActionDeps = {
|
||||
aiAssistant?: {
|
||||
open: () => void;
|
||||
};
|
||||
/**
|
||||
* Provided only in development mode. Opens the AuthZ DevTools modal
|
||||
* for testing permission overrides.
|
||||
*/
|
||||
authzDevTools?: {
|
||||
open: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
|
||||
const { navigate, handleThemeChange, aiAssistant } = deps;
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
@@ -309,17 +302,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (authzDevTools) {
|
||||
actions.push({
|
||||
id: 'authz-devtools',
|
||||
name: 'AuthZ DevTools',
|
||||
keywords: 'authz permissions rbac debug devtools override testing',
|
||||
section: 'Dev',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: authzDevTools.open,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-16);
|
||||
border: 1px dashed var(--bg-slate-400);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AttributeMappingTabPanel.module.scss';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function AttributeMappingTabPanel(): JSX.Element {
|
||||
// Draft state, mapping-group listing and the save/discard wiring land in the
|
||||
// next PRs. For now this is a read-only shell with disabled actions.
|
||||
const isDirty = false;
|
||||
const isSaving = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.actions}>
|
||||
{isDirty && (
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
as="span"
|
||||
testId="unsaved-changes"
|
||||
>
|
||||
Unsaved changes
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={noop}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={noop}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
|
||||
<Typography.Text color="muted">
|
||||
No mapping groups configured yet.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingTabPanel;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AttributeMappingTabPanel';
|
||||
@@ -0,0 +1,19 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import AttributeMappingTabPanel from './AttributeMappingTabPanel';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Map source LLM trace attributes to SigNoz semantic conventions
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Attribute mapping is the only tab for now. As more grouping/mapping
|
||||
// views land, this can become a URL-backed param like model pricing.
|
||||
defaultValue="attribute-mapping"
|
||||
items={[
|
||||
{
|
||||
key: 'attribute-mapping',
|
||||
label: 'Attribute mapping',
|
||||
children: <AttributeMappingTabPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './DeleteConfirmDialog';
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ExtraPricingBuckets';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PatternEditor';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PricingFields';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './SourceSelector';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './ModelCostDrawer';
|
||||
export { useModelCostDrawer } from './hooks/useModelCostDrawer';
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'container/MembersSettings/components/InviteMembersModal/InviteMembersModal';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
@@ -110,9 +110,7 @@ describe('MembersSettings (integration)', () => {
|
||||
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Member Details'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
@@ -129,7 +127,7 @@ describe('MembersSettings (integration)', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
padding-top: var(--padding-4);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const handleSuccess = useCallback((): void => {
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
onClose();
|
||||
onComplete?.();
|
||||
}, [onClose, onComplete]);
|
||||
|
||||
const handlePartialSuccess = useCallback((): void => {
|
||||
toast.warning('Some invites failed', { position: 'top-right' });
|
||||
onComplete?.();
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
>
|
||||
<InviteMembers
|
||||
onSuccess={handleSuccess}
|
||||
onPartialSuccess={handlePartialSuccess}
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
|
||||
<div className="invite-members-modal__footer">
|
||||
<Button type="button" variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockInviteMembersProps {
|
||||
onSuccess: () => void;
|
||||
onPartialSuccess: () => void;
|
||||
onAllFailed?: () => void;
|
||||
renderFooter: (props: {
|
||||
submit: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
|
||||
let mockInviteMembersProps: MockInviteMembersProps | null = null;
|
||||
|
||||
jest.mock('components/InviteMembers/InviteMembers', () => {
|
||||
return function MockInviteMembers(props: MockInviteMembersProps): JSX.Element {
|
||||
mockInviteMembersProps = props;
|
||||
return (
|
||||
<div data-testid="mock-invite-members">
|
||||
{props.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
function renderComponent(
|
||||
props: Partial<typeof defaultProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(<InviteMembersModal {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockInviteMembersProps = null;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders modal with title and InviteMembers component', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
|
||||
'Invite Team Members',
|
||||
);
|
||||
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when open=false', () => {
|
||||
renderComponent({ open: false });
|
||||
|
||||
expect(screen.queryByText('Invite Team Members')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer buttons', () => {
|
||||
it('renders Cancel and Invite buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Invite button when canSubmit=false', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: false,
|
||||
isSubmitting: false,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: /invite team members/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows loading state when isSubmitting=true', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: true,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: /inviting/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls submit when Invite button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSubmit = jest.fn();
|
||||
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: mockSubmit,
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
await user.click(getByRole('button', { name: /invite team members/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSuccess callback', () => {
|
||||
it('shows success toast, calls onClose and onComplete', () => {
|
||||
const onClose = jest.fn();
|
||||
const onComplete = jest.fn();
|
||||
renderComponent({ onClose, onComplete });
|
||||
|
||||
mockInviteMembersProps?.onSuccess();
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('Invites sent successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works without onComplete prop', () => {
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose, onComplete: undefined });
|
||||
|
||||
mockInviteMembersProps?.onSuccess();
|
||||
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePartialSuccess callback', () => {
|
||||
it('shows warning toast and calls onComplete', () => {
|
||||
const onComplete = jest.fn();
|
||||
renderComponent({ onComplete });
|
||||
|
||||
mockInviteMembersProps?.onPartialSuccess();
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith('Some invites failed', {
|
||||
position: 'top-right',
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClose on partial success', () => {
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
mockInviteMembersProps?.onPartialSuccess();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog close behavior', () => {
|
||||
it('calls onClose when dialog is closed via close button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user