mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 21:00:38 +01:00
Compare commits
35 Commits
nv/dashboa
...
infraM/kub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a51f22679 | ||
|
|
700e4485b9 | ||
|
|
3e6339019e | ||
|
|
4d73a24e3a | ||
|
|
7c8b2a922c | ||
|
|
89524c4c67 | ||
|
|
83bb1993fe | ||
|
|
df05f33c06 | ||
|
|
372d304a6f | ||
|
|
df83080d7c | ||
|
|
42b38b54ac | ||
|
|
11d56a1c4a | ||
|
|
c3e7e0925b | ||
|
|
1ea1b489f9 | ||
|
|
246e01c89a | ||
|
|
76791cbd7e | ||
|
|
926125df22 | ||
|
|
ce664e3c85 | ||
|
|
dbb4eb9574 | ||
|
|
c36226050e | ||
|
|
a72484f12c | ||
|
|
71eabac1e7 | ||
|
|
fea3be7c51 | ||
|
|
66f03d5912 | ||
|
|
cf69a05f74 | ||
|
|
1648fce5b1 | ||
|
|
f93a70884a | ||
|
|
e1c586e0dc | ||
|
|
984b2d0138 | ||
|
|
3ea62d3d50 | ||
|
|
9317a26337 | ||
|
|
fde817d83c | ||
|
|
13812fac62 | ||
|
|
df77b8d125 | ||
|
|
028ac27496 |
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -2668,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:
|
||||
@@ -3517,7 +3571,7 @@ components:
|
||||
- user
|
||||
- system
|
||||
- integration
|
||||
type: object
|
||||
type: string
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
@@ -4108,6 +4162,7 @@ components:
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
- kube_containers
|
||||
type: string
|
||||
InframonitoringtypesChecks:
|
||||
properties:
|
||||
@@ -4210,6 +4265,158 @@ components:
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesContainerCountsByReady:
|
||||
properties:
|
||||
notReady:
|
||||
type: integer
|
||||
ready:
|
||||
type: integer
|
||||
required:
|
||||
- ready
|
||||
- notReady
|
||||
type: object
|
||||
InframonitoringtypesContainerCountsByStatus:
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
containerCannotRun:
|
||||
type: integer
|
||||
containerCreating:
|
||||
type: integer
|
||||
crashLoopBackOff:
|
||||
type: integer
|
||||
createContainerConfigError:
|
||||
type: integer
|
||||
errImagePull:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
imagePullBackOff:
|
||||
type: integer
|
||||
oomKilled:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
terminated:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
waiting:
|
||||
type: integer
|
||||
required:
|
||||
- running
|
||||
- waiting
|
||||
- terminated
|
||||
- crashLoopBackOff
|
||||
- imagePullBackOff
|
||||
- errImagePull
|
||||
- createContainerConfigError
|
||||
- containerCreating
|
||||
- oomKilled
|
||||
- completed
|
||||
- error
|
||||
- containerCannotRun
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesContainerReady:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesContainerRecord:
|
||||
properties:
|
||||
containerCountsByReady:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByReady'
|
||||
containerCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByStatus'
|
||||
containerName:
|
||||
type: string
|
||||
cpu:
|
||||
format: double
|
||||
type: number
|
||||
cpuLimitUtilization:
|
||||
format: double
|
||||
type: number
|
||||
cpuRequestUtilization:
|
||||
format: double
|
||||
type: number
|
||||
memory:
|
||||
format: double
|
||||
type: number
|
||||
memoryLimitUtilization:
|
||||
format: double
|
||||
type: number
|
||||
memoryRequestUtilization:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
podUID:
|
||||
type: string
|
||||
ready:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerReady'
|
||||
restarts:
|
||||
format: int64
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
|
||||
required:
|
||||
- podUID
|
||||
- containerName
|
||||
- status
|
||||
- containerCountsByStatus
|
||||
- ready
|
||||
- containerCountsByReady
|
||||
- restarts
|
||||
- cpu
|
||||
- cpuRequestUtilization
|
||||
- cpuLimitUtilization
|
||||
- memory
|
||||
- memoryRequestUtilization
|
||||
- memoryLimitUtilization
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesContainerStatus:
|
||||
enum:
|
||||
- running
|
||||
- waiting
|
||||
- terminated
|
||||
- crashloopbackoff
|
||||
- imagepullbackoff
|
||||
- errimagepull
|
||||
- createcontainerconfigerror
|
||||
- containercreating
|
||||
- oomkilled
|
||||
- completed
|
||||
- error
|
||||
- containercannotrun
|
||||
- unknown
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesContainers:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerRecord'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetRecord:
|
||||
properties:
|
||||
currentNodes:
|
||||
@@ -4779,6 +4986,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableContainers:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableDaemonSets:
|
||||
properties:
|
||||
end:
|
||||
@@ -5343,6 +5576,9 @@ components:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
ingestedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5355,9 +5591,9 @@ components:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
retainedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
retainedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5375,7 +5611,8 @@ components:
|
||||
- active
|
||||
- ingestedSeries
|
||||
- retainedSeries
|
||||
- reductionPercent
|
||||
- ingestedSamples
|
||||
- retainedSamples
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRulePreview:
|
||||
properties:
|
||||
@@ -5418,15 +5655,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:
|
||||
@@ -5492,7 +5737,6 @@ components:
|
||||
- metric
|
||||
- ingested_volume
|
||||
- reduced_volume
|
||||
- reduction
|
||||
- last_updated
|
||||
type: string
|
||||
MetricreductionruletypesUpdatableReductionRule:
|
||||
@@ -11756,68 +12000,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
|
||||
@@ -11880,158 +12062,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
|
||||
@@ -15841,6 +15871,86 @@ paths:
|
||||
summary: List Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/kube_containers:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
|
||||
metrics: CPU usage (cores), CPU request/limit utilization, memory working
|
||||
set, and memory request/limit utilization. Each container also reports health
|
||||
signals from the k8s_cluster receiver: status (kubectl-style display status
|
||||
derived from k8s.container.status.state + k8s.container.status.reason), restarts
|
||||
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
|
||||
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
|
||||
stable across container restarts. Each container includes metadata attributes
|
||||
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
|
||||
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
|
||||
as deployment/statefulset/daemonset/job). The response type is ''list'' for
|
||||
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
|
||||
with its current status and ready state) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates containers in the group with per-status counts under
|
||||
containerCountsByStatus, per-readiness counts under containerCountsByReady,
|
||||
and restarts as the group sum). Status requires the optional k8s.container.status.state
|
||||
and k8s.container.status.reason metrics; when either is missing, status is
|
||||
omitted and a warning is returned while restarts and ready are still computed.
|
||||
Supports filtering via a filter expression, custom groupBy, ordering by any
|
||||
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
|
||||
and pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
|
||||
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
|
||||
and restarts return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListContainers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainers'
|
||||
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: List Kubernetes Containers for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/namespaces:
|
||||
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)
|
||||
|
||||
@@ -64,6 +64,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
signoz.Cache,
|
||||
signoz.Flagger,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -328,6 +328,11 @@
|
||||
{
|
||||
"name": "immer",
|
||||
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
|
||||
},
|
||||
{
|
||||
"name": "api/generated/services/dashboard",
|
||||
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
|
||||
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableContainersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
InframonitoringtypesPostableStatefulSetsDTO,
|
||||
InframonitoringtypesPostableVolumesDTO,
|
||||
ListClusters200,
|
||||
ListContainers200,
|
||||
ListDaemonSets200,
|
||||
ListDeployments200,
|
||||
ListHosts200,
|
||||
@@ -548,6 +550,89 @@ export const useListJobs = <
|
||||
> => {
|
||||
return useMutation(getListJobsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes containers with key kubeletstats metrics: CPU usage (cores), CPU request/limit utilization, memory working set, and memory request/limit utilization. Each container also reports health signals from the k8s_cluster receiver: status (kubectl-style display status derived from k8s.container.status.state + k8s.container.status.reason), restarts (absolute count from k8s.container.restarts), and ready (ready/not_ready from k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name), stable across container restarts. Each container includes metadata attributes (k8s.container.name, k8s.pod.name, container.image.name, container.image.tag, k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such as deployment/statefulset/daemonset/job). The response type is 'list' for the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container with its current status and ready state) or 'grouped_list' for custom groupBy keys (each row aggregates containers in the group with per-status counts under containerCountsByStatus, per-readiness counts under containerCountsByReady, and restarts as the group sum). Status requires the optional k8s.container.status.state and k8s.container.status.reason metrics; when either is missing, status is omitted and a warning is returned while restarts and ready are still computed. Supports filtering via a filter expression, custom groupBy, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization, cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization) and restarts return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Kubernetes Containers for Infra Monitoring
|
||||
*/
|
||||
export const listContainers = (
|
||||
inframonitoringtypesPostableContainersDTO?: BodyType<InframonitoringtypesPostableContainersDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListContainers200>({
|
||||
url: `/api/v2/infra_monitoring/kube_containers`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableContainersDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListContainersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listContainers'];
|
||||
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 listContainers>>,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listContainers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListContainersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listContainers>>
|
||||
>;
|
||||
export type ListContainersMutationBody =
|
||||
| BodyType<InframonitoringtypesPostableContainersDTO>
|
||||
| undefined;
|
||||
export type ListContainersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Kubernetes Containers for Infra Monitoring
|
||||
*/
|
||||
export const useListContainers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listContainers>>,
|
||||
TError,
|
||||
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getListContainersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Namespaces for Infra Monitoring
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
@@ -3931,6 +3977,14 @@ export interface DashboardtypesDashboardPanelRefDTO {
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
filterBy?: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -5499,6 +5553,7 @@ export enum InframonitoringtypesCheckTypeDTO {
|
||||
namespaces = 'namespaces',
|
||||
clusters = 'clusters',
|
||||
volumes = 'volumes',
|
||||
kube_containers = 'kube_containers',
|
||||
}
|
||||
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
@@ -5702,6 +5757,174 @@ export interface InframonitoringtypesClustersDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesContainerCountsByReadyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
notReady: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
ready: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesContainerCountsByStatusDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
completed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCannotRun: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCreating: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
crashLoopBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
createContainerConfigError: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
errImagePull: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
error: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
imagePullBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
oomKilled: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
terminated: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknown: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
waiting: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesContainerReadyDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesContainerRecordDTOMeta =
|
||||
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
|
||||
|
||||
export enum InframonitoringtypesContainerStatusDTO {
|
||||
running = 'running',
|
||||
waiting = 'waiting',
|
||||
terminated = 'terminated',
|
||||
crashloopbackoff = 'crashloopbackoff',
|
||||
imagepullbackoff = 'imagepullbackoff',
|
||||
errimagepull = 'errimagepull',
|
||||
createcontainerconfigerror = 'createcontainerconfigerror',
|
||||
containercreating = 'containercreating',
|
||||
oomkilled = 'oomkilled',
|
||||
completed = 'completed',
|
||||
error = 'error',
|
||||
containercannotrun = 'containercannotrun',
|
||||
unknown = 'unknown',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesContainerRecordDTO {
|
||||
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
|
||||
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
containerName: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpu: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpuLimitUtilization: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpuRequestUtilization: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memoryLimitUtilization: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memoryRequestUtilization: number;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
meta: InframonitoringtypesContainerRecordDTOMeta;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
podUID: string;
|
||||
ready: InframonitoringtypesContainerReadyDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
restarts: number;
|
||||
status: InframonitoringtypesContainerStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesContainersDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesContainerRecordDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6251,6 +6474,33 @@ export interface InframonitoringtypesPostableClustersDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableContainersDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableDaemonSetsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -6855,6 +7105,11 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6870,10 +7125,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reductionPercent: number;
|
||||
retainedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6932,11 +7187,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
|
||||
@@ -6992,7 +7257,6 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
|
||||
metric = 'metric',
|
||||
ingested_volume = 'ingested_volume',
|
||||
reduced_volume = 'reduced_volume',
|
||||
reduction = 'reduction',
|
||||
last_updated = 'last_updated',
|
||||
}
|
||||
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
|
||||
@@ -10184,31 +10448,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
|
||||
@@ -10797,6 +11039,14 @@ export type ListJobs200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListContainers200 = {
|
||||
data: InframonitoringtypesContainersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNamespaces200 = {
|
||||
data: InframonitoringtypesNamespacesDTO;
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { SolidAlertTriangle } from '@signozhq/icons';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import classNames from 'classnames';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { UniversalYAxisUnitMappings } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
@@ -72,9 +72,7 @@ function YAxisUnitSelector({
|
||||
}, [categoriesOverride, source]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('y-axis-unit-selector-component', containerClassName)}
|
||||
>
|
||||
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
|
||||
<Select
|
||||
showSearch
|
||||
value={universalUnit}
|
||||
@@ -84,12 +82,17 @@ function YAxisUnitSelector({
|
||||
loading={loading}
|
||||
suffixIcon={
|
||||
incompatibleUnitMessage ? (
|
||||
<Tooltip title={incompatibleUnitMessage}>
|
||||
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
|
||||
<Tooltip
|
||||
title={incompatibleUnitMessage}
|
||||
overlayClassName="y-axis-unit-warning-tooltip"
|
||||
>
|
||||
<span className="y-axis-unit-warning" role="img" aria-label="warning">
|
||||
<SolidAlertTriangle size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
className={classNames({
|
||||
className={cx({
|
||||
'warning-state': incompatibleUnitMessage,
|
||||
})}
|
||||
data-testid={dataTestId}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { YAxisCategoryNames } from '../constants';
|
||||
import { UniversalYAxisUnit, YAxisSource } from '../types';
|
||||
@@ -6,9 +7,13 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
|
||||
|
||||
describe('YAxisUnitSelector', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
|
||||
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
});
|
||||
|
||||
it('renders with default placeholder', () => {
|
||||
@@ -34,7 +39,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a value is selected', () => {
|
||||
it('calls onChange when a value is selected', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -44,9 +49,8 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const option = screen.getByText('Bytes (B)');
|
||||
fireEvent.click(option);
|
||||
await user.click(select);
|
||||
await user.click(screen.getByText('Bytes (B)'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('By', {
|
||||
children: 'Bytes (B)',
|
||||
@@ -55,7 +59,7 @@ describe('YAxisUnitSelector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filters options based on search input', () => {
|
||||
it('filters options based on search input', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -65,14 +69,13 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'bytes/sec' } });
|
||||
await user.click(select);
|
||||
await user.type(select, 'bytes/sec');
|
||||
|
||||
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories and their units', () => {
|
||||
it('shows all categories and their units', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -80,9 +83,8 @@ describe('YAxisUnitSelector', () => {
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
// Check for category headers
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
@@ -93,7 +95,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning message when incompatible unit is selected', () => {
|
||||
it('shows warning message when incompatible unit is selected', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
source={YAxisSource.ALERTS}
|
||||
@@ -104,12 +106,12 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const warningIcon = screen.getByLabelText('warning');
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
fireEvent.mouseOver(warningIcon);
|
||||
return screen
|
||||
.findByText(
|
||||
await user.hover(warningIcon);
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
|
||||
)
|
||||
.then((el) => expect(el).toBeInTheDocument());
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show warning message when compatible unit is selected', () => {
|
||||
@@ -125,7 +127,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses categories override to render custom units', () => {
|
||||
it('uses categories override to render custom units', async () => {
|
||||
const customCategories = [
|
||||
{
|
||||
name: YAxisCategoryNames.Data,
|
||||
@@ -147,9 +149,7 @@ describe('YAxisUnitSelector', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
|
||||
// `pointer-events: none`, which would otherwise suppress the tooltip.
|
||||
.y-axis-unit-warning {
|
||||
display: inline-flex;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.warning-state {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-amber-400) !important;
|
||||
@@ -17,3 +24,7 @@
|
||||
right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.y-axis-unit-warning-tooltip {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.emptyMeterSearch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EmptyMeterSearch.module.scss';
|
||||
|
||||
interface EmptyMeterSearchProps {
|
||||
hasQueryResult?: boolean;
|
||||
}
|
||||
|
||||
export default function EmptyMeterSearch({
|
||||
hasQueryResult,
|
||||
}: EmptyMeterSearchProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyMeterSearch}>
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Title level={5}>
|
||||
{hasQueryResult
|
||||
? 'No data'
|
||||
: 'Select a metric and run a query to see the results'}
|
||||
</Typography.Title>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,34 +73,6 @@
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-meter-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.time-series-view-panel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
padding: 8px !important;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.time-series-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, calc(50% - 8px)), 1fr)
|
||||
);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,22 +85,6 @@
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.meter-time-series-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.builder-units-filter {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px !important;
|
||||
|
||||
.builder-units-filter-label {
|
||||
margin-bottom: 0px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@@ -35,7 +35,6 @@ function Explorer(): JSX.Element {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -67,15 +66,6 @@ function Explorer(): JSX.Element {
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryData(0, {
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
source: 'meter',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.loadingMeter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
height: 240px;
|
||||
padding: var(--spacing-12) 0;
|
||||
}
|
||||
|
||||
.loadingMeterContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.loadingGif {
|
||||
height: 72px;
|
||||
margin-left: calc(-1 * var(--spacing-12));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import styles from './MeterLoading.module.scss';
|
||||
|
||||
export default function MeterLoading(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingMeter}>
|
||||
<div className={styles.loadingMeterContent}>
|
||||
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
|
||||
<Typography>Retrieving your {DataSource.METRICS}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.meterTimeSeriesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-5);
|
||||
width: 100%;
|
||||
|
||||
:global(.builder-units-filter) {
|
||||
padding: 0 var(--spacing-4);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
:global(.builder-units-filter-label) {
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSeriesContainer {
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
max-height: 50vh;
|
||||
padding-right: 16px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.timeSeriesViewPanel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isAxiosError } from 'axios';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { buildMeterChartConfig } from './configBuilder';
|
||||
import EmptyMeterSearch from './EmptyMeterSearch';
|
||||
import MeterLoading from './MeterLoading';
|
||||
import styles from './TimeSeries.module.scss';
|
||||
import { useTimeSeriesQueries } from './useTimeSeriesQueries';
|
||||
import { useTimeSeriesTimeManagement } from './useTimeSeriesTimeManagement';
|
||||
|
||||
const WIDGET_ID = 'meter-explorer-bar-chart';
|
||||
|
||||
interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
@@ -32,144 +33,124 @@ function TimeSeries({
|
||||
onFetchingStateChange,
|
||||
isCancelled = false,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
const { minTimeScale, maxTimeScale, onDragSelect } =
|
||||
useTimeSeriesTimeManagement({
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
});
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() => [stagedQuery || initialQueryMeterWithType],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
graphType: PANEL_TYPES.BAR,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
status = error.getHttpStatusCode();
|
||||
} else if (isAxiosError(error)) {
|
||||
status = error.response?.status;
|
||||
}
|
||||
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() =>
|
||||
data.map((datapoint) =>
|
||||
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
|
||||
),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
const { responseData, isLoading, isError } = useTimeSeriesQueries({
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
onFetchingStateChange,
|
||||
});
|
||||
|
||||
const hasMetricSelected = useMemo(
|
||||
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
|
||||
[currentQuery],
|
||||
);
|
||||
|
||||
const chartsData = useMemo(() => {
|
||||
return responseData.map((response, index) => {
|
||||
const apiResponse = response?.payload;
|
||||
|
||||
const config = buildMeterChartConfig({
|
||||
id: `${WIDGET_ID}-${index}`,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
yAxisUnit: yAxisUnit || 'short',
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
const chartData = apiResponse ? prepareChartData(apiResponse) : [];
|
||||
|
||||
return {
|
||||
config,
|
||||
chartData,
|
||||
hasData: chartData.length > 0 && chartData[0]?.length > 0,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
responseData,
|
||||
currentQuery,
|
||||
yAxisUnit,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
]);
|
||||
|
||||
const hasAnyData = chartsData.some((chart) => chart.hasData);
|
||||
|
||||
return (
|
||||
<div className="meter-time-series-container">
|
||||
<div className={styles.meterTimeSeriesContainer}>
|
||||
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{!hasMetricSelected && <EmptyMetricsSearch />}
|
||||
<div className={styles.timeSeriesContainer} ref={graphRef}>
|
||||
{!hasMetricSelected && <EmptyMeterSearch />}
|
||||
{isCancelled && hasMetricSelected && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
|
||||
)}
|
||||
{isLoading && hasMetricSelected && !isCancelled && <MeterLoading />}
|
||||
{!isCancelled &&
|
||||
hasMetricSelected &&
|
||||
responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
!hasAnyData && (
|
||||
<EmptyMeterSearch hasQueryResult={responseData[0] !== undefined} />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
hasMetricSelected &&
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 &&
|
||||
chartsData.map(
|
||||
(chart, index) =>
|
||||
chart.hasData && (
|
||||
<div
|
||||
className={styles.timeSeriesViewPanel}
|
||||
// oxlint-disable-next-line react/no-array-index-key -- query responses have no stable ID
|
||||
key={`${WIDGET_ID}-${index}`}
|
||||
>
|
||||
<BarChart
|
||||
config={chart.config}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
}}
|
||||
data={chart.chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
isStackedBarChart
|
||||
yAxisUnit={yAxisUnit || 'short'}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
117
frontend/src/container/MeterExplorer/Explorer/configBuilder.ts
Normal file
117
frontend/src/container/MeterExplorer/Explorer/configBuilder.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export interface MeterChartConfigProps {
|
||||
id: string;
|
||||
isDarkMode: boolean;
|
||||
currentQuery: Query;
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
yAxisUnit: string;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
export function buildMeterChartConfig({
|
||||
id,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: MeterChartConfigProps): UPlotConfigBuilder {
|
||||
const stepIntervals = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
) as Record<string, number>;
|
||||
const minStepInterval = Object.keys(stepIntervals).length
|
||||
? Math.min(...Object.values(stepIntervals))
|
||||
: undefined;
|
||||
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval: minStepInterval,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = getLegend(series, currentQuery, baseLabelName);
|
||||
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
metric: series.metric,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseTimeSeriesQueriesProps {
|
||||
stagedQuery: Query | null;
|
||||
currentQuery: Query;
|
||||
globalSelectedTime: Time | CustomTimeType;
|
||||
maxTime: number;
|
||||
minTime: number;
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseTimeSeriesQueriesResult {
|
||||
responseData: (SuccessResponse<MetricRangePayloadProps> | undefined)[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function useTimeSeriesQueries({
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
onFetchingStateChange,
|
||||
}: UseTimeSeriesQueriesProps): UseTimeSeriesQueriesResult {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() => [stagedQuery || initialQueryMeterWithType],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
graphType: PANEL_TYPES.BAR,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
status = error.getHttpStatusCode();
|
||||
} else if (isAxiosError(error)) {
|
||||
status = error.response?.status;
|
||||
}
|
||||
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const responseData = useMemo(() => {
|
||||
const data = queries.map(({ data }) => data) ?? [];
|
||||
return data.map((datapoint) =>
|
||||
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
|
||||
);
|
||||
}, [queries, isValidToConvertToMs]);
|
||||
|
||||
const isLoading = queries.some((q) => q.isLoading);
|
||||
const isError = queries.some((q) => q.isError);
|
||||
|
||||
return {
|
||||
responseData,
|
||||
isLoading,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
interface UseTimeSeriesTimeManagementProps {
|
||||
globalSelectedTime: Time | CustomTimeType;
|
||||
maxTime: number;
|
||||
minTime: number;
|
||||
}
|
||||
|
||||
interface UseTimeSeriesTimeManagementResult {
|
||||
minTimeScale: number | undefined;
|
||||
maxTimeScale: number | undefined;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
export function useTimeSeriesTimeManagement({
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
}: UseTimeSeriesTimeManagementProps): UseTimeSeriesTimeManagementResult {
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange();
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [maxTime, minTime, globalSelectedTime]);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
|
||||
const { maxTime, minTime } = GetMinMax('custom', [
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
]);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, minTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, maxTime.toString());
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
},
|
||||
[dispatch, location.pathname, urlQuery],
|
||||
);
|
||||
|
||||
const handleBackNavigation = useCallback((): void => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const startTime = searchParams.get(QueryParams.startTime);
|
||||
const endTime = searchParams.get(QueryParams.endTime);
|
||||
const relativeTime = searchParams.get(
|
||||
QueryParams.relativeTime,
|
||||
) as CustomTimeType;
|
||||
|
||||
if (relativeTime) {
|
||||
dispatch(UpdateTimeInterval(relativeTime));
|
||||
} else if (startTime && endTime && startTime !== endTime) {
|
||||
dispatch(
|
||||
UpdateTimeInterval('custom', [
|
||||
parseInt(getTimeString(startTime), 10),
|
||||
parseInt(getTimeString(endTime), 10),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('popstate', handleBackNavigation);
|
||||
return (): void => {
|
||||
window.removeEventListener('popstate', handleBackNavigation);
|
||||
};
|
||||
}, [handleBackNavigation]);
|
||||
|
||||
return {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
};
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie } from '@visx/shape';
|
||||
@@ -8,12 +8,10 @@ import { themeColors } from 'constants/theme';
|
||||
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
|
||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||
import { preparePieChartData } from './preparePieChartData';
|
||||
import { lightenColor, tooltipStyles } from './utils';
|
||||
|
||||
import './PiePanelWrapper.styles.scss';
|
||||
@@ -44,37 +42,15 @@ function PiePanelWrapper({
|
||||
detectBounds: true,
|
||||
});
|
||||
|
||||
const panelData = queryResponse.data?.payload?.data?.result || [];
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
let pieChartData: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}[] = [].concat(
|
||||
...(panelData
|
||||
.map((d) => {
|
||||
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
|
||||
return {
|
||||
label,
|
||||
value: d?.values?.[0]?.[1],
|
||||
record: d,
|
||||
color:
|
||||
widget?.customLegendColors?.[label] ||
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((d) => d !== undefined) as never[]),
|
||||
);
|
||||
|
||||
pieChartData = pieChartData.filter(
|
||||
(arc) =>
|
||||
arc.value && !isNaN(parseFloat(arc.value)) && parseFloat(arc.value) > 0,
|
||||
const pieChartData = useMemo(
|
||||
() =>
|
||||
preparePieChartData(queryResponse.data?.payload, {
|
||||
customLegendColors: widget?.customLegendColors,
|
||||
colorMap: isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
}),
|
||||
[queryResponse.data?.payload, widget?.customLegendColors, isDarkMode],
|
||||
);
|
||||
|
||||
let size = 0;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { preparePieChartData } from '../preparePieChartData';
|
||||
|
||||
const options = { colorMap: themeColors.chartcolors };
|
||||
|
||||
/**
|
||||
* Mirrors a query-range payload: the (possibly collapsed) time-series `result`
|
||||
* plus the scalar table nested under `newResult` (as getQueryResults produces it).
|
||||
*/
|
||||
function makePayload(
|
||||
result: QueryData[],
|
||||
tables: QueryDataV3[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result,
|
||||
resultType: 'scalar',
|
||||
newResult: { data: { result: tables, resultType: 'scalar' } },
|
||||
},
|
||||
} as MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
function tableEntry(
|
||||
columns: NonNullable<QueryDataV3['table']>['columns'],
|
||||
rows: NonNullable<QueryDataV3['table']>['rows'],
|
||||
overrides: Partial<QueryDataV3> = {},
|
||||
): QueryDataV3 {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: { columns, rows },
|
||||
...overrides,
|
||||
} as QueryDataV3;
|
||||
}
|
||||
|
||||
describe('preparePieChartData', () => {
|
||||
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
|
||||
// SELECT count() AS col1, sum(value) AS col2 — the backend collapses the
|
||||
// time-series result onto col1; the full data lives in the scalar table.
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
values: [[0, '23399927']],
|
||||
} as QueryData,
|
||||
],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
],
|
||||
[{ data: { col1: 23399927, col2: 588691297 } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices).toHaveLength(2);
|
||||
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['col1', '23399927'],
|
||||
['col2', '588691297'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefixes the group when multiple value columns are grouped', () => {
|
||||
const payload = makePayload(
|
||||
[],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
],
|
||||
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual([
|
||||
'prod · col1',
|
||||
'prod · col2',
|
||||
]);
|
||||
expect(slices[0].record.metric).toStrictEqual({ env: 'prod' });
|
||||
});
|
||||
|
||||
it('drops non-positive and non-numeric values', () => {
|
||||
const payload = makePayload(
|
||||
[],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
|
||||
],
|
||||
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
|
||||
});
|
||||
|
||||
it('keeps the series path for a single value column (grouped panel)', () => {
|
||||
// One value column → the time-series result is authoritative (one slice per
|
||||
// group), so existing behaviour is preserved.
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: { 'service.name': 'adservice' },
|
||||
queryName: 'A',
|
||||
legend: 'adservice',
|
||||
values: [[0, '100']],
|
||||
} as QueryData,
|
||||
{
|
||||
metric: { 'service.name': 'cartservice' },
|
||||
queryName: 'A',
|
||||
legend: 'cartservice',
|
||||
values: [[0, '200']],
|
||||
} as QueryData,
|
||||
],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
[
|
||||
{ data: { 'service.name': 'adservice', A: 100 } },
|
||||
{ data: { 'service.name': 'cartservice', A: 200 } },
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['adservice', '100'],
|
||||
['cartservice', '200'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the legacy series result when there is no scalar table', () => {
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: { 'service.name': 'adservice' },
|
||||
queryName: 'A',
|
||||
legend: '{{service.name}}',
|
||||
values: [[1000, '42']],
|
||||
} as QueryData,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0].value).toBe('42');
|
||||
});
|
||||
|
||||
it('returns no slices for an empty payload', () => {
|
||||
expect(preparePieChartData(undefined, options)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
144
frontend/src/container/PanelWrapper/preparePieChartData.ts
Normal file
144
frontend/src/container/PanelWrapper/preparePieChartData.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export interface PieChartSlice {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: {
|
||||
queryName: string;
|
||||
legend?: string;
|
||||
/** Group-by labels, used for drilldown; absent when the slice has no group. */
|
||||
metric?: QueryData['metric'];
|
||||
};
|
||||
}
|
||||
|
||||
interface PreparePieChartDataOptions {
|
||||
customLegendColors?: Record<string, string>;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
const colorFor = (
|
||||
label: string,
|
||||
{ customLegendColors, colorMap }: PreparePieChartDataOptions,
|
||||
): string => customLegendColors?.[label] || generateColor(label, colorMap);
|
||||
|
||||
const isPositive = (value: string): boolean =>
|
||||
!!value && !isNaN(parseFloat(value)) && parseFloat(value) > 0;
|
||||
|
||||
/**
|
||||
* Time-series result: one slice per series, value = first datapoint. This is the
|
||||
* original pie behaviour — kept verbatim (same label/value/colour/record) so
|
||||
* single-value and grouped panels are unaffected.
|
||||
*/
|
||||
function slicesFromSeries(
|
||||
result: QueryData[],
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
return result
|
||||
.filter((d) => d?.values?.[0]?.[1] !== undefined)
|
||||
.map((d) => {
|
||||
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
|
||||
return {
|
||||
label,
|
||||
value: d.values[0][1],
|
||||
color: colorFor(label, options),
|
||||
record: d,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 scalar table: one slice per (row × value column). With more than one value
|
||||
* column the column name keeps the slices distinct, so a ClickHouse query like
|
||||
* `count() AS col1, sum() AS col2` renders a slice per column instead of
|
||||
* collapsing onto the first; group-by columns become the slice label.
|
||||
*/
|
||||
function slicesFromTables(
|
||||
tables: QueryDataV3[],
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
const slices: PieChartSlice[] = [];
|
||||
|
||||
tables.forEach((entry) => {
|
||||
const { table } = entry;
|
||||
if (!table?.columns?.length || !table?.rows?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueColumns = table.columns.filter((column) => column.isValueColumn);
|
||||
if (valueColumns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
const hasMultipleValueColumns = valueColumns.length > 1;
|
||||
|
||||
table.rows.forEach((row) => {
|
||||
const groupLabel = labelColumns
|
||||
.map((column) => row.data[column.id || column.name])
|
||||
.filter((part) => part != null)
|
||||
.map(String)
|
||||
.join(', ');
|
||||
// Drilldown filters by group-by labels; leave it undefined when there
|
||||
// are none (e.g. a ClickHouse query) so no filterless menu is offered.
|
||||
const metric = labelColumns.length
|
||||
? labelColumns.reduce<Record<string, string>>((acc, column) => {
|
||||
acc[column.name] = String(row.data[column.id || column.name]);
|
||||
return acc;
|
||||
}, {})
|
||||
: undefined;
|
||||
|
||||
valueColumns.forEach((column) => {
|
||||
let label: string;
|
||||
if (hasMultipleValueColumns) {
|
||||
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
|
||||
} else {
|
||||
label = groupLabel || entry.legend || entry.queryName || '';
|
||||
}
|
||||
|
||||
slices.push({
|
||||
label,
|
||||
value: String(row.data[column.id || column.name]),
|
||||
color: colorFor(label, options),
|
||||
record: { queryName: entry.queryName, legend: entry.legend, metric },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return slices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds pie slices from a query-range payload, dropping non-positive/non-numeric
|
||||
* values.
|
||||
*
|
||||
* A scalar response with several value columns (e.g. a ClickHouse
|
||||
* `count() AS col1, sum() AS col2`) collapses to a single series in
|
||||
* `data.result` — only the first value column survives. The full data is kept in
|
||||
* the scalar table under `newResult`, so in that case slices are built from the
|
||||
* table (one per value column). Otherwise the legacy time-series result is used,
|
||||
* preserving existing behaviour for single-value and grouped panels.
|
||||
*/
|
||||
export function preparePieChartData(
|
||||
payload: MetricRangePayloadProps | undefined,
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
const tables = (payload?.data?.newResult?.data?.result || []).filter(
|
||||
(entry) => entry?.table?.rows?.length,
|
||||
);
|
||||
const hasMultipleValueColumns = tables.some(
|
||||
(entry) =>
|
||||
(entry.table?.columns || []).filter((column) => column.isValueColumn)
|
||||
.length > 1,
|
||||
);
|
||||
|
||||
const slices = hasMultipleValueColumns
|
||||
? slicesFromTables(tables, options)
|
||||
: slicesFromSeries(payload?.data?.result || [], options);
|
||||
|
||||
return slices.filter((slice) => isPositive(slice.value));
|
||||
}
|
||||
@@ -79,13 +79,11 @@ function Panel({
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
widget?.query,
|
||||
widget?.panelTypes,
|
||||
requestData,
|
||||
startTime,
|
||||
endTime,
|
||||
],
|
||||
// Public data is fetched by index and the payload redacts each widget's
|
||||
// filters, so query bodies are identical across panels. Key on panel
|
||||
// identity + time — the only inputs that determine the response — so
|
||||
// panels don't collapse onto one cache entry.
|
||||
queryKey: [widget?.id, index, startTime, endTime],
|
||||
retry(failureCount, error): boolean {
|
||||
if (
|
||||
String(error).includes('status: error') &&
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
import Panel from '../Panel';
|
||||
|
||||
const useGetQueryRangeMock = jest.fn();
|
||||
|
||||
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
|
||||
useGetQueryRange: (...args: unknown[]): unknown => {
|
||||
useGetQueryRangeMock(...args);
|
||||
return {
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="widget-graph" />,
|
||||
}));
|
||||
|
||||
const buildWidget = (id: string): Widgets =>
|
||||
({
|
||||
id,
|
||||
panelTypes: PANEL_TYPES.LIST,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'logs', limit: 100, orderBy: [] }],
|
||||
},
|
||||
},
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
}) as unknown as Widgets;
|
||||
|
||||
describe('Public dashboard Panel', () => {
|
||||
beforeEach(() => {
|
||||
useGetQueryRangeMock.mockClear();
|
||||
});
|
||||
|
||||
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
|
||||
render(
|
||||
<>
|
||||
<Panel
|
||||
widget={buildWidget('widget-a')}
|
||||
index={2}
|
||||
dashboardId="dash-1"
|
||||
startTime={100}
|
||||
endTime={200}
|
||||
/>
|
||||
<Panel
|
||||
widget={buildWidget('widget-b')}
|
||||
index={62}
|
||||
dashboardId="dash-1"
|
||||
startTime={100}
|
||||
endTime={200}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const [callA, callB] = useGetQueryRangeMock.mock.calls;
|
||||
const queryKeyA = callA[2].queryKey;
|
||||
const metaA = callA[4];
|
||||
const queryKeyB = callB[2].queryKey;
|
||||
const metaB = callB[4];
|
||||
|
||||
// Key is panel identity + time only — the redacted query body is not part
|
||||
// of it, so identical query bodies can't collapse two panels onto one key.
|
||||
expect(queryKeyA).toStrictEqual(['widget-a', 2, 100, 200]);
|
||||
expect(queryKeyB).toStrictEqual(['widget-b', 62, 100, 200]);
|
||||
expect(queryKeyA).not.toStrictEqual(queryKeyB);
|
||||
|
||||
expect(metaA.widgetIndex).toBe(2);
|
||||
expect(metaB.widgetIndex).toBe(62);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
.rolesListingTable {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
.rolesSettingsContent {
|
||||
padding: 0 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.rolesSettingsToolbar {
|
||||
|
||||
@@ -30,6 +30,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { stackSeries } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -57,6 +58,7 @@ function TimeSeriesView({
|
||||
dataSource,
|
||||
setWarning,
|
||||
panelType = PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart = false,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -65,11 +67,23 @@ function TimeSeriesView({
|
||||
const location = useLocation();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const chartData = useMemo(
|
||||
const rawChartData = useMemo(
|
||||
() => getUPlotChartData(data?.payload),
|
||||
[data?.payload],
|
||||
);
|
||||
|
||||
const { chartData, stackedBands } = useMemo(() => {
|
||||
if (!stackBarChart || !rawChartData || rawChartData.length < 2) {
|
||||
return { chartData: rawChartData, stackedBands: null };
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false;
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
rawChartData as uPlot.AlignedData,
|
||||
noSeriesHidden,
|
||||
);
|
||||
return { chartData: stacked, stackedBands: bands };
|
||||
}, [rawChartData, stackBarChart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning?.(data?.warning);
|
||||
@@ -189,7 +203,7 @@ function TimeSeriesView({
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const chartOptions = getUPlotChartOptions({
|
||||
const baseChartOptions = getUPlotChartOptions({
|
||||
id: 'time-series-explorer',
|
||||
onDragSelect,
|
||||
yAxisUnit: yAxisUnit || '',
|
||||
@@ -222,6 +236,14 @@ function TimeSeriesView({
|
||||
},
|
||||
});
|
||||
|
||||
const chartOptions = useMemo(
|
||||
() =>
|
||||
stackedBands
|
||||
? { ...baseChartOptions, bands: stackedBands }
|
||||
: baseChartOptions,
|
||||
[baseChartOptions, stackedBands],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="time-series-view">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
@@ -282,6 +304,7 @@ interface TimeSeriesViewProps {
|
||||
dataSource: DataSource;
|
||||
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
panelType?: PANEL_TYPES;
|
||||
stackBarChart?: boolean;
|
||||
}
|
||||
|
||||
TimeSeriesView.defaultProps = {
|
||||
@@ -290,6 +313,7 @@ TimeSeriesView.defaultProps = {
|
||||
error: undefined,
|
||||
setWarning: undefined,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart: false,
|
||||
};
|
||||
|
||||
export default TimeSeriesView;
|
||||
|
||||
@@ -24,6 +24,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
|
||||
/**
|
||||
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
|
||||
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
|
||||
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
|
||||
*/
|
||||
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
@@ -18,6 +17,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
@@ -51,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
|
||||
@@ -88,14 +89,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
value: next,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
await patchAsync(patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[id, refetch, showErrorModal],
|
||||
[id, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
const { isEditing, draft, setDraft, startEdit, cancel, commit } =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
@@ -9,7 +8,7 @@ import { isEqual } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
@@ -23,7 +22,7 @@ interface OverviewProps {
|
||||
function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
const id = dashboard.id;
|
||||
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -96,15 +95,14 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
await patchAsync(ops);
|
||||
toast.success('Dashboard updated');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [id, buildPatch, refetch, showErrorModal]);
|
||||
}, [buildPatch, patchAsync, showErrorModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
@@ -14,14 +14,9 @@ interface UseSaveVariables {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the dashboard's variable list via a single `/spec/variables` patch,
|
||||
* then refetches. Mirrors the General-settings save flow (patch → toast →
|
||||
* refetch → surface errors).
|
||||
*/
|
||||
export function useSaveVariables(): UseSaveVariables {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -33,9 +28,8 @@ export function useSaveVariables(): UseSaveVariables {
|
||||
const dtos = variables.map(formModelToDto);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
|
||||
await patchAsync(buildVariablesPatch(dtos));
|
||||
toast.success('Variables updated');
|
||||
refetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -44,7 +38,7 @@ export function useSaveVariables(): UseSaveVariables {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, refetch, showErrorModal],
|
||||
[dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { save, isSaving };
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SquareArrowOutUpRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import styles from './ConfigActions.module.scss';
|
||||
|
||||
interface ConfigActionRowProps {
|
||||
/** Leading glyph for the action. */
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the config pane's "Actions" list — a cross-page navigation link
|
||||
* (leading icon, label, trailing external-link affordance). The whole row is the
|
||||
* click target.
|
||||
*/
|
||||
function ConfigActionRow({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
}: ConfigActionRowProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.row}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
prefix={<span className={styles.icon}>{icon}</span>}
|
||||
suffix={<SquareArrowOutUpRight size={14} />}
|
||||
>
|
||||
<Typography.Text className={styles.label}>{label}</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigActionRow;
|
||||
@@ -0,0 +1,57 @@
|
||||
/* The "Actions" group: a list of cross-page navigation links, visually separated
|
||||
from the collapsible config sections above by the same hairline divider. */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin: 0 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* A navigation-link row: leading icon, label, trailing external-link affordance. */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Bell } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
|
||||
|
||||
import ConfigActionRow from './ConfigActionRow';
|
||||
import styles from './ConfigActions.module.scss';
|
||||
|
||||
interface ConfigActionsProps {
|
||||
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Actions" group at the foot of the config pane: cross-page navigation links,
|
||||
* kept distinct from the collapsible config sections above. Each link is gated by the
|
||||
* panel kind's capabilities; the whole group hides when none apply.
|
||||
*/
|
||||
function ConfigActions({
|
||||
panel,
|
||||
panelId,
|
||||
}: ConfigActionsProps): JSX.Element | null {
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
// Only kinds whose query can seed an alert offer this today; mirror the panel
|
||||
// menu's create-alert capability.
|
||||
if (!actions.createAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.container}>
|
||||
<span className={styles.eyebrow}>Actions</span>
|
||||
<div className={styles.list}>
|
||||
<ConfigActionRow
|
||||
testId="panel-editor-v2-create-alert"
|
||||
icon={<Bell size={14} />}
|
||||
label="Create alert"
|
||||
onClick={(): void => createAlert(panel, panelId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigActions;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigActions from '../ConfigActions';
|
||||
|
||||
const mockCreateAlert = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: jest.fn(() => mockCreateAlert),
|
||||
}),
|
||||
);
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ConfigActions', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
render(<ConfigActions panel={panel} panelId="panel-1" />);
|
||||
|
||||
const row = screen.getByTestId('panel-editor-v2-create-alert');
|
||||
expect(row).toHaveTextContent('Create alert');
|
||||
|
||||
await user.click(row);
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
|
||||
});
|
||||
|
||||
it('renders nothing for a kind that cannot seed an alert', () => {
|
||||
const { container } = render(
|
||||
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
@@ -32,6 +34,14 @@ interface ConfigPaneProps {
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
|
||||
stepInterval?: number;
|
||||
/**
|
||||
* The draft panel and its id — the "Actions" group seeds cross-page links
|
||||
* (Create alert) from the current query.
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
|
||||
metricUnit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +51,6 @@ interface ConfigPaneProps {
|
||||
* generically via the section registry — only sections with a built editor appear.
|
||||
*/
|
||||
function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
@@ -49,7 +58,11 @@ function ConfigPane({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
panel,
|
||||
panelId,
|
||||
metricUnit,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
@@ -108,12 +121,15 @@ function ConfigPane({
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfigActions panel={panel} panelId={panelId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ function SectionSlot({
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
@@ -74,6 +75,7 @@ function SectionSlot({
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigPane from '../ConfigPane';
|
||||
|
||||
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
@@ -19,13 +30,14 @@ function renderConfigPane(
|
||||
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
|
||||
): React.ComponentProps<typeof ConfigPane> {
|
||||
const props: React.ComponentProps<typeof ConfigPane> = {
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
|
||||
panelId: 'panel-1',
|
||||
...overrides,
|
||||
};
|
||||
render(<ConfigPane {...props} />);
|
||||
@@ -63,4 +75,28 @@ describe('ConfigPane', () => {
|
||||
screen.getByTestId('config-section-formatting-&-units'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Actions group for a create-alert-capable panel', () => {
|
||||
// renderConfigPane defaults to a TimeSeries panel, which can seed an alert.
|
||||
renderConfigPane();
|
||||
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-create-alert'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the create-alert action for a kind that cannot seed an alert', () => {
|
||||
// Table panels can't seed alerts → the Actions group hides its row. Only the
|
||||
// panel passed to ConfigActions needs the kind; sections are asserted elsewhere.
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: { ...spec(), plugin: { kind: 'signoz/TablePanel', spec: {} } },
|
||||
} as DashboardtypesPanelDTO;
|
||||
renderConfigPane({ panel });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,4 +19,6 @@ export interface SectionEditorContext {
|
||||
yAxisUnit?: string;
|
||||
queryType?: EQueryType;
|
||||
stepInterval?: number;
|
||||
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
|
||||
metricUnit?: string;
|
||||
}
|
||||
|
||||
@@ -46,11 +46,10 @@ function DisconnectValuesField({
|
||||
onChange,
|
||||
}: DisconnectValuesFieldProps): JSX.Element {
|
||||
const duration = value?.fillLessThan || undefined;
|
||||
const isThreshold = !!duration;
|
||||
// Remember the last threshold so toggling Never → Threshold restores it.
|
||||
const [lastDuration, setLastDuration] = useState(
|
||||
duration ?? defaultDuration(stepInterval),
|
||||
);
|
||||
// `fillOnlyBelow` is authoritative; fall back to a stored duration for legacy panels.
|
||||
const isThreshold = value?.fillOnlyBelow ?? !!duration;
|
||||
// Remember the last committed threshold so Never → Threshold restores it.
|
||||
const [lastDuration, setLastDuration] = useState<string | undefined>(duration);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration) {
|
||||
@@ -59,11 +58,17 @@ function DisconnectValuesField({
|
||||
}, [duration]);
|
||||
|
||||
const handleMode = (mode: DisconnectValuesMode): void => {
|
||||
onChange(
|
||||
mode === DisconnectValuesMode.THRESHOLD
|
||||
? { ...value, fillLessThan: lastDuration }
|
||||
: undefined,
|
||||
);
|
||||
if (mode === DisconnectValuesMode.THRESHOLD) {
|
||||
onChange({
|
||||
...value,
|
||||
fillOnlyBelow: true,
|
||||
// Seed from the live stepInterval (async — undefined until results load), not mount.
|
||||
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Never spans every gap; drop the duration so the renderer reads a clean "span all".
|
||||
onChange({ ...value, fillOnlyBelow: false, fillLessThan: undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,14 +84,16 @@ function DisconnectValuesField({
|
||||
onChange={handleMode}
|
||||
/>
|
||||
</div>
|
||||
{isThreshold && (
|
||||
{isThreshold && duration && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Threshold value</Typography.Text>
|
||||
<DisconnectValuesThresholdInput
|
||||
testId={`${testId}-value`}
|
||||
value={lastDuration}
|
||||
value={duration}
|
||||
minValue={stepInterval}
|
||||
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillOnlyBelow: true, fillLessThan: next })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,28 @@ interface DisconnectValuesThresholdInputProps {
|
||||
onChange: (duration: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline error for a raw duration, or `null` when valid and in range. The parse is
|
||||
* guarded: `isValidTimeSpan` passes some strings `intervalToSeconds` throws on (e.g. "5x").
|
||||
*/
|
||||
function validationError(raw: string, minValue?: number): string | null {
|
||||
let seconds: number;
|
||||
try {
|
||||
seconds = rangeUtil.isValidTimeSpan(raw)
|
||||
? rangeUtil.intervalToSeconds(raw)
|
||||
: NaN;
|
||||
} catch {
|
||||
seconds = NaN;
|
||||
}
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return 'Enter a valid duration (e.g. 30s, 1m, 1h)';
|
||||
}
|
||||
if (minValue !== undefined && seconds < minValue) {
|
||||
return `Threshold should be > ${rangeUtil.secondsToHms(minValue)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration input for the span-gaps threshold: shows/accepts and reports a human
|
||||
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
|
||||
@@ -36,24 +58,21 @@ function DisconnectValuesThresholdInput({
|
||||
setError(null);
|
||||
}, [value]);
|
||||
|
||||
// Validate live so an invalid entry surfaces immediately, not only on blur.
|
||||
const handleText = (raw: string): void => {
|
||||
setText(raw);
|
||||
setError(raw ? validationError(raw, minValue) : null);
|
||||
};
|
||||
|
||||
const commit = (raw: string): void => {
|
||||
if (!raw) {
|
||||
// Skip no-op commits: blur fires when clicking the Never toggle, and re-emitting
|
||||
// the unchanged value there would race the toggle and snap back to Threshold.
|
||||
if (!raw || raw === value) {
|
||||
return;
|
||||
}
|
||||
let seconds: number;
|
||||
try {
|
||||
seconds = rangeUtil.isValidTimeSpan(raw)
|
||||
? rangeUtil.intervalToSeconds(raw)
|
||||
: NaN;
|
||||
} catch {
|
||||
seconds = NaN;
|
||||
}
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
|
||||
return;
|
||||
}
|
||||
if (minValue !== undefined && seconds < minValue) {
|
||||
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
|
||||
const message = validationError(raw, minValue);
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -69,12 +88,9 @@ function DisconnectValuesThresholdInput({
|
||||
status={error ? 'error' : undefined}
|
||||
prefix={<span className={styles.thresholdPrefix}>></span>}
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setText(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
handleText(e.target.value)
|
||||
}
|
||||
onBlur={(e): void => commit(e.currentTarget.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ChartAppearanceSection from '../ChartAppearanceSection';
|
||||
|
||||
/** Stateful wrapper that feeds onChange back as the spec, mirroring the real editor. */
|
||||
function StatefulSpanGaps({
|
||||
initial,
|
||||
stepInterval,
|
||||
}: {
|
||||
initial?: DashboardtypesTimeSeriesChartAppearanceDTO;
|
||||
stepInterval?: number;
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState<
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO | undefined
|
||||
>(initial);
|
||||
return (
|
||||
<ChartAppearanceSection
|
||||
value={value}
|
||||
controls={{ spanGaps: true }}
|
||||
stepInterval={stepInterval}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label. The
|
||||
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
|
||||
// only used for the line-interpolation ConfigSelect.
|
||||
@@ -139,7 +164,7 @@ describe('ChartAppearanceSection', () => {
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '1m' },
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +187,7 @@ describe('ChartAppearanceSection', () => {
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '5m' },
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,7 +208,7 @@ describe('ChartAppearanceSection', () => {
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '300' },
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '300' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +225,24 @@ describe('ChartAppearanceSection', () => {
|
||||
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('selects Never when fillOnlyBelow is false even if a duration lingers', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillOnlyBelow: false, fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The flag is authoritative: a stale fillLessThan must not show Threshold.
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error and does not commit an invalid duration', async () => {
|
||||
@@ -244,4 +286,117 @@ describe('ChartAppearanceSection', () => {
|
||||
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds the threshold from the step interval when switching to Threshold', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
stepInterval={300}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds from the step interval even when it arrives after mount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
// The step interval is undefined until the query response carries step metadata,
|
||||
// so the panel first renders without it and receives it on a later render.
|
||||
const { rerender } = render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
stepInterval={300}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
// Regression: a value seeded at mount would still be the 1m fallback.
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a validation error while typing, before blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'abc');
|
||||
// No blur / Enter — the error must already be visible.
|
||||
|
||||
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not re-commit the threshold when blurred without a change', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
|
||||
await user.click(input);
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fully switches from Threshold to Never (the input disappears)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '1m' } }} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-span-gaps-value'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Focus the input first so clicking Never also fires its blur (the toggle race).
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('remembers the last threshold when toggling Never → Threshold', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
|
||||
|
||||
await user.click(screen.getByText('Never'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
|
||||
'5m',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import ColumnUnits from './ColumnUnits';
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
|
||||
Pick<SectionEditorContext, 'tableColumns'>;
|
||||
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
|
||||
|
||||
// `full` means "show the raw value, no rounding"; the digits round to that many places.
|
||||
const DECIMAL_OPTIONS: {
|
||||
@@ -39,6 +39,7 @@ function FormattingSection({
|
||||
controls,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
metricUnit,
|
||||
}: FormattingSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -50,6 +51,7 @@ function FormattingSection({
|
||||
data-testid="panel-editor-v2-unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value?.unit}
|
||||
initialValue={metricUnit}
|
||||
onChange={(unit): void => onChange({ ...value, unit })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
async function pickDecimal(label: string): Promise<void> {
|
||||
@@ -71,4 +73,31 @@ describe('FormattingSection', () => {
|
||||
decimalPrecision: '2',
|
||||
});
|
||||
});
|
||||
|
||||
it('warns when the selected unit mismatches the metric unit', () => {
|
||||
// metric sent in seconds, but bytes is selected.
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ unit: 'By' }}
|
||||
controls={{ unit: true }}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no warning when the selected unit matches the metric unit', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ unit: 's' }}
|
||||
controls={{ unit: true }}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -82,6 +82,15 @@ function ThresholdsSection({
|
||||
// Which row is being edited, and whether it was just added (so Discard removes it).
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
|
||||
// The saved threshold captured on edit entry, restored if the edit is discarded
|
||||
// (edits stream into the spec live, so Discard can't just drop a local draft).
|
||||
const editSnapshot = useRef<AnyThreshold | null>(null);
|
||||
|
||||
const updateAt =
|
||||
(index: number) =>
|
||||
(next: AnyThreshold): void => {
|
||||
onChange(thresholds.map((t, i) => (i === index ? next : t)));
|
||||
};
|
||||
|
||||
const addThreshold = (): void => {
|
||||
const nextIndex = thresholds.length;
|
||||
@@ -90,6 +99,11 @@ function ThresholdsSection({
|
||||
setUnsavedIndex(nextIndex);
|
||||
};
|
||||
|
||||
const beginEdit = (index: number): void => {
|
||||
editSnapshot.current = thresholds[index] ?? null;
|
||||
setEditingIndex(index);
|
||||
};
|
||||
|
||||
const saveAt =
|
||||
(index: number) =>
|
||||
(next: AnyThreshold): void => {
|
||||
@@ -105,11 +119,15 @@ function ThresholdsSection({
|
||||
};
|
||||
|
||||
const discardAt = (index: number) => (): void => {
|
||||
// Discarding a row that was never saved removes it; otherwise just exit edit.
|
||||
// A never-saved row is removed; otherwise revert the live edits to the snapshot.
|
||||
if (index === unsavedIndex) {
|
||||
removeAt(index);
|
||||
return;
|
||||
}
|
||||
const original = editSnapshot.current;
|
||||
if (original) {
|
||||
onChange(thresholds.map((t, i) => (i === index ? original : t)));
|
||||
}
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
@@ -120,8 +138,9 @@ function ThresholdsSection({
|
||||
index,
|
||||
yAxisUnit,
|
||||
isEditing: editingIndex === index,
|
||||
onEdit: (): void => setEditingIndex(index),
|
||||
onEdit: (): void => beginEdit(index),
|
||||
onSave: saveAt(index),
|
||||
onLiveChange: updateAt(index),
|
||||
onDiscard: discardAt(index),
|
||||
onRemove: (): void => removeAt(index),
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user