mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 04:40:37 +01:00
Compare commits
20 Commits
nv/dashboa
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f23273cf | ||
|
|
66f03d5912 | ||
|
|
cf69a05f74 | ||
|
|
1648fce5b1 | ||
|
|
f93a70884a | ||
|
|
e1c586e0dc | ||
|
|
984b2d0138 | ||
|
|
3ea62d3d50 | ||
|
|
9317a26337 | ||
|
|
fde817d83c | ||
|
|
13812fac62 | ||
|
|
df77b8d125 | ||
|
|
028ac27496 | ||
|
|
8b56f39261 | ||
|
|
2be1063602 | ||
|
|
6546602242 | ||
|
|
ba49b28c5a | ||
|
|
adc3909b71 | ||
|
|
e00f47c812 | ||
|
|
b2f048770e |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -109,10 +109,7 @@ go.mod @therealpandey
|
||||
/pkg/modules/role/ @therealpandey
|
||||
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
|
||||
|
||||
/frontend/src/hooks/useAuthZ/ @H4ad
|
||||
/frontend/src/components/GuardAuthZ/ @H4ad
|
||||
/frontend/src/components/AuthZTooltip/ @H4ad
|
||||
/frontend/src/components/createGuardedRoute/ @H4ad
|
||||
/frontend/src/lib/authz/ @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/hooks/useAuthZ/permissions.type.ts"
|
||||
const permissionsTypePath = "frontend/src/lib/authz/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,9 +177,11 @@ 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,15 +65,31 @@ 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,13 +618,6 @@ components:
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesPatchableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- description
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
@@ -1024,6 +1017,8 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesAgentReport:
|
||||
nullable: true
|
||||
@@ -1169,6 +1164,8 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
|
||||
type: object
|
||||
CloudintegrationtypesCredentials:
|
||||
properties:
|
||||
@@ -1199,6 +1196,46 @@ 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:
|
||||
@@ -1331,6 +1368,8 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -1355,6 +1394,8 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
@@ -1399,6 +1440,8 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
@@ -1441,6 +1484,7 @@ components:
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -1502,6 +1546,8 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAzureAccountConfig:
|
||||
properties:
|
||||
@@ -1512,6 +1558,22 @@ 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:
|
||||
@@ -2467,22 +2529,6 @@ 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:
|
||||
@@ -2626,6 +2672,7 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -2667,6 +2714,14 @@ components:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
filterBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
panelId:
|
||||
type: string
|
||||
panelName:
|
||||
@@ -3516,7 +3571,7 @@ components:
|
||||
- user
|
||||
- system
|
||||
- integration
|
||||
type: object
|
||||
type: string
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
@@ -3567,6 +3622,7 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3603,6 +3659,7 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -5340,6 +5397,9 @@ components:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
ingestedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5352,9 +5412,9 @@ components:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
retainedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
retainedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5372,7 +5432,8 @@ components:
|
||||
- active
|
||||
- ingestedSeries
|
||||
- retainedSeries
|
||||
- reductionPercent
|
||||
- ingestedSamples
|
||||
- retainedSamples
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRulePreview:
|
||||
properties:
|
||||
@@ -5415,15 +5476,23 @@ 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:
|
||||
@@ -5489,7 +5558,6 @@ components:
|
||||
- metric
|
||||
- ingested_volume
|
||||
- reduced_volume
|
||||
- reduction
|
||||
- last_updated
|
||||
type: string
|
||||
MetricreductionruletypesUpdatableReductionRule:
|
||||
@@ -6209,6 +6277,25 @@ 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:
|
||||
@@ -6529,6 +6616,40 @@ 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,
|
||||
@@ -7879,6 +8000,96 @@ 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
|
||||
@@ -11610,68 +11821,6 @@ 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
|
||||
@@ -11734,158 +11883,6 @@ 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
|
||||
@@ -23410,6 +23407,75 @@ 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,40 +260,6 @@ 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 {
|
||||
@@ -324,39 +290,6 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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][]map[string]string, error) {
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ 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
|
||||
@@ -289,12 +291,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
orderExpr := "ingested"
|
||||
switch orderBy {
|
||||
case metricreductionruletypes.OrderByReducedVolume:
|
||||
orderExpr = "reduced"
|
||||
case metricreductionruletypes.OrderByReduction:
|
||||
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
|
||||
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))"
|
||||
}
|
||||
direction := "ASC"
|
||||
if order == metricreductionruletypes.OrderDesc {
|
||||
@@ -310,17 +309,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 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, 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",
|
||||
"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 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"+
|
||||
"(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"+
|
||||
" UNION ALL "+
|
||||
"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"+
|
||||
"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"+
|
||||
") GROUP BY metric_name) AS d",
|
||||
"base.metric_name = d.metric_name",
|
||||
)
|
||||
@@ -347,122 +346,186 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
|
||||
func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return 0, 0, nil
|
||||
return map[string]volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return ingested, min(last+sum, ingested), nil
|
||||
}
|
||||
|
||||
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("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...)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
// 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...)
|
||||
|
||||
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)
|
||||
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, 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)
|
||||
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, 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
|
||||
}
|
||||
|
||||
func (c *clickhouse) countSamplesByMetric(ctx context.Context, table, countExpr string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", countExpr)
|
||||
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
|
||||
}
|
||||
for ts, count := range reduced {
|
||||
retained[ts] += count
|
||||
}
|
||||
}
|
||||
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)
|
||||
ruledRetained, err = c.ruledRetainedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range nonReducedIngested {
|
||||
retained[ts] += count
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
counts, err := c.scanBuckets(ctx, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range counts {
|
||||
out[ts] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
|
||||
buckets := make(map[int64]struct{}, len(ingested))
|
||||
for ts := range ingested {
|
||||
@@ -488,60 +551,6 @@ 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,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -28,9 +27,16 @@ 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 (see signoz-otel-collector#839).
|
||||
effectiveFromMargin = 5 * time.Minute
|
||||
defaultPreviewLookback = 24 * time.Hour
|
||||
// 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
|
||||
|
||||
pricePerMillionSamplesUSD = 0.1
|
||||
monthDuration = 30 * 24 * time.Hour
|
||||
@@ -80,7 +86,7 @@ func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricredu
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
startMs := now.Add(-statsLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
switch params.OrderBy {
|
||||
@@ -107,10 +113,14 @@ 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]))
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName], sampleVolumes[rule.MetricName]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
@@ -139,13 +149,24 @@ 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))
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), row, sampleVolumes[row.MetricName]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
@@ -288,20 +309,17 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
startMs := now.Add(-statsLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
rules, _, 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(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames := make([]string, len(rules))
|
||||
effectiveFrom := make(map[string]int64, len(rules))
|
||||
for i, rule := range rules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
@@ -310,31 +328,43 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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]
|
||||
}
|
||||
var ruledIngestedSeries, ruledRetainedSeries uint64
|
||||
for _, volume := range volumes {
|
||||
ruledIngestedSeries += volume.Ingested
|
||||
ruledRetainedSeries += effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
}
|
||||
|
||||
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{
|
||||
IngestedSeries: ingestedSeries,
|
||||
RetainedSeries: retainedSeries,
|
||||
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
|
||||
IngestedSeries: totalSeries,
|
||||
RetainedSeries: clampSub(totalSeries, ruledIngestedSeries-ruledRetainedSeries),
|
||||
IngestedSamples: totalSamples,
|
||||
RetainedSamples: clampSub(totalSamples, ruledIngestedSamples-ruledRetainedSamples),
|
||||
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ruledIngestedSamples, ruledRetainedSamples, 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 {
|
||||
@@ -352,7 +382,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
startMs := now.Add(-timeseriesLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
@@ -366,18 +396,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
|
||||
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)
|
||||
points, err := m.ch.SampleTimeseries(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -414,7 +433,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, metricName)
|
||||
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, orgID, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -447,12 +466,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(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
|
||||
usedLabels := append(append([]string{}, item.GroupBy...), item.FilterBy...)
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeDashboard,
|
||||
ID: item["dashboard_id"],
|
||||
Name: item["dashboard_name"],
|
||||
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
|
||||
ID: item.DashboardID,
|
||||
Name: item.DashboardName,
|
||||
Widget: &metricreductionruletypes.AffectedWidget{ID: item.PanelID, Name: item.PanelName},
|
||||
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
|
||||
})
|
||||
}
|
||||
@@ -482,7 +501,7 @@ func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metri
|
||||
MatchType: rule.MatchType,
|
||||
Labels: rule.Labels,
|
||||
EffectiveFrom: rule.EffectiveFrom,
|
||||
Active: !rule.EffectiveFrom.After(time.Now()),
|
||||
Active: !rule.EffectiveFrom.Add(uiActivationDelay).After(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,12 +512,11 @@ func effectiveRetained(ingested, reduced uint64) uint64 {
|
||||
return 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
|
||||
}
|
||||
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)
|
||||
return rule
|
||||
}
|
||||
|
||||
@@ -518,13 +536,6 @@ 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 ?", "%"+params.Search+"%")
|
||||
query = query.Where("metric_name LIKE ? ESCAPE '\\'", "%"+s.sqlstore.Formatter().EscapeLikePattern(params.Search)+"%")
|
||||
}
|
||||
if params.MetricName != "" {
|
||||
query = query.Where("metric_name = ?", params.MetricName)
|
||||
|
||||
@@ -101,6 +101,10 @@ 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,6 +64,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
signoz.Cache,
|
||||
signoz.Flagger,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,5 +61,7 @@
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
|
||||
@@ -86,5 +86,7 @@
|
||||
"ROLE_EDIT": "SigNoz | Edit Role",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
QueryRangePreviewV5200,
|
||||
QueryRangePreviewV5Params,
|
||||
QueryRangeV5200,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -104,6 +106,107 @@ 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,19 +18,13 @@ 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';
|
||||
@@ -365,107 +359,6 @@ 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
|
||||
@@ -565,205 +458,3 @@ 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,13 +2230,6 @@ export interface AuthtypesOrgSessionContextDTO {
|
||||
warning?: ErrorsJSONDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPatchableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
@@ -2630,9 +2623,25 @@ 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 {
|
||||
@@ -2740,9 +2749,29 @@ 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 {
|
||||
@@ -2773,6 +2802,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql = 'cloudsql',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -2837,9 +2867,14 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
|
||||
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesCredentialsDTO {
|
||||
@@ -2872,6 +2907,10 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
|
||||
/**
|
||||
@@ -2963,6 +3002,7 @@ export type CloudintegrationtypesIntegrationConfigDTO =
|
||||
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
@@ -3025,6 +3065,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -3154,9 +3195,25 @@ 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 {
|
||||
@@ -3185,17 +3242,6 @@ 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;
|
||||
/**
|
||||
@@ -3338,6 +3384,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -3865,6 +3912,7 @@ export interface DashboardtypesComparisonThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -3929,6 +3977,14 @@ export interface DashboardtypesDashboardPanelRefDTO {
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
filterBy?: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4145,6 +4201,7 @@ export interface DashboardtypesTableThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -6852,6 +6909,11 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6867,10 +6929,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reductionPercent: number;
|
||||
retainedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6929,11 +6991,21 @@ 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
|
||||
@@ -6989,7 +7061,6 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
|
||||
metric = 'metric',
|
||||
ingested_volume = 'ingested_volume',
|
||||
reduced_volume = 'reduced_volume',
|
||||
reduction = 'reduction',
|
||||
last_updated = 'last_updated',
|
||||
}
|
||||
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
|
||||
@@ -7552,6 +7623,126 @@ 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
|
||||
@@ -7633,6 +7824,41 @@ 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',
|
||||
@@ -10026,31 +10252,9 @@ 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
|
||||
@@ -11507,6 +11711,22 @@ 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;
|
||||
/**
|
||||
|
||||
@@ -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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SACreatePermission } from 'lib/authz/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('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -167,6 +167,7 @@ describe('InviteMembers - Submission', () => {
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -243,6 +244,7 @@ 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?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
@@ -56,9 +56,9 @@ export interface InviteMembersProps {
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -207,11 +207,11 @@ export function useInviteMembers(
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.();
|
||||
onSuccess?.(results, touched);
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results);
|
||||
onPartialSuccess?.(results, touched);
|
||||
} else {
|
||||
onAllFailed?.(results);
|
||||
onAllFailed?.(results, touched);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -1,254 +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: 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);
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
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;
|
||||
@@ -1,276 +0,0 @@
|
||||
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,7 +3,6 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,13 @@ export function useRoles(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.id ?? '',
|
||||
value: role[valueField] ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -82,6 +85,7 @@ interface BaseProps {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
valueField?: 'id' | 'name';
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -113,7 +117,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
const options = getRoleOptions(roles, props.valueField || 'id');
|
||||
|
||||
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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildSADeletePermission } from 'lib/authz/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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildAPIKeyUpdatePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/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 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildAPIKeyUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { buildSAUpdatePermission } from 'lib/authz/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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/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 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import PermissionDeniedCallout from 'lib/authz/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 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/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 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/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('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/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('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
setupAuthzDenyAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
APIKeyListPermission,
|
||||
buildSADeletePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/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 'tests/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.editInput {
|
||||
width: 160px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
164
frontend/src/components/TagKeyValueInput/TagKeyValueInput.tsx
Normal file
164
frontend/src/components/TagKeyValueInput/TagKeyValueInput.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
|
||||
interface TagKeyValueInputProps {
|
||||
// Tags as `key:value` strings.
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
// Override the outer container styling per host (e.g. the create modal).
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
// Strict key:value tag editor. A tag is committed only on Enter and only when
|
||||
// it parses to a valid `key:value` pair — bare values are rejected with an
|
||||
// inline error. Existing chips can be edited inline (double-click), and removed.
|
||||
function TagKeyValueInput({
|
||||
tags,
|
||||
onTagsChange,
|
||||
placeholder = 'key:value',
|
||||
className,
|
||||
testId = 'tag-key-value-input',
|
||||
}: TagKeyValueInputProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [editIndex, setEditIndex] = useState(-1);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const removeTag = (tag: string): void => {
|
||||
onTagsChange(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const commit = (): void => {
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const normalized = parseKeyValueTag(raw);
|
||||
if (!normalized) {
|
||||
setError('Tags must be in key:value format (both sides required).');
|
||||
return;
|
||||
}
|
||||
if (tags.includes(normalized)) {
|
||||
setError('This tag already exists.');
|
||||
return;
|
||||
}
|
||||
onTagsChange([...tags, normalized]);
|
||||
setInputValue('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputValue(e.target.value);
|
||||
if (error) {
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (index: number): void => {
|
||||
setEditIndex(index);
|
||||
setEditValue(tags[index]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const cancelEdit = (): void => {
|
||||
setEditIndex(-1);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const commitEdit = (): void => {
|
||||
const normalized = parseKeyValueTag(editValue);
|
||||
// Drop into a no-op (revert) on invalid or duplicate edits rather than
|
||||
// stranding the user in an un-exitable edit box.
|
||||
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
|
||||
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
|
||||
}
|
||||
cancelEdit();
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)}>
|
||||
<div className={styles.field}>
|
||||
{tags.map((tag, index) =>
|
||||
index === editIndex ? (
|
||||
<Input
|
||||
key={tag}
|
||||
className={styles.editInput}
|
||||
value={editValue}
|
||||
autoFocus
|
||||
testId={`${testId}-edit`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setEditValue(e.target.value)
|
||||
}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
testId={testId}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<Typography className={styles.error} data-testid={`${testId}-error`}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagKeyValueInput;
|
||||
31
frontend/src/components/TagKeyValueInput/utils.test.ts
Normal file
31
frontend/src/components/TagKeyValueInput/utils.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
describe('parseKeyValueTag', () => {
|
||||
it('normalizes a valid key:value pair', () => {
|
||||
expect(parseKeyValueTag('env:prod')).toBe('env:prod');
|
||||
});
|
||||
|
||||
it('trims whitespace around key and value', () => {
|
||||
expect(parseKeyValueTag(' env : prod ')).toBe('env:prod');
|
||||
});
|
||||
|
||||
it('keeps colons inside the value', () => {
|
||||
expect(parseKeyValueTag('url:http://x')).toBe('url:http://x');
|
||||
});
|
||||
|
||||
it('rejects a bare value with no colon', () => {
|
||||
expect(parseKeyValueTag('prod')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty key', () => {
|
||||
expect(parseKeyValueTag(':prod')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty value', () => {
|
||||
expect(parseKeyValueTag('env:')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects blank input', () => {
|
||||
expect(parseKeyValueTag(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
17
frontend/src/components/TagKeyValueInput/utils.ts
Normal file
17
frontend/src/components/TagKeyValueInput/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Tags are strictly key:value. Parse a raw input into a normalized `key:value`
|
||||
// string, or null if it isn't a valid pair (both sides non-empty). The first
|
||||
// colon separates key from value, so values may themselves contain colons
|
||||
// (e.g. `url:http://x`).
|
||||
export function parseKeyValueTag(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!key || !value) {
|
||||
return null;
|
||||
}
|
||||
return `${key}:${value}`;
|
||||
}
|
||||
@@ -680,6 +680,13 @@ 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',
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
.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(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--text-cherry-400);
|
||||
background: color-mix(in srgb, var(--accent-cherry) 8%, transparent);
|
||||
color: var(--accent-cherry);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,164 @@
|
||||
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 { Typography } from '@signozhq/ui/typography';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
|
||||
import styles from './ModelCostTabPanel.module.scss';
|
||||
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 { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useModelCostDelete } from './hooks/useModelCostDelete';
|
||||
import styles from './ModelCostTabPanel.module.scss';
|
||||
|
||||
// "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 } = useTableParams(
|
||||
const { page, limit, setPage } = useTableParams(
|
||||
{ page: PAGE_KEY, limit: LIMIT_KEY },
|
||||
{ page: 1, limit: PAGE_SIZE },
|
||||
);
|
||||
|
||||
// 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 [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];
|
||||
|
||||
const listParams: ListLLMPricingRulesParams = {
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
...(debouncedSearch ? { q: debouncedSearch } : {}),
|
||||
...(isOverride !== undefined ? { isOverride } : {}),
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
|
||||
query: {
|
||||
enabled: search === debouncedSearch,
|
||||
},
|
||||
});
|
||||
|
||||
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
|
||||
() => data?.data?.items || [],
|
||||
[data],
|
||||
const { user } = useAppContext();
|
||||
const [canManagePricing] = useComponentPermission(
|
||||
['manage_llm_pricing'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
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={null}
|
||||
canManage={false}
|
||||
onEdit={(): void => undefined}
|
||||
onDelete={(): void => undefined}
|
||||
selectedRuleId={drawer.selectedRuleId}
|
||||
canManage={canManagePricing}
|
||||
onEdit={drawer.openForEdit}
|
||||
onDelete={deletion.requestDelete}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
@@ -54,6 +166,29 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DeleteConfirmDialog';
|
||||
@@ -0,0 +1,58 @@
|
||||
.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%;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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;
|
||||
@@ -0,0 +1,69 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ExtraPricingBuckets';
|
||||
@@ -0,0 +1,49 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './PatternEditor';
|
||||
@@ -0,0 +1,31 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './PricingFields';
|
||||
@@ -0,0 +1,115 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SourceSelector';
|
||||
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './ModelCostDrawer';
|
||||
export { useModelCostDrawer } from './hooks/useModelCostDrawer';
|
||||
@@ -0,0 +1,59 @@
|
||||
/* 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(--text-vanilla-400);
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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,6 +1,68 @@
|
||||
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,4 +1,39 @@
|
||||
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,8 +1,19 @@
|
||||
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 { ExtraBucket } from './types';
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
ExtraBucket,
|
||||
PricingRule,
|
||||
} from './types';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -13,6 +24,19 @@ 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 => {
|
||||
@@ -23,38 +47,117 @@ export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): ExtraBucket[] => {
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
if (hasCacheValue(cache.read)) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
if (hasCacheValue(cache.write)) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
|
||||
export const getRelativeLastSeen = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
export const getRelativeLastSeen = (rule: PricingRule): 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: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => {
|
||||
export const getCanonicalId = (rule: PricingRule): 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,9 +1,14 @@
|
||||
@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 'components/InviteMembersModal/InviteMembersModal';
|
||||
import InviteMembersModal from 'container/MembersSettings/components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
@@ -110,7 +110,9 @@ describe('MembersSettings (integration)', () => {
|
||||
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await screen.findByText('Member Details');
|
||||
await expect(
|
||||
screen.findByText('Member Details'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
@@ -127,7 +129,7 @@ describe('MembersSettings (integration)', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -137,7 +139,7 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
@@ -0,0 +1,210 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Gauge } from '@signozhq/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
@@ -8,16 +9,26 @@ interface VolumeControlBadgeProps {
|
||||
}
|
||||
|
||||
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
|
||||
return (
|
||||
const badge = (
|
||||
<Badge
|
||||
data-testid="vc-badge-active"
|
||||
variant="outline"
|
||||
color={!rule.active ? 'success' : 'warning'}
|
||||
color={rule.active ? 'success' : 'warning'}
|
||||
>
|
||||
<Gauge size={12} />
|
||||
{!rule.active ? 'Active' : 'Pending'}
|
||||
{rule.active ? 'Active' : 'Pending'}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (rule.active) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Takes about 5 minutes to take effect">
|
||||
<span>{badge}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeControlBadge;
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
padding: 12px 16px 0 16px;
|
||||
}
|
||||
|
||||
.chartHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -15,3 +21,11 @@
|
||||
.chartBody {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.chartStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spin } from 'antd';
|
||||
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
@@ -25,7 +26,9 @@ interface VolumeControlChartProps {
|
||||
}
|
||||
|
||||
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
|
||||
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
|
||||
const { data, isLoading, isError } = useGetMetricReductionRuleTimeseries({
|
||||
query: { enabled },
|
||||
});
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
@@ -65,11 +68,34 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.chart} data-testid="volume-control-chart">
|
||||
<Typography.Text className={styles.chartTitle} size={'small'}>
|
||||
Series volume over time · ingested vs retained
|
||||
</Typography.Text>
|
||||
<div className={styles.chartHeader}>
|
||||
<Typography.Text className={styles.chartTitle} size={'small'}>
|
||||
Sample volume · ingested vs retained
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
Last 6 hours
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.chartBody} ref={graphRef}>
|
||||
{dimensions.width > 0 && (
|
||||
{isLoading && (
|
||||
<div
|
||||
className={styles.chartStatus}
|
||||
data-testid="volume-control-chart-loading"
|
||||
>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && isError && (
|
||||
<div
|
||||
className={styles.chartStatus}
|
||||
data-testid="volume-control-chart-error"
|
||||
>
|
||||
<Typography.Text size="small" color="danger">
|
||||
Failed to load chart
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !isError && dimensions.width > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
|
||||
@@ -17,11 +17,27 @@
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.meterLabelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meterLabel {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.meterInfo {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.meterValue {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spin } from 'antd';
|
||||
import { Spin, Tooltip } from 'antd';
|
||||
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { formatCompact } from '../../../configUtils';
|
||||
import { RuleMode } from '../../../types';
|
||||
@@ -27,6 +29,7 @@ function ImpactPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const full = preview?.ingestedSeries ?? 0;
|
||||
const current = preview?.currentRetainedSeries ?? 0;
|
||||
const proposed = preview?.retainedSeries ?? 0;
|
||||
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
|
||||
@@ -40,31 +43,59 @@ function ImpactPanel({
|
||||
{!isLoading && preview && (
|
||||
<div className={styles.meterGrid}>
|
||||
<div className={styles.meter}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Current series
|
||||
<div className={styles.meterLabelRow}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Full series
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title="Total number of series for this metric before any reduction."
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Info size={12} className={styles.meterInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text size="2xl" className={styles.meterValue}>
|
||||
{formatCompact(full)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<div className={styles.meterLabelRow}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Current retained
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title="Series kept today under the metric's existing rule, or all of them if it has no rule yet."
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Info size={12} className={styles.meterInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text size="2xl" className={styles.meterValue}>
|
||||
{formatCompact(current)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Proposed series
|
||||
</Typography.Text>
|
||||
<div className={styles.meterLabelRow}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Potential retained
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title="Series that would be kept if you save this rule, with the reduction vs what's retained today."
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Info size={12} className={styles.meterInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text size="2xl" className={styles.meterValue}>
|
||||
{formatCompact(proposed)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Reduction
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="2xl"
|
||||
color={deltaPct >= 0 ? 'success' : undefined}
|
||||
className={styles.meterValue}
|
||||
>
|
||||
{reductionLabel}
|
||||
{deltaPct !== 0 && (
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={deltaPct >= 0 ? 'success' : undefined}
|
||||
>
|
||||
{reductionLabel}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,12 +18,12 @@ const MODE_OPTIONS: ModeOption[] = [
|
||||
},
|
||||
{
|
||||
mode: 'include',
|
||||
title: 'Include attributes',
|
||||
title: 'Include',
|
||||
description: 'Allowlist: only the selected attributes stay queryable.',
|
||||
},
|
||||
{
|
||||
mode: 'exclude',
|
||||
title: 'Exclude attributes',
|
||||
title: 'Exclude',
|
||||
description: 'Blocklist: the selected attributes are aggregated away.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,12 +53,12 @@ function RelatedAssetsWarning({
|
||||
<div className={styles.warning} data-testid="volume-control-warning">
|
||||
<Info size={14} />
|
||||
<div className={styles.warningBody}>
|
||||
<Typography.Text as="div" size="small" weight="semibold" color="warning">
|
||||
<Typography.Text as="div" size="base" weight="semibold" color="warning">
|
||||
This rule affects {impacted.length} related asset
|
||||
{impacted.length > 1 ? 's' : ''}.
|
||||
</Typography.Text>
|
||||
{impactedLabels.length > 0 && (
|
||||
<Typography.Text as="div" size="sm" color="muted">
|
||||
<Typography.Text as="div" size="base" color="muted">
|
||||
{impactedLabels.join(', ')} will no longer be queryable; affected panels
|
||||
fall back to aggregated data once the rule applies.
|
||||
</Typography.Text>
|
||||
@@ -73,7 +73,7 @@ function RelatedAssetsWarning({
|
||||
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
|
||||
{href ? (
|
||||
<Typography.Link
|
||||
size="sm"
|
||||
size="base"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -81,7 +81,7 @@ function RelatedAssetsWarning({
|
||||
{label}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Typography.Text size="sm" color="muted">
|
||||
<Typography.Text size="base" color="muted">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,10 @@ function VolumeControlConfigDrawer({
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text size="small" color="muted">
|
||||
Changes take effect about 5 minutes after saving.
|
||||
</Typography.Text>
|
||||
<div className={styles.footerSpacer} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
@@ -50,7 +54,6 @@ function VolumeControlConfigDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={styles.footerSpacer} />
|
||||
{hasExistingRule && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,8 +8,8 @@ function PendingActivationBanner(): JSX.Element {
|
||||
<div className={styles.banner} data-testid="volume-control-pending-banner">
|
||||
<Info size={13} />
|
||||
<Typography.Text size="sm" color="muted">
|
||||
This metric's configuration was recently updated. Volume changes will
|
||||
take effect within a few minutes.
|
||||
This metric's configuration was recently updated. Volume changes take
|
||||
effect within about 5 minutes.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ function VolumeControlSection({
|
||||
useVolumeControlFeatureGate();
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useListMetricReductionRules(
|
||||
const { data, isLoading, isError } = useListMetricReductionRules(
|
||||
{ metricName },
|
||||
{
|
||||
query: {
|
||||
@@ -37,7 +37,7 @@ function VolumeControlSection({
|
||||
}
|
||||
|
||||
const rule = data?.data.rules?.[0];
|
||||
const hasRule = !!rule && !error;
|
||||
const hasRule = !!rule && !isError;
|
||||
|
||||
const openConfig = (): void => setIsConfigOpen(true);
|
||||
const closeConfig = (): void => setIsConfigOpen(false);
|
||||
@@ -53,6 +53,16 @@ function VolumeControlSection({
|
||||
|
||||
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
|
||||
|
||||
{!isLoading && isError && (
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color="danger"
|
||||
data-testid="volume-control-section-error"
|
||||
>
|
||||
Failed to load volume control. Please try again.
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{!isLoading && hasRule && rule && !rule.active && (
|
||||
<PendingActivationBanner />
|
||||
)}
|
||||
@@ -65,7 +75,7 @@ function VolumeControlSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasRule && (
|
||||
{!isLoading && !isError && !hasRule && (
|
||||
<NoRuleEmptyState canManage={canManageVolumeControl} onSetup={openConfig} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.statsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -21,11 +27,24 @@
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
.statCardLabelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statCardLabel {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.statCardInfo {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.statCardValue {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCompact, formatUsd } from '../../../configUtils';
|
||||
@@ -8,12 +10,17 @@ interface VolumeControlStatsProps {
|
||||
activeRules: number;
|
||||
ingestedSeries: number;
|
||||
retainedSeries: number;
|
||||
ingestedSamples: number;
|
||||
retainedSamples: number;
|
||||
estimatedMonthlySavingsUsd: number;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip: string;
|
||||
delta?: string;
|
||||
unit?: string;
|
||||
highlighted?: boolean;
|
||||
@@ -24,20 +31,53 @@ function VolumeControlStats({
|
||||
activeRules,
|
||||
ingestedSeries,
|
||||
retainedSeries,
|
||||
ingestedSamples,
|
||||
retainedSamples,
|
||||
estimatedMonthlySavingsUsd,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: VolumeControlStatsProps): JSX.Element {
|
||||
const overallReduction =
|
||||
ingestedSeries > 0
|
||||
? Math.round((1 - retainedSeries / ingestedSeries) * 100)
|
||||
: 0;
|
||||
|
||||
const sampleReduction =
|
||||
ingestedSamples > 0
|
||||
? Math.round((1 - retainedSamples / ingestedSamples) * 100)
|
||||
: 0;
|
||||
|
||||
const items: StatItem[] = [
|
||||
{ label: 'Active rules', value: String(activeRules) },
|
||||
{ label: 'Ingested series', value: formatCompact(ingestedSeries) },
|
||||
{
|
||||
label: 'Configured rules',
|
||||
value: String(activeRules),
|
||||
tooltip: 'Volume-control rules currently configured for this workspace.',
|
||||
},
|
||||
{
|
||||
label: 'Ingested series',
|
||||
value: formatCompact(ingestedSeries),
|
||||
tooltip:
|
||||
'Distinct time series across all metrics in the last 1 hour, before any reduction.',
|
||||
},
|
||||
{
|
||||
label: 'Retained series',
|
||||
value: formatCompact(retainedSeries),
|
||||
delta: overallReduction > 0 ? `−${overallReduction}%` : undefined,
|
||||
tooltip:
|
||||
'Distinct time series kept across all metrics in the last 1 hour; everything except what the rules reduce away. Lower than ingested means more reduction.',
|
||||
},
|
||||
{
|
||||
label: 'Ingested samples',
|
||||
value: formatCompact(ingestedSamples),
|
||||
tooltip:
|
||||
'Sample data points across all metrics in the last 1 hour, before any reduction.',
|
||||
},
|
||||
{
|
||||
label: 'Retained samples',
|
||||
value: formatCompact(retainedSamples),
|
||||
delta: sampleReduction > 0 ? `−${sampleReduction}%` : undefined,
|
||||
tooltip:
|
||||
'Sample data points kept across all metrics in the last 1 hour; everything except what the rules reduce. Samples reduce more than series because series do not all carry the same sample volume.',
|
||||
},
|
||||
{
|
||||
label: 'Est. monthly savings',
|
||||
@@ -45,42 +85,73 @@ function VolumeControlStats({
|
||||
unit: '/mo',
|
||||
highlighted: true,
|
||||
valueGood: true,
|
||||
tooltip:
|
||||
'Rough monthly estimate: the samples the rules reduced in the last 1 hour, scaled to a month at 1-month standard retention. It is extrapolated from a single rolling hour.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.stats} data-testid="volume-control-stats">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cx(styles.statCard, {
|
||||
[styles.statCardHighlighted]: item.highlighted,
|
||||
})}
|
||||
>
|
||||
<Typography.Text size="sm" color="muted" className={styles.statCardLabel}>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="div"
|
||||
size="large"
|
||||
weight="semibold"
|
||||
color={item.valueGood ? 'success' : undefined}
|
||||
className={styles.statCardValue}
|
||||
<div className={styles.statsSection}>
|
||||
<Typography.Text size="small" color="muted">
|
||||
Last 1 hour
|
||||
</Typography.Text>
|
||||
<div className={styles.stats} data-testid="volume-control-stats">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cx(styles.statCard, {
|
||||
[styles.statCardHighlighted]: item.highlighted,
|
||||
})}
|
||||
>
|
||||
{item.value}
|
||||
{item.delta && (
|
||||
<Typography.Text size="small" weight="semibold" color="success">
|
||||
{item.delta}
|
||||
<div className={styles.statCardLabelRow}>
|
||||
<Typography.Text
|
||||
size="sm"
|
||||
color="muted"
|
||||
className={styles.statCardLabel}
|
||||
>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
<Tooltip title={item.tooltip}>
|
||||
<Info size={12} className={styles.statCardInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<Skeleton.Button active size="small" className={styles.statCardValue} />
|
||||
)}
|
||||
{!isLoading && isError && (
|
||||
<Typography.Text
|
||||
as="div"
|
||||
size="small"
|
||||
color="danger"
|
||||
className={styles.statCardValue}
|
||||
>
|
||||
Failed to load
|
||||
</Typography.Text>
|
||||
)}
|
||||
{item.unit && (
|
||||
<Typography.Text size="small" weight="medium" color="muted">
|
||||
{item.unit}
|
||||
{!isLoading && !isError && (
|
||||
<Typography.Text
|
||||
as="div"
|
||||
size="large"
|
||||
weight="semibold"
|
||||
color={item.valueGood ? 'success' : undefined}
|
||||
className={styles.statCardValue}
|
||||
>
|
||||
{item.value}
|
||||
{item.delta && (
|
||||
<Typography.Text size="small" weight="semibold" color="success">
|
||||
{item.delta}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{item.unit && (
|
||||
<Typography.Text size="small" weight="medium" color="muted">
|
||||
{item.unit}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.reductionCell {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
.volumeCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -36,7 +36,7 @@ type VolumeControlTableParams = Required<
|
||||
>;
|
||||
|
||||
const DEFAULT_PARAMS: VolumeControlTableParams = {
|
||||
orderBy: OrderBy.reduction,
|
||||
orderBy: OrderBy.ingested_volume,
|
||||
order: SortOrder.desc,
|
||||
search: '',
|
||||
offset: 0,
|
||||
@@ -60,11 +60,20 @@ function VolumeControlTab(): JSX.Element {
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const { data, isLoading } = useListMetricReductionRules(params, {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError: isListError,
|
||||
} = useListMetricReductionRules(params, {
|
||||
query: { enabled: isVolumeControlEnabled },
|
||||
});
|
||||
|
||||
const { data: statsData } = useGetMetricReductionRuleStats({
|
||||
const {
|
||||
data: statsData,
|
||||
isLoading: isStatsLoading,
|
||||
isFetching: isStatsFetching,
|
||||
isError: isStatsError,
|
||||
} = useGetMetricReductionRuleStats({
|
||||
query: { enabled: isVolumeControlEnabled },
|
||||
});
|
||||
const stats = statsData?.data;
|
||||
@@ -111,7 +120,7 @@ function VolumeControlTab(): JSX.Element {
|
||||
{
|
||||
title: 'MODE',
|
||||
key: 'mode',
|
||||
width: 160,
|
||||
width: 110,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
@@ -138,7 +147,14 @@ function VolumeControlTab(): JSX.Element {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'INGESTED',
|
||||
title: (
|
||||
<>
|
||||
INGESTED{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
(1h)
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
key: OrderBy.ingested_volume,
|
||||
width: 130,
|
||||
sorter: true,
|
||||
@@ -147,13 +163,28 @@ function VolumeControlTab(): JSX.Element {
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<Typography.Text size="small" color="muted">
|
||||
{formatCompact(rule.ingestedSeries)}
|
||||
</Typography.Text>
|
||||
<div className={styles.volumeCell}>
|
||||
<Typography.Text size="small">
|
||||
{formatCompact(rule.ingestedSeries)}{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
series
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
{formatCompact(rule.ingestedSamples)} samples
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'RETAINED',
|
||||
title: (
|
||||
<>
|
||||
RETAINED{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
(1h)
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
key: OrderBy.reduced_volume,
|
||||
width: 130,
|
||||
sorter: true,
|
||||
@@ -162,22 +193,35 @@ function VolumeControlTab(): JSX.Element {
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<Typography.Text size="small">
|
||||
{formatCompact(rule.retainedSeries)}
|
||||
</Typography.Text>
|
||||
<div className={styles.volumeCell}>
|
||||
<Typography.Text size="small">
|
||||
{formatCompact(rule.retainedSeries)}{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
series
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
{formatCompact(rule.retainedSamples)} samples
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'CHANGE',
|
||||
key: OrderBy.reduction,
|
||||
width: 110,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.reduction),
|
||||
width: 140,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => {
|
||||
if (rule.reductionPercent <= 0) {
|
||||
const seriesReduction =
|
||||
rule.ingestedSeries > 0
|
||||
? (1 - rule.retainedSeries / rule.ingestedSeries) * 100
|
||||
: 0;
|
||||
const samplesReduction =
|
||||
rule.ingestedSamples > 0
|
||||
? (1 - rule.retainedSamples / rule.ingestedSamples) * 100
|
||||
: 0;
|
||||
if (seriesReduction <= 0 && samplesReduction <= 0) {
|
||||
return (
|
||||
<Typography.Text size="small" color="muted">
|
||||
—
|
||||
@@ -185,14 +229,18 @@ function VolumeControlTab(): JSX.Element {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="semibold"
|
||||
color="success"
|
||||
className={styles.reductionCell}
|
||||
>
|
||||
−{Math.round(rule.reductionPercent)}%
|
||||
</Typography.Text>
|
||||
<div className={styles.volumeCell}>
|
||||
<Typography.Text size="small" weight="semibold" color="success">
|
||||
{seriesReduction > 0 ? `−${Math.round(seriesReduction)}%` : '0%'}{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
series
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
{samplesReduction > 0 ? `−${Math.round(samplesReduction)}%` : '0%'}{' '}
|
||||
samples
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -273,7 +321,11 @@ function VolumeControlTab(): JSX.Element {
|
||||
activeRules={total}
|
||||
ingestedSeries={stats?.ingestedSeries ?? 0}
|
||||
retainedSeries={stats?.retainedSeries ?? 0}
|
||||
ingestedSamples={stats?.ingestedSamples ?? 0}
|
||||
retainedSamples={stats?.retainedSamples ?? 0}
|
||||
estimatedMonthlySavingsUsd={stats?.estimatedMonthlySavingsUsd ?? 0}
|
||||
isLoading={isStatsLoading || isStatsFetching}
|
||||
isError={isStatsError}
|
||||
/>
|
||||
|
||||
<VolumeControlChart enabled={isVolumeControlEnabled} />
|
||||
@@ -293,7 +345,13 @@ function VolumeControlTab(): JSX.Element {
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
emptyText: isListError ? (
|
||||
<div className={styles.empty} data-testid="volume-control-tab-error">
|
||||
<Typography.Text color="danger">
|
||||
Failed to load volume control rules. Please try again.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty} data-testid="volume-control-tab-empty">
|
||||
<Typography.Text color="muted">
|
||||
No volume control rules yet. Open a metric and set one up to start
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface UseVolumeControlConfigResult {
|
||||
const PREVIEW_DEBOUNCE_MS = 400;
|
||||
const SAVE_ERROR_MESSAGE = 'Failed to save volume control rule';
|
||||
const REMOVE_ERROR_MESSAGE = 'Failed to remove volume control rule';
|
||||
const PREVIEW_ERROR_MESSAGE = 'Failed to preview volume control rule';
|
||||
|
||||
export function useVolumeControlConfig({
|
||||
metricName,
|
||||
@@ -95,11 +96,25 @@ export function useVolumeControlConfig({
|
||||
const timer = setTimeout(() => {
|
||||
previewMutate(
|
||||
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
|
||||
{ onSettled: () => setIsPreviewPending(false) },
|
||||
{
|
||||
onError: (error) =>
|
||||
notifications.error({
|
||||
message: error.response?.data?.error?.message ?? PREVIEW_ERROR_MESSAGE,
|
||||
}),
|
||||
onSettled: () => setIsPreviewPending(false),
|
||||
},
|
||||
);
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return (): void => clearTimeout(timer);
|
||||
}, [open, mode, labels, metricName, previewMutate, previewReset]);
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
labels,
|
||||
metricName,
|
||||
previewMutate,
|
||||
previewReset,
|
||||
notifications,
|
||||
]);
|
||||
|
||||
const createMutation = useCreateMetricReductionRule();
|
||||
const updateMutation = useUpdateMetricReductionRuleByID();
|
||||
@@ -142,7 +157,10 @@ export function useVolumeControlConfig({
|
||||
}
|
||||
|
||||
const onSuccess = (): void => {
|
||||
notifications.success({ message: 'Volume control rule saved' });
|
||||
notifications.success({
|
||||
message:
|
||||
'Volume control rule saved. It takes about 5 minutes to take effect.',
|
||||
});
|
||||
invalidate();
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export function isKeepMode(
|
||||
export function getMatchTypeLabel(
|
||||
matchType: MetricreductionruletypesMatchTypeDTO,
|
||||
): string {
|
||||
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
|
||||
return isKeepMode(matchType) ? 'Include' : 'Exclude';
|
||||
}
|
||||
|
||||
export function getLabelVerb(
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowRight, LoaderCircle } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
import { InviteMemberRow, InviteResult } from 'components/InviteMembers/types';
|
||||
import { useRoles } from 'components/RolesSelect/RolesSelect';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
|
||||
|
||||
@@ -36,101 +22,41 @@ interface TeamMember {
|
||||
|
||||
interface InviteTeamMembersProps {
|
||||
isLoading: boolean;
|
||||
teamMembers: TeamMember[] | null;
|
||||
setTeamMembers: (teamMembers: TeamMember[]) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
function InviteTeamMembers({
|
||||
isLoading,
|
||||
teamMembers,
|
||||
setTeamMembers,
|
||||
onNext,
|
||||
}: InviteTeamMembersProps): JSX.Element {
|
||||
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
|
||||
TeamMember[] | null
|
||||
>(teamMembers);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
const [inviteError, setInviteError] = useState<APIError | null>(null);
|
||||
const { notifications } = useNotifications();
|
||||
const { roles } = useRoles();
|
||||
|
||||
const defaultTeamMember: TeamMember = {
|
||||
email: '',
|
||||
role: '',
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: '',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (teamMembers === null) {
|
||||
const initialTeamMembers = Array.from({ length: 3 }, () => ({
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
}));
|
||||
|
||||
setTeamMembersToInvite(initialTeamMembers);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamMembers]);
|
||||
|
||||
const handleAddTeamMember = (): void => {
|
||||
const newTeamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
|
||||
};
|
||||
|
||||
const handleRemoveTeamMember = (id: string): void => {
|
||||
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
const isMemberTouched = (member: TeamMember): boolean =>
|
||||
member.email.trim() !== '' ||
|
||||
Boolean(member.role && member.role.trim() !== '');
|
||||
|
||||
const validateAllUsers = (): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedMembers = teamMembersToInvite?.filter(isMemberTouched) ?? [];
|
||||
|
||||
touchedMembers?.forEach((member) => {
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
|
||||
const roleValid = Boolean(member.role && member.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !member.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (member.id) {
|
||||
updatedEmailValidity[member.id] = emailValid;
|
||||
const roleIdToName = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
roles.forEach((role) => {
|
||||
if (role.id && role.name) {
|
||||
map[role.id] = role.name;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [roles]);
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
const toTeamMembers = (rows: InviteMemberRow[]): TeamMember[] =>
|
||||
rows.map((row) => ({
|
||||
email: row.email,
|
||||
role: roleIdToName[row.roleId] ?? row.roleId,
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: row.id,
|
||||
}));
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleInviteUsersSuccess = (): void => {
|
||||
const handleSuccess = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Success', {
|
||||
teamMembers: teamMembersToInvite,
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Invites sent successfully!',
|
||||
@@ -140,125 +66,34 @@ function InviteTeamMembers({
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
|
||||
inviteUsers,
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
handleInviteUsersSuccess();
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Failed', {
|
||||
teamMembers: teamMembersToInvite,
|
||||
});
|
||||
setInviteError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (validateAllUsers()) {
|
||||
setTeamMembers(teamMembersToInvite?.filter(isMemberTouched) ?? []);
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
setInviteError(null);
|
||||
sendInvites({
|
||||
invites: teamMembersToInvite?.filter(isMemberTouched) ?? [],
|
||||
});
|
||||
}
|
||||
const handlePartialSuccess = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Partial Success', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
notifications.warning({
|
||||
message: 'Some invites failed. Check the errors above.',
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedValidateEmail = useCallback(
|
||||
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
|
||||
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
|
||||
|
||||
// Clear hasInvalidEmails only when ALL emails are valid
|
||||
if (hasInvalidEmails) {
|
||||
const allEmailsValid = updatedMembers.every(
|
||||
(m) => m.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.email),
|
||||
);
|
||||
if (allEmailsValid) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
}
|
||||
}, 500),
|
||||
[hasInvalidEmails],
|
||||
);
|
||||
|
||||
const handleEmailChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>, member: TeamMember): void => {
|
||||
const { value } = e.target;
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate && member.id) {
|
||||
memberToUpdate.email = value;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
debouncedValidateEmail(value, member.id, updatedMembers);
|
||||
// Clear API error when user starts typing
|
||||
if (inviteError) {
|
||||
setInviteError(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[debouncedValidateEmail, inviteError, teamMembersToInvite],
|
||||
);
|
||||
|
||||
const createEmailChangeHandler = useCallback(
|
||||
(member: TeamMember) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
handleEmailChange(e, member);
|
||||
},
|
||||
[handleEmailChange],
|
||||
);
|
||||
|
||||
const handleRoleChange = (role: string, member: TeamMember): void => {
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate && member.id) {
|
||||
memberToUpdate.role = role;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
|
||||
// Clear errors when user selects a role
|
||||
if (hasInvalidRoles) {
|
||||
// Check if all roles are now valid
|
||||
const allRolesValid = updatedMembers.every(
|
||||
(m) => m.role && m.role.trim() !== '',
|
||||
);
|
||||
if (allRolesValid) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
if (inviteError) {
|
||||
setInviteError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 handleAllFailed = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Failed', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDoLater = (): void => {
|
||||
logEvent('Org Onboarding: Clicked Do Later', {
|
||||
currentPageID: 4,
|
||||
});
|
||||
|
||||
onNext();
|
||||
};
|
||||
|
||||
const hasInvites =
|
||||
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
|
||||
const isButtonDisabled = isSendingInvites || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
|
||||
|
||||
return (
|
||||
<div className="questions-container">
|
||||
<OnboardingQuestionHeader
|
||||
@@ -273,126 +108,52 @@ function InviteTeamMembers({
|
||||
Invite your team to the SigNoz workspace
|
||||
</div>
|
||||
|
||||
<div className="invite-team-members-table">
|
||||
<div className="invite-team-members-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>
|
||||
<InviteMembers
|
||||
onSuccess={handleSuccess}
|
||||
onPartialSuccess={handlePartialSuccess}
|
||||
onAllFailed={handleAllFailed}
|
||||
showHeader
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => {
|
||||
const isButtonDisabled = isSubmitting || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !canSubmit;
|
||||
|
||||
<div className="invite-team-members-container">
|
||||
{teamMembersToInvite?.map((member) => (
|
||||
<div className="team-member-row" key={member.id}>
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
placeholder="e.g. john@signoz.io"
|
||||
value={member.email}
|
||||
type="email"
|
||||
id={`email-input-${member.id}`}
|
||||
name={`email-input-${member.id}`}
|
||||
required
|
||||
autoComplete="off"
|
||||
className="team-member-email-input"
|
||||
onChange={createEmailChangeHandler(member)}
|
||||
/>
|
||||
{member.id &&
|
||||
emailValidity[member.id] === false &&
|
||||
member.email.trim() !== '' && (
|
||||
<Typography.Text className="email-error-message">
|
||||
Invalid email address
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={member.role || undefined}
|
||||
onChange={(value): void => handleRoleChange(value, member)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
>
|
||||
<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">
|
||||
{teamMembersToInvite && teamMembersToInvite.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => handleRemoveTeamMember(member.id)}
|
||||
aria-label="Remove team member"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={submit}
|
||||
disabled={isInviteButtonDisabled}
|
||||
data-testid="send-invites-button"
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="onboarding-do-later-button"
|
||||
onClick={handleDoLater}
|
||||
disabled={isButtonDisabled}
|
||||
data-testid="do-later-button"
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="invite-team-members-add-another-member-container">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className="add-another-member-button"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={handleAddTeamMember}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="invite-team-members-error-callout"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{inviteError && !hasInvalidEmails && !hasInvalidRoles && (
|
||||
<AuthError error={inviteError} />
|
||||
)}
|
||||
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
disabled={isInviteButtonDisabled}
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="onboarding-do-later-button"
|
||||
onClick={handleDoLater}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,97 +1,86 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
InviteMemberRow,
|
||||
InviteMembersProps,
|
||||
InviteResult,
|
||||
} from 'components/InviteMembers/types';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
|
||||
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
const mockNotificationError = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
const mockNotificationSuccess = jest.fn();
|
||||
const mockNotificationWarning = jest.fn();
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotificationSuccess,
|
||||
error: mockNotificationError,
|
||||
warning: mockNotificationWarning,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk';
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
interface TeamMember {
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
frontendBaseUrl: string;
|
||||
id: string;
|
||||
}
|
||||
jest.mock('components/RolesSelect/RolesSelect', () => ({
|
||||
useRoles: (): any => ({
|
||||
roles: [
|
||||
{ id: 'role-viewer-id', name: 'VIEWER' },
|
||||
{ id: 'role-editor-id', name: 'EDITOR' },
|
||||
{ id: 'role-admin-id', name: 'ADMIN' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
interface InviteRequestBody {
|
||||
invites: { email: string; role: string }[];
|
||||
}
|
||||
jest.mock('utils/basePath', () => ({
|
||||
...jest.requireActual('utils/basePath'),
|
||||
getBaseUrl: (): string => 'http://localhost:3301',
|
||||
}));
|
||||
|
||||
interface RenderProps {
|
||||
isLoading?: boolean;
|
||||
teamMembers?: TeamMember[] | null;
|
||||
}
|
||||
let mockInviteMembersProps: InviteMembersProps | null = null;
|
||||
|
||||
const mockOnNext = jest.fn() as jest.MockedFunction<() => void>;
|
||||
const mockSetTeamMembers = jest.fn() as jest.MockedFunction<
|
||||
(members: TeamMember[]) => void
|
||||
>;
|
||||
jest.mock('components/InviteMembers/InviteMembers', () => {
|
||||
return function MockInviteMembers(props: InviteMembersProps): JSX.Element {
|
||||
mockInviteMembersProps = props;
|
||||
return (
|
||||
<div data-testid="mock-invite-members">
|
||||
{props.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
touchedCount: 0,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const mockOnNext = jest.fn();
|
||||
|
||||
function renderComponent({
|
||||
isLoading = false,
|
||||
teamMembers = null,
|
||||
}: RenderProps = {}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<InviteTeamMembers
|
||||
isLoading={isLoading}
|
||||
teamMembers={teamMembers}
|
||||
setTeamMembers={mockSetTeamMembers}
|
||||
onNext={mockOnNext}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function selectRole(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
selectIndex: number,
|
||||
optionLabel: string,
|
||||
): Promise<void> {
|
||||
const placeholders = screen.getAllByText(/select roles/i);
|
||||
await user.click(placeholders[selectIndex]);
|
||||
const optionContent = await screen.findByText(optionLabel);
|
||||
fireEvent.click(optionContent);
|
||||
}: { isLoading?: boolean } = {}): ReturnType<typeof render> {
|
||||
return render(<InviteTeamMembers isLoading={isLoading} onNext={mockOnNext} />);
|
||||
}
|
||||
|
||||
describe('InviteTeamMembers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
jest.useFakeTimers();
|
||||
mockInviteMembersProps = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial rendering', () => {
|
||||
it('renders the page header, column labels, default rows, and action buttons', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders header and InviteMembers component', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
@@ -100,11 +89,20 @@ describe('InviteTeamMembers', () => {
|
||||
expect(
|
||||
screen.getByText(/signoz is a lot more useful with collaborators/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(3);
|
||||
expect(screen.getByText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes showHeader=true to InviteMembers', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(mockInviteMembersProps?.showHeader).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer buttons', () => {
|
||||
it('renders Send Invites and Do Later buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /send invites/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -113,7 +111,7 @@ describe('InviteTeamMembers', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables both action buttons while isLoading is true', () => {
|
||||
it('disables buttons when isLoading=true', () => {
|
||||
renderComponent({ isLoading: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: /send invites/i })).toBeDisabled();
|
||||
@@ -121,355 +119,181 @@ describe('InviteTeamMembers', () => {
|
||||
screen.getByRole('button', { name: /i'll do this later/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Row management', () => {
|
||||
it('adds a new empty row when "Add another" is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
it('disables Send Invites when canSubmit=false from InviteMembers', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(3);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('removes the correct row when its trash icon is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(emailInputs[0], 'first@example.com');
|
||||
await screen.findByDisplayValue('first@example.com');
|
||||
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
const { getByTestId } = render(
|
||||
mockInviteMembersProps?.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: false,
|
||||
isSubmitting: false,
|
||||
touchedCount: 0,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByDisplayValue('first@example.com'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
expect(getByTestId('send-invites-button')).toBeDisabled();
|
||||
expect(getByTestId('do-later-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides remove buttons when only one row remains', async () => {
|
||||
renderComponent();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
it('disables buttons when isSubmitting=true from InviteMembers', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
let removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
while (removeButtons.length > 0) {
|
||||
await user.click(removeButtons[0]);
|
||||
removeButtons = screen.queryAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
}
|
||||
const { getByTestId } = render(
|
||||
mockInviteMembersProps?.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: true,
|
||||
touchedCount: 0,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /remove team member/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(getByTestId('send-invites-button')).toBeDisabled();
|
||||
expect(getByTestId('do-later-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline email validation', () => {
|
||||
it('shows an inline error after typing an invalid email and clears it when a valid email is entered', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
});
|
||||
describe('handleSuccess callback', () => {
|
||||
it('logs event with teamMembers in correct shape, shows success notification, and calls onNext after delay', () => {
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: true },
|
||||
{ email: 'user2@test.com', success: true },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-editor-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onSuccess?.(mockResults, mockRows);
|
||||
|
||||
await user.type(firstInput, 'not-an-email');
|
||||
jest.advanceTimersByTime(600);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'good@example.com');
|
||||
jest.advanceTimersByTime(600);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email address/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show an inline error when the field is cleared back to empty', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'a');
|
||||
await user.clear(firstInput);
|
||||
jest.advanceTimersByTime(600);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email address/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation callout on Complete', () => {
|
||||
it('shows the correct callout message for each combination of email/role validity', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderComponent();
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, 'bad-email');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please enter valid emails and select roles for team members/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectRole(user, 0, 'Viewer');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please enter valid emails for team members/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'valid@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
const allInputs = screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i);
|
||||
await user.type(allInputs[1], 'norole@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please select roles for team members/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('treats whitespace as untouched, clears the callout on fix-and-resubmit, and clears role error on role select', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderComponent();
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, ' ');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/please select roles/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'bad-email');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please enter valid emails and select roles for team members/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'good@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockOnNext).toHaveBeenCalledTimes(1), {
|
||||
timeout: 1200,
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('disables the Send Invites button when all rows are untouched (empty)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const sendInvitesBtn = screen.getByRole('button', { name: /send invites/i });
|
||||
expect(sendInvitesBtn).toBeDisabled();
|
||||
|
||||
// Type something to make a row touched
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'a');
|
||||
|
||||
expect(sendInvitesBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API integration', () => {
|
||||
it('only sends touched (non-empty) rows — empty rows are excluded from the invite payload', async () => {
|
||||
let capturedBody: InviteRequestBody | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedBody = await req.json<InviteRequestBody>();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'only@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect(capturedBody?.invites).toHaveLength(1);
|
||||
expect(capturedBody?.invites[0]).toMatchObject({
|
||||
email: 'only@example.com',
|
||||
role: 'ADMIN',
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(mockOnNext).toHaveBeenCalled(), {
|
||||
timeout: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the invite API, shows a success notification, and calls onNext after the 1 s delay', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'alice@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Invites sent successfully!' }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Success',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ timeout: 1200 },
|
||||
);
|
||||
expect(mockNotificationSuccess).toHaveBeenCalledWith({
|
||||
message: 'Invites sent successfully!',
|
||||
});
|
||||
|
||||
expect(mockOnNext).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an API error container when the invite request fails', async () => {
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
errors: [{ code: 'INTERNAL_ERROR', msg: 'Something went wrong' }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
describe('handlePartialSuccess callback', () => {
|
||||
it('logs event with teamMembers in correct shape and shows warning notification', () => {
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: true },
|
||||
{ email: 'user2@test.com', success: false, error: 'Already exists' },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-admin-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onPartialSuccess?.(mockResults, mockRows);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Partial Success',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'ADMIN',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await user.type(firstInput, 'fail@example.com');
|
||||
await selectRole(user, 0, 'Viewer');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.auth-error-container')).toBeInTheDocument();
|
||||
expect(mockNotificationWarning).toHaveBeenCalledWith({
|
||||
message: 'Some invites failed. Check the errors above.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await user.type(firstInput, 'x');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.auth-error-container'),
|
||||
).not.toBeInTheDocument();
|
||||
describe('handleAllFailed callback', () => {
|
||||
it('logs event with teamMembers in correct shape', () => {
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: false, error: 'Error 1' },
|
||||
{ email: 'user2@test.com', success: false, error: 'Error 2' },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-editor-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-viewer-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onAllFailed?.(mockResults, mockRows);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Failed',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDoLater', () => {
|
||||
it('logs event and calls onNext immediately', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
renderComponent();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /i'll do this later/i }),
|
||||
);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Org Onboarding: Clicked Do Later', {
|
||||
currentPageID: 4,
|
||||
});
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
}
|
||||
|
||||
&.invite-team-members-form {
|
||||
--invite-members-field-background: var(--l3-background);
|
||||
|
||||
padding-right: 12px;
|
||||
|
||||
.form-group {
|
||||
|
||||
@@ -22,7 +22,8 @@ const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
|
||||
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
|
||||
const UPDATE_PROFILE_ENDPOINT = '*/api/v2/zeus/profiles';
|
||||
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
|
||||
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
|
||||
const CREATE_USER_ENDPOINT = '*/api/v2/users';
|
||||
const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
const mockOrgPreferences = {
|
||||
data: {
|
||||
@@ -31,6 +32,12 @@ const mockOrgPreferences = {
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
const MOCK_ROLES = [
|
||||
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
|
||||
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
|
||||
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
|
||||
];
|
||||
|
||||
describe('OnboardingQuestionaire Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -48,8 +55,11 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
rest.post(UPDATE_ORG_PREFERENCE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
|
||||
),
|
||||
rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AxiosError } from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -71,9 +70,6 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
|
||||
const [optimiseSignozDetails, setOptimiseSignozDetails] =
|
||||
useState<OptimiseSignozDetails>(INITIAL_OPTIMISE_SIGNOZ_DETAILS);
|
||||
const [teamMembers, setTeamMembers] = useState<
|
||||
InviteTeamMembersProps[] | null
|
||||
>(null);
|
||||
|
||||
const [updatingOrgOnboardingStatus, setUpdatingOrgOnboardingStatus] =
|
||||
useState<boolean>(false);
|
||||
@@ -232,8 +228,6 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
{currentStep === 4 && (
|
||||
<InviteTeamMembers
|
||||
isLoading={updatingOrgOnboardingStatus}
|
||||
teamMembers={teamMembers}
|
||||
setTeamMembers={setTeamMembers}
|
||||
onNext={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
|
||||
import { ArrowRight, Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Space,
|
||||
Steps,
|
||||
} from 'antd';
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
@@ -27,7 +29,7 @@ import { isModifierKeyPressed } from 'utils/app';
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import OnboardingIngestionDetails from '../IngestionDetails/IngestionDetails';
|
||||
import InviteTeamMembers from '../InviteTeamMembers/InviteTeamMembers';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
import onboardingConfigWithLinks from '../onboarding-configs/onboarding-config-with-links';
|
||||
|
||||
import '../OnboardingV2.styles.scss';
|
||||
@@ -119,6 +121,10 @@ const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
|
||||
GET_HELP_BUTTON_CLICKED: 'Get help clicked',
|
||||
GET_EXPERT_ASSISTANCE_BUTTON_CLICKED: 'Get expert assistance clicked',
|
||||
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Invite team member clicked',
|
||||
INVITE_TEAM_MEMBER_SEND_CLICKED: 'Send invites clicked',
|
||||
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
|
||||
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
|
||||
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
|
||||
CLOSE_ONBOARDING_CLICKED: 'Close onboarding clicked',
|
||||
DATA_SOURCE_REQUESTED: 'Datasource requested',
|
||||
DATA_SOURCE_SEARCHED: 'Searched',
|
||||
@@ -1147,12 +1153,54 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="invite-team-member-modal-content">
|
||||
<InviteTeamMembers
|
||||
isLoading={false}
|
||||
teamMembers={null}
|
||||
setTeamMembers={(): void => {}}
|
||||
onNext={(): void => setShowInviteTeamMembersModal(false)}
|
||||
onClose={(): void => setShowInviteTeamMembersModal(false)}
|
||||
<InviteMembers
|
||||
onSuccess={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
|
||||
{},
|
||||
);
|
||||
setShowInviteTeamMembersModal(false);
|
||||
|
||||
toast.success('Invites sent successfully', { position: 'top-center' });
|
||||
}}
|
||||
onPartialSuccess={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_PARTIAL_SUCCESS}`,
|
||||
{},
|
||||
);
|
||||
}}
|
||||
onAllFailed={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
|
||||
{},
|
||||
);
|
||||
}}
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
|
||||
<div className="invite-team-member-modal-footer">
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowInviteTeamMembersModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</SignozButton>
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
onClick={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SEND_CLICKED}`,
|
||||
{},
|
||||
);
|
||||
void submit();
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
suffix={<ArrowRight size={14} />}
|
||||
>
|
||||
Send Invites
|
||||
</SignozButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
.team-member-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.invite-team-members-form {
|
||||
padding: 16px 0px;
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 80%;
|
||||
background-color: var(--l1-background);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
.ant-input,
|
||||
.ant-input-group-addon {
|
||||
background-color: var(--l1-background) !important;
|
||||
border-right: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-role-select {
|
||||
width: 20%;
|
||||
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-team-members-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.invite-team-members-add-another-member-container {
|
||||
margin: 16px 0px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.next-prev-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error-message-container,
|
||||
.success-message-container,
|
||||
.partially-sent-invites-container {
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.error-message,
|
||||
.success-message {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-users-error-message-container,
|
||||
.invite-users-success-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.success-message {
|
||||
color: var(--bg-success-500, #00b37e);
|
||||
}
|
||||
}
|
||||
|
||||
.partially-sent-invites-container {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.partially-sent-invites-message {
|
||||
color: var(--bg-warning-500, #fbbd23);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user