mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 16:40:35 +01:00
Compare commits
16 Commits
issue-5340
...
ns/trace-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c28dd1b60e | ||
|
|
2cc123d34f | ||
|
|
fb1f6951e5 | ||
|
|
949d18f028 | ||
|
|
8180436432 | ||
|
|
ad243b88aa | ||
|
|
3369ed7172 | ||
|
|
a98b84c1cd | ||
|
|
4dda1e0ab5 | ||
|
|
749943abe4 | ||
|
|
4f51ee37ba | ||
|
|
d5617657b5 | ||
|
|
5600576722 | ||
|
|
f84b818552 | ||
|
|
4147c5c4bd | ||
|
|
e1cb822091 |
76
.github/CODEOWNERS
vendored
76
.github/CODEOWNERS
vendored
@@ -189,6 +189,82 @@ go.mod @therealpandey
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
## Logs
|
||||
/frontend/src/pages/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogControls/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
|
||||
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/logs/ @SigNoz/events-frontend
|
||||
|
||||
## Logs Pipelines
|
||||
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
|
||||
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
|
||||
|
||||
## Traces / Trace Explorer
|
||||
/frontend/src/pages/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/container/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Funnels
|
||||
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Details
|
||||
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/trace/ @SigNoz/events-frontend
|
||||
|
||||
## Exceptions
|
||||
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/container/AllError/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
|
||||
|
||||
## External APIs
|
||||
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
|
||||
|
||||
## Messaging Queues
|
||||
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
@@ -29,8 +29,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
pkgimplmetricreductionrule "github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -121,9 +119,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
|
||||
return pkgimplmetricreductionrule.NewModule()
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
@@ -47,7 +46,6 @@ import (
|
||||
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -184,9 +182,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
|
||||
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, licensing, ps, threads)
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
|
||||
},
|
||||
|
||||
@@ -647,14 +647,41 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
transactionGroups:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesPostableRotateToken:
|
||||
properties:
|
||||
refreshToken:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesPostableUser:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
frontendBaseUrl:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
- userRoles
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
- create
|
||||
@@ -703,6 +730,34 @@ components:
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesRoleWithTransactionGroups:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
transactionGroups:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
@@ -736,11 +791,35 @@ components:
|
||||
- relation
|
||||
- object
|
||||
type: object
|
||||
AuthtypesTransactionGroup:
|
||||
properties:
|
||||
objectGroup:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
relation:
|
||||
$ref: '#/components/schemas/AuthtypesRelation'
|
||||
required:
|
||||
- relation
|
||||
- objectGroup
|
||||
type: object
|
||||
AuthtypesTransactionGroups:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroup'
|
||||
type: array
|
||||
AuthtypesUpdatableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
type: object
|
||||
AuthtypesUpdatableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
transactionGroups:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesUserRole:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -5022,169 +5101,6 @@ components:
|
||||
required:
|
||||
- rules
|
||||
type: object
|
||||
MetricreductionruletypesAffectedAsset:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
impactedLabels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesAssetType'
|
||||
widget:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
- name
|
||||
- impactedLabels
|
||||
type: object
|
||||
MetricreductionruletypesAssetType:
|
||||
enum:
|
||||
- dashboard
|
||||
- alert_rule
|
||||
type: string
|
||||
MetricreductionruletypesGettableReductionRule:
|
||||
properties:
|
||||
active:
|
||||
type: boolean
|
||||
effectiveFrom:
|
||||
format: date-time
|
||||
type: string
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
matchType:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
reducedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- metricName
|
||||
- matchType
|
||||
- labels
|
||||
- effectiveFrom
|
||||
- updatedAt
|
||||
- updatedBy
|
||||
- active
|
||||
- ingestedSeries
|
||||
- reducedSeries
|
||||
- reductionPercent
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRulePreview:
|
||||
properties:
|
||||
affectedAssets:
|
||||
items:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesAffectedAsset'
|
||||
nullable: true
|
||||
type: array
|
||||
droppedLabels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
effectiveFrom:
|
||||
format: date-time
|
||||
type: string
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reducedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- ingestedSeries
|
||||
- reducedSeries
|
||||
- reductionPercent
|
||||
- droppedLabels
|
||||
- affectedAssets
|
||||
- effectiveFrom
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRules:
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
|
||||
nullable: true
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
required:
|
||||
- rules
|
||||
- total
|
||||
type: object
|
||||
MetricreductionruletypesMatchType:
|
||||
enum:
|
||||
- drop
|
||||
- keep
|
||||
type: string
|
||||
MetricreductionruletypesOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
MetricreductionruletypesPostableReductionRule:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
matchType:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
required:
|
||||
- matchType
|
||||
- labels
|
||||
type: object
|
||||
MetricreductionruletypesPostableReductionRulePreview:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
lookbackMs:
|
||||
format: int64
|
||||
type: integer
|
||||
matchType:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
required:
|
||||
- metricName
|
||||
- matchType
|
||||
- labels
|
||||
type: object
|
||||
MetricreductionruletypesReductionRuleOrderBy:
|
||||
enum:
|
||||
- metricname
|
||||
- ingestedvolume
|
||||
- reducedvolume
|
||||
- reduction
|
||||
- lastupdated
|
||||
type: string
|
||||
MetricsexplorertypesInspectMetricsRequest:
|
||||
properties:
|
||||
end:
|
||||
@@ -5301,10 +5217,6 @@ components:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
widgetId:
|
||||
type: string
|
||||
widgetName:
|
||||
@@ -10294,7 +10206,7 @@ paths:
|
||||
- global
|
||||
/api/v1/invite:
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint creates an invite for a user
|
||||
operationId: CreateInvite
|
||||
requestBody:
|
||||
@@ -10357,7 +10269,7 @@ paths:
|
||||
- users
|
||||
/api/v1/invite/bulk:
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint creates a bulk invite for a user
|
||||
operationId: CreateBulkInvite
|
||||
requestBody:
|
||||
@@ -10420,6 +10332,15 @@ paths:
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: q
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: isOverride
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -11225,7 +11146,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -11260,7 +11181,7 @@ paths:
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint patches a role
|
||||
operationId: PatchRole
|
||||
parameters:
|
||||
@@ -11321,6 +11242,68 @@ paths:
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a role
|
||||
operationId: UpdateRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableRole'
|
||||
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: Update role
|
||||
tags:
|
||||
- role
|
||||
/api/v1/roles/{id}/relations/{relation}/objects:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -11400,7 +11383,7 @@ paths:
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: Patches the objects connected to the specified role via a given
|
||||
relation type
|
||||
operationId: PatchObjects
|
||||
@@ -13127,7 +13110,7 @@ paths:
|
||||
- tracedetail
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint lists all users
|
||||
operationId: ListUsersDeprecated
|
||||
responses:
|
||||
@@ -13220,7 +13203,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint returns the user by id
|
||||
operationId: GetUserDeprecated
|
||||
parameters:
|
||||
@@ -13277,7 +13260,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint updates the user by id
|
||||
operationId: UpdateUserDeprecated
|
||||
parameters:
|
||||
@@ -13346,7 +13329,7 @@ paths:
|
||||
- users
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint returns the user I belong to
|
||||
operationId: GetMyUserDeprecated
|
||||
responses:
|
||||
@@ -16064,229 +16047,6 @@ paths:
|
||||
summary: Update metric metadata
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/reduction_rule:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Removes the volume-control (label reduction) rule for a specified
|
||||
metric, reverting it to full fidelity. Admin only; enterprise feature.
|
||||
operationId: DeleteMetricReductionRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"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:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the active volume-control (label reduction) rule for a
|
||||
specified metric. Enterprise feature.
|
||||
operationId: GetMetricReductionRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"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:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
put:
|
||||
deprecated: false
|
||||
description: Creates or updates the volume-control (label reduction) rule for
|
||||
a specified metric. The rule takes effect after a short activation delay.
|
||||
Admin only; enterprise feature.
|
||||
operationId: UpsertMetricReductionRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesPostableReductionRule'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"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:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create or update a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/inspect:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -16392,158 +16152,6 @@ paths:
|
||||
summary: Check if non-SigNoz metrics have been received
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/reduction_rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns active metric volume-control (label reduction) rules, sorted
|
||||
and paginated server-side. Enterprise feature.
|
||||
operationId: ListMetricReductionRules
|
||||
parameters:
|
||||
- in: query
|
||||
name: orderBy
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesReductionRuleOrderBy'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesOrder'
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRules'
|
||||
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
|
||||
"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:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List metric reduction rules
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/reduction_rules/preview:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Estimates the series reduction and related-asset impact of a candidate
|
||||
volume-control rule without persisting it. Enterprise feature.
|
||||
operationId: PreviewMetricReductionRule
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesPostableReductionRulePreview'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRulePreview'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"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:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Preview a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/stats:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -21137,6 +20745,68 @@ paths:
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a user for the organization
|
||||
operationId: CreateUser
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUser'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create user
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -179,13 +179,36 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Create(ctx, role)
|
||||
existingRole, err := provider.GetByOrgIDAndName(ctx, orgID, role.Name)
|
||||
if err != nil && !errors.Asc(err, authtypes.ErrCodeRoleNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return errors.Newf(errors.TypeAlreadyExists, authtypes.ErrCodeRoleAlreadyExists, "role with name: %s already exists", existingRole.Name)
|
||||
}
|
||||
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, tuples, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
@@ -213,6 +236,26 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, 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())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
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 {
|
||||
@@ -247,6 +290,36 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
|
||||
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 {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -286,7 +359,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -302,7 +375,12 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.Write(ctx, nil, tuples); err != nil {
|
||||
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
|
||||
}
|
||||
|
||||
@@ -361,7 +439,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
|
||||
return tuples
|
||||
}
|
||||
|
||||
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
@@ -371,26 +449,10 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
if len(tuples) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
|
||||
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
|
||||
if end > len(tuples) {
|
||||
end = len(tuples)
|
||||
}
|
||||
|
||||
err := provider.Write(ctx, nil, tuples[idx:end])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
timeseriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4TableName
|
||||
)
|
||||
|
||||
type clickhouse struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
threads int
|
||||
}
|
||||
|
||||
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
|
||||
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
|
||||
}
|
||||
|
||||
func (c *clickhouse) withThreads(ctx context.Context) context.Context {
|
||||
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
|
||||
}
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
|
||||
// floorToTimeSeriesBucket rounds the start down to the hour, since unix_milli is hour-bucketed.
|
||||
func floorToTimeSeriesBucket(ms int64) int64 {
|
||||
return ms - (ms % timeSeriesBucketMilli)
|
||||
}
|
||||
|
||||
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ib := sqlbuilder.NewInsertBuilder()
|
||||
ib.InsertInto(reductionRulesTable)
|
||||
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
|
||||
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
|
||||
|
||||
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) MetricExists(ctx context.Context, metricName string) (bool, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0")
|
||||
sb.From(metadataTable)
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var exists bool
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&exists); err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to check metric existence")
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) IsExponentialHistogram(ctx context.Context, metricName string) (bool, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0")
|
||||
sb.From(timeseriesTable)
|
||||
sb.Where(sb.E("metric_name", metricName), sb.E("type", metrictypes.ExpHistogramType.StringValue()))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var isExpHist bool
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&isExpHist); err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to check metric type")
|
||||
}
|
||||
return isExpHist, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("attr_name")
|
||||
sb.Distinct()
|
||||
sb.From(metadataTable)
|
||||
sb.Where(
|
||||
sb.E("metric_name", metricName),
|
||||
"NOT startsWith(attr_name, '__')",
|
||||
sb.GE("last_reported_unix_milli", startMs),
|
||||
sb.LE("first_reported_unix_milli", endMs),
|
||||
)
|
||||
|
||||
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 fetch metric attribute keys")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) tableExists(ctx context.Context, distributedTableName string) bool {
|
||||
var exists bool
|
||||
query := "SELECT count() > 0 FROM system.tables WHERE database = ? AND name = ?"
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, telemetrymetrics.DBName, distributedTableName).Scan(&exists); err != nil {
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
func (c *clickhouse) originalSeriesSource(ctx context.Context) (table string, originalOnly bool) {
|
||||
if c.tableExists(ctx, telemetrymetrics.TimeseriesV4BufferTableName) {
|
||||
return telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName, true
|
||||
}
|
||||
return telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4TableName, false
|
||||
}
|
||||
|
||||
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
table, originalOnly := c.originalSeriesSource(ctx)
|
||||
startMs = floorToTimeSeriesBucket(startMs)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
reducedExpr := "1"
|
||||
if len(keptLabels) > 0 {
|
||||
reducedExpr = "countDistinct(("
|
||||
for i, label := range keptLabels {
|
||||
if i > 0 {
|
||||
reducedExpr += ", "
|
||||
}
|
||||
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
|
||||
}
|
||||
reducedExpr += "))"
|
||||
}
|
||||
|
||||
sb.Select("countDistinct(fingerprint)", reducedExpr)
|
||||
sb.From(table)
|
||||
conds := []string{
|
||||
sb.E("metric_name", metricName),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if originalOnly {
|
||||
conds = append(conds, sb.E("is_reduced", false))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var current, reduced uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(¤t, &reduced); err != nil {
|
||||
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
|
||||
}
|
||||
if len(keptLabels) == 0 && current == 0 {
|
||||
reduced = 0
|
||||
}
|
||||
if reduced > current {
|
||||
reduced = current
|
||||
}
|
||||
return current, reduced, nil
|
||||
}
|
||||
|
||||
type volumeRow struct {
|
||||
MetricName string
|
||||
Ingested uint64
|
||||
Reduced uint64
|
||||
}
|
||||
|
||||
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, startMs, endMs int64) (map[string]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingestedTable, originalOnly := c.originalSeriesSource(ctx)
|
||||
ingested, err := c.countSeries(ctx, ingestedTable, originalOnly, metricNames, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reduced := ingested
|
||||
if c.tableExists(ctx, telemetrymetrics.TimeseriesV4ReducedTableName) {
|
||||
reduced, err = c.countSeries(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.TimeseriesV4ReducedTableName, false, metricNames, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[string]volumeRow, len(metricNames))
|
||||
for metricName, count := range ingested {
|
||||
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
|
||||
}
|
||||
for metricName, count := range reduced {
|
||||
row := out[metricName]
|
||||
row.MetricName = metricName
|
||||
row.Reduced = count
|
||||
out[metricName] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countSeries(ctx context.Context, table string, originalOnly bool, metricNames []string, startMs, endMs int64) (map[string]uint64, error) {
|
||||
startMs = floorToTimeSeriesBucket(startMs)
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "countDistinct(fingerprint)")
|
||||
sb.From(table)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if originalOnly {
|
||||
conds = append(conds, sb.E("is_reduced", false))
|
||||
}
|
||||
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 metric series")
|
||||
}
|
||||
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) RankByVolume(ctx context.Context, metricNames []string, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return []volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingestedTable, originalOnly := c.originalSeriesSource(ctx)
|
||||
reducedPresent := c.tableExists(ctx, telemetrymetrics.TimeseriesV4ReducedTableName)
|
||||
startMs = floorToTimeSeriesBucket(startMs)
|
||||
|
||||
orderExpr := "ingested"
|
||||
switch orderBy {
|
||||
case metricreductionruletypes.OrderByReducedVolume:
|
||||
orderExpr = "reduced"
|
||||
case metricreductionruletypes.OrderByReduction:
|
||||
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
|
||||
}
|
||||
direction := "ASC"
|
||||
if order == metricreductionruletypes.OrderDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedFilter := ""
|
||||
if originalOnly {
|
||||
ingestedFilter = "is_reduced = false AND "
|
||||
}
|
||||
reducedSelect := "ifNull(i.cnt, 0) AS reduced"
|
||||
if reducedPresent {
|
||||
reducedSelect = "ifNull(d.cnt, 0) AS reduced"
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", reducedSelect)
|
||||
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, countDistinct(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND "+ingestedFilter+"unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" GROUP BY metric_name) AS i",
|
||||
"base.metric_name = i.metric_name",
|
||||
)
|
||||
if reducedPresent {
|
||||
reducedTable := telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4ReducedTableName
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, countDistinct(fingerprint) AS cnt FROM "+reducedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" GROUP BY metric_name) AS d",
|
||||
"base.metric_name = d.metric_name",
|
||||
)
|
||||
}
|
||||
sb.OrderBy(orderExpr + " " + direction)
|
||||
if limit > 0 {
|
||||
sb.Limit(limit).Offset(offset)
|
||||
}
|
||||
|
||||
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 rank reduction rules by volume")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]volumeRow, 0, len(metricNames))
|
||||
for rows.Next() {
|
||||
var row volumeRow
|
||||
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
effectiveFromMargin = 5 * time.Minute
|
||||
defaultPreviewLookback = 24 * time.Hour
|
||||
)
|
||||
|
||||
var protectedLabels = map[string]struct{}{
|
||||
"le": {},
|
||||
"quantile": {},
|
||||
"__name__": {},
|
||||
"__temporality__": {},
|
||||
"deployment.environment": {},
|
||||
}
|
||||
|
||||
func isProtected(label string) bool {
|
||||
_, ok := protectedLabels[label]
|
||||
return ok
|
||||
}
|
||||
|
||||
type module struct {
|
||||
store metricreductionruletypes.Store
|
||||
ch *clickhouse
|
||||
dashboard dashboard.Module
|
||||
ruleStore ruletypes.RuleStore
|
||||
licensing licensing.Licensing
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
|
||||
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
|
||||
return &module{
|
||||
store: NewStore(sqlStore),
|
||||
ch: newClickhouse(telemetryStore, threads),
|
||||
dashboard: dashboardModule,
|
||||
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
|
||||
licensing: licensing,
|
||||
logger: scoped.Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) checkLicense(ctx context.Context, orgID valuer.UUID) error {
|
||||
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
if err := m.checkLicense(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
switch params.OrderBy {
|
||||
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
|
||||
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
|
||||
default:
|
||||
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
domainRules, total, err := m.store.List(ctx, orgID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(domainRules))
|
||||
for i, rule := range domainRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
}
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, 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]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
}
|
||||
|
||||
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(allRules))
|
||||
ruleByMetric := make(map[string]*metricreductionruletypes.StorableReductionRule, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
ruleByMetric[rule.MetricName] = rule
|
||||
}
|
||||
|
||||
ranked, err := m.ch.RankByVolume(ctx, metricNames, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
|
||||
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))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
}
|
||||
|
||||
func (m *module) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkLicense(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metricName == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
|
||||
rule, err := m.store.Get(ctx, orgID, metricName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
current, reduced, reductionPercent, _, err := m.estimateVolume(ctx, rule.MetricName, rule.MatchType, rule.Labels, now.Add(-defaultPreviewLookback).UnixMilli(), now.UnixMilli())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gettable := toGettableReductionRule(rule)
|
||||
gettable.IngestedSeries = current
|
||||
gettable.ReducedSeries = reduced
|
||||
gettable.ReductionPercent = reductionPercent
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) Upsert(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkLicense(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateMetricForReduction(ctx, req.MetricName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.MatchType == metricreductionruletypes.MatchTypeDrop {
|
||||
for _, label := range req.Labels {
|
||||
if isProtected(label) {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleProtectedLabel,
|
||||
"label %q is protected and cannot be dropped", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
|
||||
|
||||
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.Upsert(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettable := toGettableReductionRule(rule)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) Delete(ctx context.Context, orgID valuer.UUID, metricName string) error {
|
||||
if err := m.checkLicense(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
if metricName == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
|
||||
return m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.Delete(ctx, orgID, metricName); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, metricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
|
||||
if err := m.checkLicense(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateMetricForReduction(ctx, req.MetricName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lookback := time.Duration(req.LookbackMs) * time.Millisecond
|
||||
if lookback <= 0 {
|
||||
lookback = defaultPreviewLookback
|
||||
}
|
||||
now := time.Now()
|
||||
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, now.Add(-lookback).UnixMilli(), now.UnixMilli())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRulePreview{
|
||||
IngestedSeries: current,
|
||||
ReducedSeries: reduced,
|
||||
ReductionPercent: reductionPercent,
|
||||
DroppedLabels: dropped,
|
||||
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
|
||||
EffectiveFrom: now.Add(effectiveFromMargin),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) validateMetricForReduction(ctx context.Context, metricName string) error {
|
||||
exists, err := m.ch.MetricExists(ctx, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
|
||||
}
|
||||
|
||||
isExpHist, err := m.ch.IsExponentialHistogram(ctx, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isExpHist {
|
||||
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
|
||||
"exponential histogram metrics cannot be reduced in v1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
|
||||
affected := make([]metricreductionruletypes.AffectedAsset, 0)
|
||||
droppedSet := make(map[string]struct{}, len(dropped))
|
||||
for _, label := range dropped {
|
||||
droppedSet[label] = struct{}{}
|
||||
}
|
||||
|
||||
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
|
||||
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] {
|
||||
var groupBy []string
|
||||
if gb := item["group_by"]; gb != "" {
|
||||
groupBy = strings.Split(gb, ",")
|
||||
}
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeDashboard,
|
||||
ID: item["dashboard_id"],
|
||||
Name: item["dashboard_name"],
|
||||
Widget: item["widget_name"],
|
||||
ImpactedLabels: intersectLabels(groupBy, droppedSet),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
|
||||
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, a := range alerts {
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeAlert,
|
||||
ID: a.AlertID,
|
||||
Name: a.AlertName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return affected
|
||||
}
|
||||
|
||||
func toGettableReductionRule(rule *metricreductionruletypes.StorableReductionRule) metricreductionruletypes.GettableReductionRule {
|
||||
return metricreductionruletypes.GettableReductionRule{
|
||||
MetricName: rule.MetricName,
|
||||
MatchType: rule.MatchType,
|
||||
Labels: rule.Labels,
|
||||
EffectiveFrom: rule.EffectiveFrom,
|
||||
UpdatedAt: rule.UpdatedAt,
|
||||
UpdatedBy: rule.UpdatedBy,
|
||||
Active: !rule.EffectiveFrom.After(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
|
||||
rule.IngestedSeries = volume.Ingested
|
||||
rule.ReducedSeries = volume.Reduced
|
||||
if volume.Ingested > 0 && volume.Reduced <= volume.Ingested {
|
||||
rule.ReductionPercent = (1 - float64(volume.Reduced)/float64(volume.Ingested)) * 100
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
|
||||
var out []string
|
||||
for _, key := range keys {
|
||||
if _, ok := droppedSet[key]; ok {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
|
||||
ruleSet := make(map[string]struct{}, len(ruleLabels))
|
||||
for _, l := range ruleLabels {
|
||||
ruleSet[l] = struct{}{}
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if isProtected(k) {
|
||||
kept = append(kept, k)
|
||||
continue
|
||||
}
|
||||
_, listed := ruleSet[k]
|
||||
drop := listed
|
||||
if matchType == metricreductionruletypes.MatchTypeKeep {
|
||||
drop = !listed
|
||||
}
|
||||
if drop {
|
||||
dropped = append(dropped, k)
|
||||
} else {
|
||||
kept = append(kept, k)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(dropped)
|
||||
sort.Strings(kept)
|
||||
return dropped, kept
|
||||
}
|
||||
|
||||
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
|
||||
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, 0, nil, err
|
||||
}
|
||||
dropped, kept := resolveDroppedKept(matchType, labels, keys)
|
||||
|
||||
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, 0, nil, err
|
||||
}
|
||||
if current > 0 && reduced <= current {
|
||||
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
|
||||
}
|
||||
return current, reduced, reductionPercent, dropped, nil
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.StorableReductionRule, int, error) {
|
||||
column := "metric_name"
|
||||
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
|
||||
column = "updated_at"
|
||||
}
|
||||
direction := "ASC"
|
||||
if params.Order == metricreductionruletypes.OrderDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
rules := make([]*metricreductionruletypes.StorableReductionRule, 0)
|
||||
query := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID).
|
||||
Order(column + " " + direction)
|
||||
if params.Limit > 0 {
|
||||
query = query.Limit(params.Limit).Offset(params.Offset)
|
||||
}
|
||||
|
||||
total, err := query.ScanAndCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rules, total, nil
|
||||
}
|
||||
|
||||
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.StorableReductionRule, error) {
|
||||
rule := new(metricreductionruletypes.StorableReductionRule)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("metric_name = ?", metricName).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.StorableReductionRule) error {
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
On("CONFLICT (org_id, metric_name) DO UPDATE").
|
||||
Set("match_type = EXCLUDED.match_type").
|
||||
Set("labels = EXCLUDED.labels").
|
||||
Set("effective_from = EXCLUDED.effective_from").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Set("updated_by = EXCLUDED.updated_by").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) Delete(ctx context.Context, orgID valuer.UUID, metricName string) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*metricreductionruletypes.StorableReductionRule)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("metric_name = ?", metricName).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: dbPath,
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*metricreductionruletypes.StorableReductionRule)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_metric_reduction_rule_org_metric ON metric_reduction_rule (org_id, metric_name)`)
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func newRule(orgID valuer.UUID, metricName string, matchType metricreductionruletypes.MatchType, labels []string, by string) *metricreductionruletypes.StorableReductionRule {
|
||||
return metricreductionruletypes.NewReductionRule(orgID, metricName, matchType, labels, time.Now(), by)
|
||||
}
|
||||
|
||||
func TestStore_UpsertGetListDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newTestStore(t))
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
empty, _, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, empty)
|
||||
|
||||
require.NoError(t, s.Upsert(ctx, newRule(orgID, "http_requests_total", metricreductionruletypes.MatchTypeDrop, []string{"pod", "container"}, "creator@x.com")))
|
||||
|
||||
got, err := s.Get(ctx, orgID, "http_requests_total")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, metricreductionruletypes.MatchTypeDrop, got.MatchType)
|
||||
assert.Equal(t, []string{"pod", "container"}, []string(got.Labels))
|
||||
assert.Equal(t, "creator@x.com", got.CreatedBy)
|
||||
|
||||
list, _, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
}
|
||||
|
||||
func TestStore_UpsertReplacesAndPreservesCreator(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newTestStore(t))
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
require.NoError(t, s.Upsert(ctx, newRule(orgID, "cpu_usage", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "creator@x.com")))
|
||||
require.NoError(t, s.Upsert(ctx, newRule(orgID, "cpu_usage", metricreductionruletypes.MatchTypeKeep, []string{"le"}, "editor@x.com")))
|
||||
|
||||
list, _, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1, "upsert on the same (org, metric) replaces, it does not duplicate")
|
||||
|
||||
got, err := s.Get(ctx, orgID, "cpu_usage")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, metricreductionruletypes.MatchTypeKeep, got.MatchType)
|
||||
assert.Equal(t, []string{"le"}, []string(got.Labels))
|
||||
assert.Equal(t, "creator@x.com", got.CreatedBy, "created_by is preserved on update")
|
||||
assert.Equal(t, "editor@x.com", got.UpdatedBy, "updated_by reflects the latest editor")
|
||||
}
|
||||
|
||||
func TestStore_DeleteMissingRuleErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newTestStore(t))
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
require.NoError(t, s.Upsert(ctx, newRule(orgID, "mem_usage", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "creator@x.com")))
|
||||
require.NoError(t, s.Delete(ctx, orgID, "mem_usage"))
|
||||
|
||||
_, err := s.Get(ctx, orgID, "mem_usage")
|
||||
require.Error(t, err)
|
||||
|
||||
require.Error(t, s.Delete(ctx, orgID, "mem_usage"), "deleting a non-existent rule returns an error")
|
||||
}
|
||||
|
||||
func TestStore_ScopedByOrg(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newTestStore(t))
|
||||
orgA := valuer.GenerateUUID()
|
||||
orgB := valuer.GenerateUUID()
|
||||
|
||||
require.NoError(t, s.Upsert(ctx, newRule(orgA, "shared_metric", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "a@x.com")))
|
||||
|
||||
_, err := s.Get(ctx, orgB, "shared_metric")
|
||||
require.Error(t, err, "a rule in org A must not be visible to org B")
|
||||
|
||||
list, _, err := s.List(ctx, orgB, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list)
|
||||
}
|
||||
|
||||
func TestStore_ListSortsAndPaginates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newTestStore(t))
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
for _, name := range []string{"c_metric", "a_metric", "b_metric"} {
|
||||
require.NoError(t, s.Upsert(ctx, newRule(orgID, name, metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "x@x.com")))
|
||||
}
|
||||
|
||||
page, total, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{
|
||||
OrderBy: metricreductionruletypes.OrderByMetricName,
|
||||
Order: metricreductionruletypes.OrderAsc,
|
||||
Offset: 0,
|
||||
Limit: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, total, "total reflects all rows, not the page size")
|
||||
require.Len(t, page, 2)
|
||||
assert.Equal(t, "a_metric", page[0].MetricName)
|
||||
assert.Equal(t, "b_metric", page[1].MetricName)
|
||||
|
||||
page, _, err = s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{
|
||||
OrderBy: metricreductionruletypes.OrderByMetricName,
|
||||
Order: metricreductionruletypes.OrderDesc,
|
||||
Offset: 2,
|
||||
Limit: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page, 1)
|
||||
assert.Equal(t, "a_metric", page[0].MetricName, "desc order with offset 2 lands on the smallest name")
|
||||
}
|
||||
|
||||
func TestStore_RunInTxRollsBackOnError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newTestStore(t))
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
err := s.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := s.Upsert(ctx, newRule(orgID, "rolled_back", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "creator@x.com")); err != nil {
|
||||
return err
|
||||
}
|
||||
return assert.AnError
|
||||
})
|
||||
require.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
_, err = s.Get(ctx, orgID, "rolled_back")
|
||||
require.Error(t, err, "the upsert must not persist when the transaction callback fails")
|
||||
}
|
||||
@@ -98,6 +98,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
Active: aiObservability,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -48,13 +48,14 @@ const config: Config.InitialOptions = {
|
||||
],
|
||||
'^.+\\.(js|jsx)$': 'babel-jest',
|
||||
},
|
||||
// TODO: https://github.com/SigNoz/engineering-pod/issues/5334
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
|
||||
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
|
||||
// can be exercised in tests.
|
||||
if (!HTMLElement.prototype.hasPointerCapture) {
|
||||
HTMLElement.prototype.hasPointerCapture = function (): boolean {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
if (!HTMLElement.prototype.releasePointerCapture) {
|
||||
HTMLElement.prototype.releasePointerCapture = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.14",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.0.0",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
@@ -231,16 +231,17 @@
|
||||
"xml2js": "0.5.0",
|
||||
"phin": "^3.7.1",
|
||||
"body-parser": "1.20.3",
|
||||
"http-proxy-middleware": "4.0.0",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"cross-spawn": "7.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"serialize-javascript": "6.0.2",
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.4",
|
||||
"form-data": "4.0.6",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
85
frontend/pnpm-lock.yaml
generated
85
frontend/pnpm-lock.yaml
generated
@@ -12,16 +12,17 @@ overrides:
|
||||
xml2js: 0.5.0
|
||||
phin: ^3.7.1
|
||||
body-parser: 1.20.3
|
||||
http-proxy-middleware: 4.0.0
|
||||
http-proxy-middleware: 4.1.1
|
||||
cross-spawn: 7.0.5
|
||||
cookie: ^0.7.1
|
||||
serialize-javascript: 6.0.2
|
||||
prismjs: 1.30.0
|
||||
got: 11.8.5
|
||||
form-data: 4.0.4
|
||||
form-data: 4.0.6
|
||||
brace-expansion: ^2.0.2
|
||||
on-headers: ^1.1.0
|
||||
tmp: 0.2.4
|
||||
js-cookie: ^3.0.7
|
||||
tmp: 0.2.7
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
|
||||
importers:
|
||||
@@ -56,8 +57,8 @@ importers:
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2(react@18.2.0)
|
||||
'@grafana/data':
|
||||
specifier: ^11.6.14
|
||||
version: 11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
specifier: ^11.6.15
|
||||
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -164,8 +165,8 @@ importers:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
http-proxy-middleware:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -1636,14 +1637,14 @@ packages:
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
|
||||
'@grafana/data@11.6.14':
|
||||
resolution: {integrity: sha512-Nsjq1A9m6LbsKsKvOgvAk9Wq7RGjy0V4N9d5YsSnzMwCiw/ov2wblR2bcDpy95uF8KaDTIR2Gf40nJaOYksPMA==}
|
||||
'@grafana/data@11.6.15':
|
||||
resolution: {integrity: sha512-q2Zbjr0N9iEGY/zKHm4Z4X5x64806E17W58y7mnvwc0MlbyGPPVulcp/rWA2Nd190mZeafZQPer9u+MaO+0HUQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
|
||||
'@grafana/schema@11.6.14':
|
||||
resolution: {integrity: sha512-YTqgYekb7kiu5NEoQxKF8czJ6QIARmMkCi9cNcynHqYpcDLOv5pg5Q0QtKgiiqHjlYoEeCV6iejdB4hXxzB+VA==}
|
||||
'@grafana/schema@11.6.15':
|
||||
resolution: {integrity: sha512-MPIvGAp9uzkswnH6e+Fmzu+WBTqWMgbv93/8iu56gb+sjCB2LciZLz4KvrPFdw32bWCGSMAGqsML9mgmeJZtGQ==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
@@ -5167,8 +5168,8 @@ packages:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
format@0.2.2:
|
||||
@@ -5381,6 +5382,10 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-from-parse5@8.0.1:
|
||||
resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==}
|
||||
|
||||
@@ -5456,8 +5461,8 @@ packages:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-middleware@4.0.0:
|
||||
resolution: {integrity: sha512-wuHwaUtmC0XzJNHqRp41zXtt5ojpHbusXGhq6781VvnjWUYPu7opmOF3eomGNujT07kEOnHWZyV9UZzKimVCKA==}
|
||||
http-proxy-middleware@4.1.1:
|
||||
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
@@ -5467,8 +5472,8 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpxy@0.5.1:
|
||||
resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==}
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
@@ -6041,8 +6046,8 @@ packages:
|
||||
js-base64@3.7.5:
|
||||
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
|
||||
|
||||
js-cookie@2.2.1:
|
||||
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
||||
js-cookie@3.0.8:
|
||||
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
|
||||
|
||||
js-levenshtein@1.1.6:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
@@ -8394,8 +8399,8 @@ packages:
|
||||
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
tmp@0.2.4:
|
||||
resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==}
|
||||
tmp@0.2.7:
|
||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
tmpl@1.0.5:
|
||||
@@ -10318,10 +10323,10 @@ snapshots:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@grafana/data@11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
'@grafana/data@11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.0.1
|
||||
'@grafana/schema': 11.6.14
|
||||
'@grafana/schema': 11.6.15
|
||||
'@types/d3-interpolate': 3.0.1
|
||||
'@types/string-hash': 1.1.3
|
||||
d3-interpolate: 3.0.1
|
||||
@@ -10347,7 +10352,7 @@ snapshots:
|
||||
uplot: 1.6.31
|
||||
xss: 1.0.14
|
||||
|
||||
'@grafana/schema@11.6.14':
|
||||
'@grafana/schema@11.6.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -12886,7 +12891,7 @@ snapshots:
|
||||
axios@1.16.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.4
|
||||
form-data: 4.0.6
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -13833,7 +13838,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
|
||||
es-toolkit@1.46.1: {}
|
||||
|
||||
@@ -14031,7 +14036,7 @@ snapshots:
|
||||
dependencies:
|
||||
chardet: 0.7.0
|
||||
iconv-lite: 0.4.24
|
||||
tmp: 0.2.4
|
||||
tmp: 0.2.7
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
@@ -14164,12 +14169,12 @@ snapshots:
|
||||
cross-spawn: 7.0.5
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data@4.0.4:
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
format@0.2.2: {}
|
||||
@@ -14248,7 +14253,7 @@ snapshots:
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
@@ -14386,6 +14391,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-from-parse5@8.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -14506,10 +14515,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy-middleware@4.0.0:
|
||||
http-proxy-middleware@4.1.1:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@5.5.0)
|
||||
httpxy: 0.5.1
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
@@ -14525,7 +14534,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.1: {}
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
@@ -15339,7 +15348,7 @@ snapshots:
|
||||
|
||||
js-base64@3.7.5: {}
|
||||
|
||||
js-cookie@2.2.1: {}
|
||||
js-cookie@3.0.8: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
@@ -15367,7 +15376,7 @@ snapshots:
|
||||
decimal.js: 10.6.0
|
||||
domexception: 4.0.0
|
||||
escodegen: 2.1.0
|
||||
form-data: 4.0.4
|
||||
form-data: 4.0.6
|
||||
html-encoding-sniffer: 3.0.0
|
||||
http-proxy-agent: 5.0.0
|
||||
https-proxy-agent: 5.0.1
|
||||
@@ -17336,7 +17345,7 @@ snapshots:
|
||||
copy-to-clipboard: 3.3.3
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-shallow-equal: 1.0.0
|
||||
js-cookie: 2.2.1
|
||||
js-cookie: 3.0.8
|
||||
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -17355,7 +17364,7 @@ snapshots:
|
||||
copy-to-clipboard: 3.3.3
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-shallow-equal: 1.0.0
|
||||
js-cookie: 2.2.1
|
||||
js-cookie: 3.0.8
|
||||
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -18103,7 +18112,7 @@ snapshots:
|
||||
|
||||
tinypool@2.1.0: {}
|
||||
|
||||
tmp@0.2.4: {}
|
||||
tmp@0.2.7: {}
|
||||
|
||||
tmpl@1.0.5: {}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
),
|
||||
[pathname],
|
||||
);
|
||||
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
@@ -83,12 +82,36 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
}, [usersData?.data]);
|
||||
|
||||
// Handle old routes - redirect to new routes
|
||||
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
// TODO(H4ad): Remove this after https://github.com/SigNoz/engineering-pod/issues/5322
|
||||
// A mapped target may itself carry a query string (e.g. `/alerts?tab=Channels`).
|
||||
// react-router does not re-parse a `?` embedded in the `pathname` field, so split
|
||||
// it out and merge with the incoming search params.
|
||||
const [redirectPath, redirectSearch = ''] = redirectUrl.split('?');
|
||||
const mergedParams = new URLSearchParams(location.search);
|
||||
new URLSearchParams(redirectSearch).forEach((value, name) => {
|
||||
mergedParams.set(name, value);
|
||||
});
|
||||
const search = mergedParams.toString();
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: redirectUrl,
|
||||
pathname: redirectPath,
|
||||
search: search ? `?${search}` : '',
|
||||
hash: location.hash,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/settings/channels/edit/')) {
|
||||
const channelId = pathname.replace('/settings/channels/edit/', '');
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: `/alerts/channels/edit/${channelId}`,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
}}
|
||||
|
||||
@@ -73,7 +73,13 @@ const queryClient = new QueryClient({
|
||||
// Component to capture current location for assertions
|
||||
function LocationDisplay(): ReactElement {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location-display">{location.pathname}</div>;
|
||||
return (
|
||||
<>
|
||||
<div data-testid="location-display">{location.pathname}</div>
|
||||
<div data-testid="location-search">{location.search}</div>
|
||||
<div data-testid="location-hash">{location.hash}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to create mock user
|
||||
@@ -1475,12 +1481,10 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should not redirect VIEWER from /settings/channels/new due to route matching order (ALL_CHANNELS matches last)', () => {
|
||||
// Note: This tests the ACTUAL behavior of Private.tsx route matching
|
||||
// CHANNELS_NEW has path '/settings/channels/new' with permission ['ADMIN']
|
||||
// ALL_CHANNELS has path '/settings/channels' with permission ['ADMIN', 'EDITOR', 'VIEWER']
|
||||
// Due to non-exact matching and array order, ALL_CHANNELS matches LAST for '/settings/channels/new'
|
||||
// This is a known limitation - actual permission enforcement happens in the page component
|
||||
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
|
||||
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
|
||||
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
|
||||
// route to match last, so a VIEWER is now correctly redirected.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.CHANNELS_NEW,
|
||||
appContext: {
|
||||
@@ -1489,8 +1493,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started route', () => {
|
||||
@@ -1548,4 +1551,60 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old channel route redirects', () => {
|
||||
it.each([
|
||||
['/settings/channels', '/alerts', 'tab=Channels'],
|
||||
['/settings/channels/new', '/alerts/channels/new', ''],
|
||||
])(
|
||||
'should redirect %s to %s',
|
||||
async (oldRoute, expectedPath, expectedSearch) => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: oldRoute,
|
||||
appContext: { isLoggedIn: true },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
expectedPath,
|
||||
);
|
||||
});
|
||||
|
||||
if (expectedSearch) {
|
||||
const search = screen.getByTestId('location-search').textContent ?? '';
|
||||
const params = new URLSearchParams(search);
|
||||
new URLSearchParams(expectedSearch).forEach((value, name) => {
|
||||
expect(params.get(name)).toBe(value);
|
||||
});
|
||||
} else {
|
||||
expect(screen.getByTestId('location-search')).toHaveTextContent('');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('should redirect dynamic channel edit route preserving the channel id', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: '/settings/channels/edit/abc123',
|
||||
appContext: { isLoggedIn: true },
|
||||
});
|
||||
|
||||
await assertRedirectsTo('/alerts/channels/edit/abc123');
|
||||
});
|
||||
|
||||
it('should merge incoming query params with the embedded query of the target', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: '/settings/channels?foo=bar',
|
||||
appContext: { isLoggedIn: true },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent('/alerts');
|
||||
});
|
||||
|
||||
const search = screen.getByTestId('location-search').textContent ?? '';
|
||||
const params = new URLSearchParams(search);
|
||||
expect(params.get('tab')).toBe('Channels');
|
||||
expect(params.get('foo')).toBe('bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,13 @@ export const DashboardWidget = Loadable(
|
||||
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
|
||||
);
|
||||
|
||||
export const DashboardPanelEditorPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const EditRulesPage = Loadable(
|
||||
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
|
||||
);
|
||||
@@ -142,12 +149,12 @@ export const AlertOverview = Loadable(
|
||||
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
|
||||
);
|
||||
|
||||
export const CreateAlertChannelAlerts = Loadable(
|
||||
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
|
||||
export const ChannelsNew = Loadable(
|
||||
() => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertList'),
|
||||
);
|
||||
|
||||
export const AllAlertChannels = Loadable(
|
||||
() => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'),
|
||||
export const ChannelsEdit = Loadable(
|
||||
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/AlertList'),
|
||||
);
|
||||
|
||||
export const AllErrors = Loadable(
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AllAlertChannels,
|
||||
AllErrors,
|
||||
ApiMonitoring,
|
||||
CreateAlertChannelAlerts,
|
||||
ChannelsEdit,
|
||||
ChannelsNew,
|
||||
CreateNewAlerts,
|
||||
DashboardPage,
|
||||
DashboardPanelEditorPage,
|
||||
DashboardsListPage,
|
||||
DashboardWidget,
|
||||
EditRulesPage,
|
||||
@@ -196,6 +197,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_WIDGET',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR,
|
||||
exact: true,
|
||||
component: DashboardPanelEditorPage,
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_PANEL_EDITOR',
|
||||
},
|
||||
{
|
||||
path: ROUTES.EDIT_ALERTS,
|
||||
exact: true,
|
||||
@@ -269,16 +277,16 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.CHANNELS_NEW,
|
||||
exact: true,
|
||||
component: CreateAlertChannelAlerts,
|
||||
component: ChannelsNew,
|
||||
isPrivate: true,
|
||||
key: 'CHANNELS_NEW',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALL_CHANNELS,
|
||||
path: ROUTES.CHANNELS_EDIT,
|
||||
exact: true,
|
||||
component: AllAlertChannels,
|
||||
component: ChannelsEdit,
|
||||
isPrivate: true,
|
||||
key: 'ALL_CHANNELS',
|
||||
key: 'CHANNELS_EDIT',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALL_ERROR,
|
||||
@@ -469,13 +477,6 @@ const routes: AppRoutes[] = [
|
||||
key: 'METRICS_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
exact: true,
|
||||
component: MetricsExplorer,
|
||||
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
@@ -541,6 +542,9 @@ export const oldNewRoutesMapping: Record<string, string> = {
|
||||
'/messaging-queues': '/messaging-queues/overview',
|
||||
'/alerts/edit': '/alerts/overview',
|
||||
'/alerts/type-selection': '/alerts/new',
|
||||
// TODO(H4ad): Update this after https://github.com/SigNoz/engineering-pod/issues/5322
|
||||
'/settings/channels': '/alerts?tab=Channels',
|
||||
'/settings/channels/new': '/alerts/channels/new',
|
||||
};
|
||||
export const oldRoutes = Object.keys(oldNewRoutesMapping);
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
DeleteMetricReductionRulePathParameters,
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsPathParameters,
|
||||
GetMetricAttributes200,
|
||||
@@ -30,27 +29,18 @@ import type {
|
||||
GetMetricHighlightsPathParameters,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataPathParameters,
|
||||
GetMetricReductionRule200,
|
||||
GetMetricReductionRulePathParameters,
|
||||
GetMetricsOnboardingStatus200,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
InspectMetrics200,
|
||||
ListMetricReductionRules200,
|
||||
ListMetricReductionRulesParams,
|
||||
ListMetrics200,
|
||||
ListMetricsParams,
|
||||
MetricreductionruletypesPostableReductionRuleDTO,
|
||||
MetricreductionruletypesPostableReductionRulePreviewDTO,
|
||||
MetricsexplorertypesInspectMetricsRequestDTO,
|
||||
MetricsexplorertypesStatsRequestDTO,
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
PreviewMetricReductionRule200,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateMetricMetadataPathParameters,
|
||||
UpsertMetricReductionRule200,
|
||||
UpsertMetricReductionRulePathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -781,292 +771,6 @@ export const useUpdateMetricMetadata = <
|
||||
> => {
|
||||
return useMutation(getUpdateMetricMetadataMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes the volume-control (label reduction) rule for a specified metric, reverting it to full fidelity. Admin only; enterprise feature.
|
||||
* @summary Delete a metric reduction rule
|
||||
*/
|
||||
export const deleteMetricReductionRule = (
|
||||
{ metricName }: DeleteMetricReductionRulePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/${metricName}/reduction_rule`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRulePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRulePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteMetricReductionRule'];
|
||||
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 deleteMetricReductionRule>>,
|
||||
{ pathParams: DeleteMetricReductionRulePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteMetricReductionRule(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRule>>
|
||||
>;
|
||||
|
||||
export type DeleteMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a metric reduction rule
|
||||
*/
|
||||
export const useDeleteMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRulePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRulePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the active volume-control (label reduction) rule for a specified metric. Enterprise feature.
|
||||
* @summary Get a metric reduction rule
|
||||
*/
|
||||
export const getMetricReductionRule = (
|
||||
{ metricName }: GetMetricReductionRulePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRule200>({
|
||||
url: `/api/v2/metrics/${metricName}/reduction_rule`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricReductionRulePathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/reduction_rule`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRule>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricReductionRulePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRule>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRule>>
|
||||
> = ({ signal }) => getMetricReductionRule({ metricName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRule>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRule>>
|
||||
>;
|
||||
export type GetMetricReductionRuleQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a metric reduction rule
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRule<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRule>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricReductionRulePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRule>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a metric reduction rule
|
||||
*/
|
||||
export const invalidateGetMetricReductionRule = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricReductionRulePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleQueryKey({ metricName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates or updates the volume-control (label reduction) rule for a specified metric. The rule takes effect after a short activation delay. Admin only; enterprise feature.
|
||||
* @summary Create or update a metric reduction rule
|
||||
*/
|
||||
export const upsertMetricReductionRule = (
|
||||
{ metricName }: UpsertMetricReductionRulePathParameters,
|
||||
metricreductionruletypesPostableReductionRuleDTO?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpsertMetricReductionRule200>({
|
||||
url: `/api/v2/metrics/${metricName}/reduction_rule`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesPostableReductionRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpsertMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpsertMetricReductionRulePathParameters;
|
||||
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpsertMetricReductionRulePathParameters;
|
||||
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['upsertMetricReductionRule'];
|
||||
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 upsertMetricReductionRule>>,
|
||||
{
|
||||
pathParams: UpsertMetricReductionRulePathParameters;
|
||||
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return upsertMetricReductionRule(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpsertMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof upsertMetricReductionRule>>
|
||||
>;
|
||||
export type UpsertMetricReductionRuleMutationBody =
|
||||
| BodyType<MetricreductionruletypesPostableReductionRuleDTO>
|
||||
| undefined;
|
||||
export type UpsertMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create or update a metric reduction rule
|
||||
*/
|
||||
export const useUpsertMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpsertMetricReductionRulePathParameters;
|
||||
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpsertMetricReductionRulePathParameters;
|
||||
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpsertMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
* @summary Inspect raw metric data points
|
||||
@@ -1236,192 +940,6 @@ export const invalidateGetMetricsOnboardingStatus = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns active metric volume-control (label reduction) rules, sorted and paginated server-side. Enterprise feature.
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
export const listMetricReductionRules = (
|
||||
params?: ListMetricReductionRulesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListMetricReductionRules200>({
|
||||
url: `/api/v2/metrics/reduction_rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListMetricReductionRulesQueryKey = (
|
||||
params?: ListMetricReductionRulesParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/metrics/reduction_rules`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListMetricReductionRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListMetricReductionRulesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>
|
||||
> = ({ signal }) => listMetricReductionRules(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListMetricReductionRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>
|
||||
>;
|
||||
export type ListMetricReductionRulesQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
|
||||
export function useListMetricReductionRules<
|
||||
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListMetricReductionRulesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
export const invalidateListMetricReductionRules = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListMetricReductionRulesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it. Enterprise feature.
|
||||
* @summary Preview a metric reduction rule
|
||||
*/
|
||||
export const previewMetricReductionRule = (
|
||||
metricreductionruletypesPostableReductionRulePreviewDTO?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PreviewMetricReductionRule200>({
|
||||
url: `/api/v2/metrics/reduction_rules/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesPostableReductionRulePreviewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['previewMetricReductionRule'];
|
||||
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 previewMetricReductionRule>>,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return previewMetricReductionRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PreviewMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>
|
||||
>;
|
||||
export type PreviewMetricReductionRuleMutationBody =
|
||||
| BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>
|
||||
| undefined;
|
||||
export type PreviewMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Preview a metric reduction rule
|
||||
*/
|
||||
export const usePreviewMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPreviewMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint provides list of metrics with their number of samples and timeseries for the given time range
|
||||
* @summary Get metrics statistics
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
import type {
|
||||
AuthtypesPatchableRoleDTO,
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
CoretypesPatchableObjectsDTO,
|
||||
CreateRole201,
|
||||
DeleteRolePathParameters,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
PatchObjectsPathParameters,
|
||||
PatchRolePathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateRolePathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -365,6 +367,7 @@ export const invalidateGetRole = async (
|
||||
|
||||
/**
|
||||
* This endpoint patches a role
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const patchRole = (
|
||||
@@ -436,6 +439,7 @@ export type PatchRoleMutationBody =
|
||||
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const usePatchRole = <
|
||||
@@ -462,6 +466,105 @@ export const usePatchRole = <
|
||||
> => {
|
||||
return useMutation(getPatchRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a role
|
||||
* @summary Update role
|
||||
*/
|
||||
export const updateRole = (
|
||||
{ id }: UpdateRolePathParameters,
|
||||
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesUpdatableRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateRole'];
|
||||
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 updateRole>>,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateRole>>
|
||||
>;
|
||||
export type UpdateRoleMutationBody =
|
||||
| BodyType<AuthtypesUpdatableRoleDTO>
|
||||
| undefined;
|
||||
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update role
|
||||
*/
|
||||
export const useUpdateRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
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
|
||||
@@ -565,6 +668,7 @@ export const invalidateGetObjects = async (
|
||||
|
||||
/**
|
||||
* 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 = (
|
||||
@@ -636,6 +740,7 @@ export type PatchObjectsMutationBody =
|
||||
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const usePatchObjects = <
|
||||
|
||||
@@ -2224,15 +2224,31 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CoretypesObjectGroupDTO {
|
||||
resource: CoretypesResourceRefDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectors: string[];
|
||||
}
|
||||
|
||||
export interface AuthtypesTransactionGroupDTO {
|
||||
objectGroup: CoretypesObjectGroupDTO;
|
||||
relation: AuthtypesRelationDTO;
|
||||
}
|
||||
|
||||
export type AuthtypesTransactionGroupsDTO = AuthtypesTransactionGroupDTO[];
|
||||
|
||||
export interface AuthtypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRotateTokenDTO {
|
||||
@@ -2242,6 +2258,32 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frontendBaseUrl?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles: AuthtypesPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2275,6 +2317,40 @@ export interface AuthtypesRoleDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleWithTransactionGroupsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesSessionContextDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -2295,6 +2371,14 @@ export interface AuthtypesUpdatableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdatableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3065,14 +3149,6 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export interface CoretypesObjectGroupDTO {
|
||||
resource: CoretypesResourceRefDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectors: string[];
|
||||
}
|
||||
|
||||
export interface CoretypesPatchableObjectsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -6577,157 +6653,6 @@ export interface LlmpricingruletypesUpdatableLLMPricingRulesDTO {
|
||||
rules: LlmpricingruletypesUpdatableLLMPricingRuleDTO[] | null;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesAssetTypeDTO {
|
||||
dashboard = 'dashboard',
|
||||
alert_rule = 'alert_rule',
|
||||
}
|
||||
export interface MetricreductionruletypesAffectedAssetDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
impactedLabels: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
type: MetricreductionruletypesAssetTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
widget?: string;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesMatchTypeDTO {
|
||||
drop = 'drop',
|
||||
keep = 'keep',
|
||||
}
|
||||
export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
active: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
effectiveFrom: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSeries: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
labels: string[] | null;
|
||||
matchType: MetricreductionruletypesMatchTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reducedSeries: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
reductionPercent: number;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface MetricreductionruletypesGettableReductionRulePreviewDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
affectedAssets: MetricreductionruletypesAffectedAssetDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
droppedLabels: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
effectiveFrom: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSeries: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reducedSeries: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
reductionPercent: number;
|
||||
}
|
||||
|
||||
export interface MetricreductionruletypesGettableReductionRulesDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
rules: MetricreductionruletypesGettableReductionRuleDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export interface MetricreductionruletypesPostableReductionRuleDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
labels: string[] | null;
|
||||
matchType: MetricreductionruletypesMatchTypeDTO;
|
||||
}
|
||||
|
||||
export interface MetricreductionruletypesPostableReductionRulePreviewDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
labels: string[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
lookbackMs?: number;
|
||||
matchType: MetricreductionruletypesMatchTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
metricName: string;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesReductionRuleOrderByDTO {
|
||||
metricname = 'metricname',
|
||||
ingestedvolume = 'ingestedvolume',
|
||||
reducedvolume = 'reducedvolume',
|
||||
reduction = 'reduction',
|
||||
lastupdated = 'lastupdated',
|
||||
}
|
||||
export interface MetricsexplorertypesInspectMetricsRequestDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -6889,10 +6814,6 @@ export interface MetricsexplorertypesMetricDashboardDTO {
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9605,6 +9526,16 @@ export type ListLLMPricingRulesParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
q?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
isOverride?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListLLMPricingRules200 = {
|
||||
@@ -9714,7 +9645,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleDTO;
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9724,6 +9655,9 @@ export type GetRole200 = {
|
||||
export type PatchRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
@@ -10471,31 +10405,6 @@ export type GetMetricMetadata200 = {
|
||||
export type UpdateMetricMetadataPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type DeleteMetricReductionRulePathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricReductionRulePathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricReductionRule200 = {
|
||||
data: MetricreductionruletypesGettableReductionRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpsertMetricReductionRulePathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type UpsertMetricReductionRule200 = {
|
||||
data: MetricreductionruletypesGettableReductionRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
/**
|
||||
@@ -10512,43 +10421,6 @@ export type GetMetricsOnboardingStatus200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListMetricReductionRulesParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
orderBy?: MetricreductionruletypesReductionRuleOrderByDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: MetricreductionruletypesOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ListMetricReductionRules200 = {
|
||||
data: MetricreductionruletypesGettableReductionRulesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PreviewMetricReductionRule200 = {
|
||||
data: MetricreductionruletypesGettableReductionRulePreviewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricsStats200 = {
|
||||
data: MetricsexplorertypesStatsResponseDTO;
|
||||
/**
|
||||
@@ -10961,6 +10833,14 @@ export type ListUsers200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUser201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -18,9 +18,11 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
DeleteUserPathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
@@ -169,6 +171,7 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const createInvite = (
|
||||
@@ -230,6 +233,7 @@ export type CreateInviteMutationBody =
|
||||
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const useCreateInvite = <
|
||||
@@ -252,6 +256,7 @@ export const useCreateInvite = <
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a bulk invite for a user
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const createBulkInvite = (
|
||||
@@ -313,6 +318,7 @@ export type CreateBulkInviteMutationBody =
|
||||
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const useCreateBulkInvite = <
|
||||
@@ -418,6 +424,7 @@ export const useResetPassword = <
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all users
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const listUsersDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -463,6 +470,7 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
|
||||
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
|
||||
@@ -486,6 +494,7 @@ export function useListUsersDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const invalidateListUsersDeprecated = async (
|
||||
@@ -581,6 +590,7 @@ export const useDeleteUser = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const getUserDeprecated = (
|
||||
@@ -640,6 +650,7 @@ export type GetUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
|
||||
@@ -666,6 +677,7 @@ export function useGetUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const invalidateGetUserDeprecated = async (
|
||||
@@ -683,6 +695,7 @@ export const invalidateGetUserDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint updates the user by id
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const updateUserDeprecated = (
|
||||
@@ -755,6 +768,7 @@ export type UpdateUserDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const useUpdateUserDeprecated = <
|
||||
@@ -783,6 +797,7 @@ export const useUpdateUserDeprecated = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user I belong to
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const getMyUserDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -828,6 +843,7 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
|
||||
@@ -851,6 +867,7 @@ export function useGetMyUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const invalidateGetMyUserDeprecated = async (
|
||||
@@ -1209,6 +1226,89 @@ export const invalidateListUsers = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a user for the organization
|
||||
* @summary Create user
|
||||
*/
|
||||
export const createUser = (
|
||||
authtypesPostableUserDTO?: BodyType<AuthtypesPostableUserDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUser201>({
|
||||
url: `/api/v2/users`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUser'];
|
||||
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 createUser>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUser(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUser>>
|
||||
>;
|
||||
export type CreateUserMutationBody =
|
||||
| BodyType<AuthtypesPostableUserDTO>
|
||||
| undefined;
|
||||
export type CreateUserMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user
|
||||
*/
|
||||
export const useCreateUser = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @summary Get user by user id
|
||||
|
||||
@@ -274,4 +274,110 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('clickhouse_sql scalar keeps each value column distinct (regression: all-"A" collapse)', () => {
|
||||
const scalar: ScalarData = {
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'group',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'current_availability',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'aggregation',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'error_budget_remaining',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 1,
|
||||
columnType: 'aggregation',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'budget_status',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 2,
|
||||
columnType: 'group',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'total_requests',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 4,
|
||||
columnType: 'aggregation',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
],
|
||||
data: [['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
|
||||
};
|
||||
|
||||
const v5Data: QueryRangeResponseV5 = {
|
||||
type: 'scalar',
|
||||
data: { results: [scalar] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
};
|
||||
|
||||
// A clickhouse_sql envelope contributes no aggregation metadata.
|
||||
const params = makeBaseParams('scalar', [
|
||||
{
|
||||
type: 'clickhouse_sql',
|
||||
spec: {
|
||||
name: 'A',
|
||||
query: 'SELECT ...',
|
||||
disabled: false,
|
||||
},
|
||||
} as unknown as QueryRangeRequestV5['compositeQuery']['queries'][number],
|
||||
]);
|
||||
|
||||
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
|
||||
makeBaseSuccess({ data: v5Data }, params);
|
||||
// formatForWeb=true is the table-panel path.
|
||||
const result = convertV5ResponseToLegacy(input, { A: '' }, true);
|
||||
|
||||
const [tableEntry] = result.payload.data.result;
|
||||
// Headers keep their real names instead of collapsing to "A".
|
||||
expect(tableEntry.table?.columns).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{
|
||||
name: 'current_availability',
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
id: 'current_availability',
|
||||
},
|
||||
{
|
||||
name: 'error_budget_remaining',
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
id: 'error_budget_remaining',
|
||||
},
|
||||
{
|
||||
name: 'budget_status',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'budget_status',
|
||||
},
|
||||
{
|
||||
name: 'total_requests',
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
id: 'total_requests',
|
||||
},
|
||||
]);
|
||||
// Ids are unique, so value columns don't overwrite each other in the row.
|
||||
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
|
||||
data: {
|
||||
'service.name': 'kuja-api_gateway-service',
|
||||
current_availability: 99.985,
|
||||
error_budget_remaining: 0.985,
|
||||
budget_status: 'Healthy ✅',
|
||||
total_requests: 2181216,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
@@ -39,16 +40,32 @@ function getColName(
|
||||
return alias || expression || col.queryName;
|
||||
}
|
||||
|
||||
// clickhouse_sql value columns carry their real SQL alias in col.name — use
|
||||
// it so each value column keeps its own header instead of collapsing onto
|
||||
// the query name. Formulas/promql use placeholder names, so they fall back
|
||||
// to legend || queryName.
|
||||
if (clickhouseQueryNames.has(col.queryName)) {
|
||||
return col.name;
|
||||
}
|
||||
return legend || col.queryName;
|
||||
}
|
||||
|
||||
function getColId(
|
||||
col: ScalarData['columns'][number],
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
|
||||
// value columns stay unique instead of all collapsing onto the query name
|
||||
// (which would overwrite every cell in the row with the last column's value).
|
||||
if (clickhouseQueryNames.has(col.queryName)) {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const aggregation =
|
||||
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
|
||||
const expression = aggregation?.expression || '';
|
||||
@@ -141,6 +158,7 @@ function convertScalarDataArrayToTable(
|
||||
scalarDataArray: ScalarData[],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): QueryDataV3[] {
|
||||
// If no scalar data, return empty structure
|
||||
|
||||
@@ -166,10 +184,10 @@ function convertScalarDataArrayToTable(
|
||||
|
||||
// Collect columns for this specific query
|
||||
const columns = scalarData?.columns?.map((col) => ({
|
||||
name: getColName(col, legendMap, aggregationPerQuery),
|
||||
name: getColName(col, legendMap, aggregationPerQuery, clickhouseQueryNames),
|
||||
queryName: col.queryName,
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationPerQuery),
|
||||
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
|
||||
}));
|
||||
|
||||
// Process rows for this specific query
|
||||
@@ -177,8 +195,13 @@ function convertScalarDataArrayToTable(
|
||||
const rowData: Record<string, any> = {};
|
||||
|
||||
scalarData?.columns?.forEach((col, colIndex) => {
|
||||
const columnName = getColName(col, legendMap, aggregationPerQuery);
|
||||
const columnId = getColId(col, aggregationPerQuery);
|
||||
const columnName = getColName(
|
||||
col,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
const columnId = getColId(col, aggregationPerQuery, clickhouseQueryNames);
|
||||
rowData[columnId || columnName] = dataRow[colIndex];
|
||||
});
|
||||
|
||||
@@ -202,6 +225,7 @@ function convertScalarWithFormatForWeb(
|
||||
scalarDataArray: ScalarData[],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): QueryDataV3[] {
|
||||
if (!scalarDataArray || scalarDataArray.length === 0) {
|
||||
return [];
|
||||
@@ -210,13 +234,18 @@ function convertScalarWithFormatForWeb(
|
||||
return scalarDataArray.map((scalarData) => {
|
||||
const columns =
|
||||
scalarData.columns?.map((col) => {
|
||||
const colName = getColName(col, legendMap, aggregationPerQuery);
|
||||
const colName = getColName(
|
||||
col,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
|
||||
return {
|
||||
name: colName,
|
||||
queryName: col.queryName,
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationPerQuery),
|
||||
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
|
||||
};
|
||||
}) || [];
|
||||
|
||||
@@ -289,6 +318,7 @@ function convertV5DataByType(
|
||||
v5Data: any,
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): MetricRangePayloadV3['data'] {
|
||||
switch (v5Data?.type) {
|
||||
case 'time_series': {
|
||||
@@ -307,6 +337,7 @@ function convertV5DataByType(
|
||||
scalarData,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
return {
|
||||
resultType: 'scalar',
|
||||
@@ -373,6 +404,15 @@ export function convertV5ResponseToLegacy(
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
|
||||
// clickhouse_sql queries have no aggregation metadata; their value columns
|
||||
// are named/keyed by the real SQL alias the response carries (see getColId).
|
||||
const clickhouseQueryNames = new Set<string>(
|
||||
(params?.compositeQuery?.queries ?? [])
|
||||
.filter((query) => query.type === 'clickhouse_sql')
|
||||
.map((query) => (query.spec as { name?: string })?.name)
|
||||
.filter((name): name is string => !!name),
|
||||
);
|
||||
|
||||
// If formatForWeb is true, return as-is (like existing logic)
|
||||
if (formatForWeb && v5Data?.type === 'scalar') {
|
||||
const scalarData = v5Data.data.results as ScalarData[];
|
||||
@@ -380,6 +420,7 @@ export function convertV5ResponseToLegacy(
|
||||
scalarData,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -402,6 +443,7 @@ export function convertV5ResponseToLegacy(
|
||||
v5Data,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
|
||||
// Create legacy-compatible response structure
|
||||
|
||||
@@ -12,4 +12,5 @@ export enum FeatureKeys {
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
}
|
||||
|
||||
@@ -43,4 +43,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
}
|
||||
|
||||
@@ -24,14 +24,16 @@ const ROUTES = {
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
DASHBOARD: '/dashboard/:dashboardId',
|
||||
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
|
||||
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
|
||||
EDIT_ALERTS: '/alerts/edit',
|
||||
LIST_ALL_ALERT: '/alerts',
|
||||
ALERTS_NEW: '/alerts/new',
|
||||
ALERT_HISTORY: '/alerts/history',
|
||||
ALERT_OVERVIEW: '/alerts/overview',
|
||||
ALL_CHANNELS: '/settings/channels',
|
||||
CHANNELS_NEW: '/settings/channels/new',
|
||||
CHANNELS_EDIT: '/settings/channels/edit/:channelId',
|
||||
// TODO(H4ad): Add test to forbidden ? in this map after https://github.com/SigNoz/engineering-pod/issues/5322
|
||||
ALL_CHANNELS: '/alerts?tab=Channels',
|
||||
CHANNELS_NEW: '/alerts/channels/new',
|
||||
CHANNELS_EDIT: '/alerts/channels/edit/:channelId',
|
||||
ALL_ERROR: '/exceptions',
|
||||
ERROR_DETAIL: '/error-detail',
|
||||
VERSION: '/status',
|
||||
@@ -77,7 +79,6 @@ const ROUTES = {
|
||||
METRICS_EXPLORER: '/metrics-explorer/summary',
|
||||
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
|
||||
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
|
||||
API_MONITORING_BASE: '/api-monitoring',
|
||||
API_MONITORING: '/api-monitoring/explorer',
|
||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('resourceRoute', () => {
|
||||
|
||||
it('routes channels to the edit page', () => {
|
||||
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
|
||||
'/settings/channels/edit/channel-uuid-1',
|
||||
'/alerts/channels/edit/channel-uuid-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.alert-channels-container {
|
||||
width: 90%;
|
||||
margin: 12px auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -408,6 +408,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
|
||||
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
|
||||
// like the onboarding and public-dashboard screens.
|
||||
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
@@ -418,7 +421,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard;
|
||||
isPublicDashboard ||
|
||||
isPanelEditorV2;
|
||||
|
||||
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
.create-alert-channels-container {
|
||||
width: 90%;
|
||||
margin: 12px auto;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-wrapper {
|
||||
// The inline height is the legend rectangle from calculateChartDimensions;
|
||||
// border-box keeps the padding inside it so the wrapper doesn't grow past
|
||||
// that height and steal space from the chart. overflow:hidden clips to the
|
||||
// rectangle so the virtualized legend scrolls within it.
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,11 +516,6 @@
|
||||
--tooltip-z-index: 1000;
|
||||
}
|
||||
|
||||
// Lift the volume-control config drawer above the MetricDetails drawer (z-index 1000).
|
||||
.volume-control-config-drawer {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
@keyframes fade-in-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -21,7 +21,6 @@ import AllAttributes from './AllAttributes';
|
||||
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
|
||||
import Highlights from './Highlights';
|
||||
import Metadata from './Metadata';
|
||||
import VolumeControlSection from './VolumeControl/VolumeControlSection';
|
||||
import { MetricDetailsProps } from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
@@ -191,7 +190,6 @@ function MetricDetails({
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
refetchMetricMetadata={refetchMetricMetadata}
|
||||
/>
|
||||
<VolumeControlSection metricName={metricName} />
|
||||
<AllAttributes
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spin } from 'antd';
|
||||
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { formatCompact } from './configUtils';
|
||||
import { RuleMode } from './types';
|
||||
import styles from './VolumeControlConfig.module.scss';
|
||||
|
||||
interface ImpactPanelProps {
|
||||
mode: RuleMode;
|
||||
preview?: MetricreductionruletypesGettableReductionRulePreviewDTO;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ImpactPanel({
|
||||
mode,
|
||||
preview,
|
||||
isLoading,
|
||||
}: ImpactPanelProps): JSX.Element {
|
||||
if (mode === 'all') {
|
||||
return (
|
||||
<div className={styles.impact} data-testid="volume-control-impact">
|
||||
<Typography.Text className={styles.impactNote}>
|
||||
All attributes remain queryable, no reduction.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.impact} data-testid="volume-control-impact">
|
||||
{isLoading && <Spin size="small" />}
|
||||
{!isLoading && preview && (
|
||||
<div className={styles.meters}>
|
||||
<div className={styles.meter}>
|
||||
<span className={styles.meterLabel}>Ingested series</span>
|
||||
<span className={styles.meterValue}>
|
||||
{formatCompact(preview.ingestedSeries)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<span className={styles.meterLabel}>Reduced series</span>
|
||||
<span className={styles.meterValue}>
|
||||
{formatCompact(preview.reducedSeries)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<span className={styles.meterLabel}>Reduction</span>
|
||||
<span className={`${styles.meterValue} ${styles.meterValueGood}`}>
|
||||
−{Math.round(preview.reductionPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !preview && (
|
||||
<Typography.Text className={styles.impactNote}>
|
||||
Select attributes to preview the impact.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpactPanel;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Select } from 'antd';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { RuleMode } from './types';
|
||||
import styles from './VolumeControlConfig.module.scss';
|
||||
|
||||
interface LabelSelectorProps {
|
||||
mode: RuleMode;
|
||||
options: string[];
|
||||
value: string[];
|
||||
onChange: (labels: string[]) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function LabelSelector({
|
||||
mode,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
loading,
|
||||
}: LabelSelectorProps): JSX.Element {
|
||||
const helpText =
|
||||
mode === 'include'
|
||||
? 'Only the selected attributes will remain queryable.'
|
||||
: 'The selected attributes will be aggregated away; all others stay queryable.';
|
||||
|
||||
return (
|
||||
<div className={styles.field} data-testid="volume-control-label-selector">
|
||||
<Typography.Text className={styles.fieldLabel}>Attributes</Typography.Text>
|
||||
<Typography.Text className={styles.fieldHint}>{helpText}</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
className={styles.labelSelect}
|
||||
placeholder="Select attributes"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loading={loading}
|
||||
options={options.map((key) => ({ label: key, value: key }))}
|
||||
getPopupContainer={popupContainer}
|
||||
data-testid="volume-control-label-select"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelSelector;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { RuleMode } from './types';
|
||||
import styles from './VolumeControlConfig.module.scss';
|
||||
|
||||
interface ModeOption {
|
||||
mode: RuleMode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: ModeOption[] = [
|
||||
{
|
||||
mode: 'all',
|
||||
title: 'Allow all attributes',
|
||||
description: 'All attributes stay queryable. Removes any existing rule.',
|
||||
},
|
||||
{
|
||||
mode: 'include',
|
||||
title: 'Include attributes',
|
||||
description: 'Allowlist: only the selected attributes stay queryable.',
|
||||
},
|
||||
{
|
||||
mode: 'exclude',
|
||||
title: 'Exclude attributes',
|
||||
description: 'Blocklist: the selected attributes are aggregated away.',
|
||||
},
|
||||
];
|
||||
|
||||
interface ModeSelectorProps {
|
||||
mode: RuleMode;
|
||||
onChange: (mode: RuleMode) => void;
|
||||
}
|
||||
|
||||
function ModeSelector({ mode, onChange }: ModeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.modeCards} data-testid="volume-control-mode-selector">
|
||||
{MODE_OPTIONS.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.mode}
|
||||
className={`${styles.modeCard} ${
|
||||
mode === option.mode ? styles.modeCardActive : ''
|
||||
}`}
|
||||
onClick={(): void => onChange(option.mode)}
|
||||
data-testid={`volume-control-mode-${option.mode}`}
|
||||
>
|
||||
<Typography.Text className={styles.modeTitle}>
|
||||
{option.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.modeDesc}>
|
||||
{option.description}
|
||||
</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModeSelector;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { MetricreductionruletypesAffectedAssetDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './VolumeControlConfig.module.scss';
|
||||
|
||||
interface RelatedAssetsWarningProps {
|
||||
affectedAssets?: MetricreductionruletypesAffectedAssetDTO[] | null;
|
||||
}
|
||||
|
||||
function RelatedAssetsWarning({
|
||||
affectedAssets,
|
||||
}: RelatedAssetsWarningProps): JSX.Element | null {
|
||||
const impacted = (affectedAssets ?? []).filter(
|
||||
(asset) => (asset.impactedLabels ?? []).length > 0,
|
||||
);
|
||||
if (impacted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const impactedLabels = Array.from(
|
||||
new Set(impacted.flatMap((asset) => asset.impactedLabels ?? [])),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.warning} data-testid="volume-control-warning">
|
||||
<Info size={14} />
|
||||
<div>
|
||||
<div className={styles.warningTitle}>
|
||||
This rule affects {impacted.length} related asset
|
||||
{impacted.length > 1 ? 's' : ''}.
|
||||
</div>
|
||||
{impactedLabels.length > 0 && (
|
||||
<div className={styles.warningDetail}>
|
||||
{impactedLabels.join(', ')} will no longer be queryable; affected panels
|
||||
fall back to aggregated data once the rule applies.
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.assetList}>
|
||||
{impacted.map((asset) => (
|
||||
<li key={`${asset.type}-${asset.id}`}>
|
||||
{asset.name}
|
||||
{asset.widget ? ` · ${asset.widget}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RelatedAssetsWarning;
|
||||
@@ -1,172 +0,0 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--bg-amber-400, #ffd778);
|
||||
border: 1px solid var(--bg-amber-500, #ffcc56);
|
||||
border-radius: 99px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
/* mode cards */
|
||||
.mode-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-ink-300, #16181d);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
border-color: var(--bg-slate-200, #2c3140);
|
||||
}
|
||||
|
||||
.mode-card-active {
|
||||
border-color: var(--bg-robin-500, #4e74f8);
|
||||
background: rgba(78, 116, 248, 0.08);
|
||||
}
|
||||
|
||||
.mode-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* label selector */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.label-select {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* impact panel */
|
||||
.impact {
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-ink-300, #16181d);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.impact-note {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.meter-value {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 18px;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.meter-value-good {
|
||||
color: var(--bg-forest-400, #50e7a7);
|
||||
}
|
||||
|
||||
/* related-asset warning */
|
||||
.warning {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 204, 86, 0.07);
|
||||
border: 1px solid rgba(255, 204, 86, 0.3);
|
||||
color: var(--bg-amber-400, #ffd778);
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.warning-detail {
|
||||
font-size: 11.5px;
|
||||
color: var(--bg-vanilla-300, #e9e9e9);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 16px;
|
||||
font-size: 11.5px;
|
||||
color: var(--bg-vanilla-300, #e9e9e9);
|
||||
}
|
||||
|
||||
/* footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ImpactPanel from './ImpactPanel';
|
||||
import LabelSelector from './LabelSelector';
|
||||
import ModeSelector from './ModeSelector';
|
||||
import RelatedAssetsWarning from './RelatedAssetsWarning';
|
||||
import { useVolumeControlConfig } from './useVolumeControlConfig';
|
||||
import styles from './VolumeControlConfig.module.scss';
|
||||
|
||||
interface VolumeControlConfigDrawerProps {
|
||||
metricName: string;
|
||||
existingRule: MetricreductionruletypesGettableReductionRuleDTO | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function VolumeControlConfigDrawer({
|
||||
metricName,
|
||||
existingRule,
|
||||
open,
|
||||
onClose,
|
||||
}: VolumeControlConfigDrawerProps): JSX.Element {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
labels,
|
||||
setLabels,
|
||||
attributeKeys,
|
||||
isLoadingAttributes,
|
||||
preview,
|
||||
isPreviewLoading,
|
||||
save,
|
||||
remove,
|
||||
isSaving,
|
||||
isRemoving,
|
||||
hasExistingRule,
|
||||
isSaveDisabled,
|
||||
} = useVolumeControlConfig({ metricName, existingRule, open, onClose });
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
data-testid="volume-control-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={styles.footerSpacer} />
|
||||
{hasExistingRule && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={remove}
|
||||
loading={isRemoving}
|
||||
data-testid="volume-control-remove"
|
||||
>
|
||||
Remove rule
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={save}
|
||||
disabled={isSaveDisabled}
|
||||
loading={isSaving}
|
||||
data-testid="volume-control-save"
|
||||
>
|
||||
Save rule
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(next: boolean): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={`Manage attributes · ${metricName}`}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
width="wide"
|
||||
footer={footer}
|
||||
showOverlay={false}
|
||||
className="volume-control-config-drawer"
|
||||
style={{ zIndex: 1100 }}
|
||||
>
|
||||
<div className={styles.body} data-testid="volume-control-config-drawer">
|
||||
<div className={styles.title}>
|
||||
<span className={styles.adminTag}>Admin only</span>
|
||||
</div>
|
||||
<ModeSelector mode={mode} onChange={setMode} />
|
||||
{mode !== 'all' && (
|
||||
<LabelSelector
|
||||
mode={mode}
|
||||
options={attributeKeys}
|
||||
value={labels}
|
||||
onChange={setLabels}
|
||||
loading={isLoadingAttributes}
|
||||
/>
|
||||
)}
|
||||
<ImpactPanel mode={mode} preview={preview} isLoading={isPreviewLoading} />
|
||||
<RelatedAssetsWarning affectedAssets={preview?.affectedAssets} />
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeControlConfigDrawer;
|
||||
@@ -1,114 +0,0 @@
|
||||
.section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-ink-300, #16181d);
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: var(--bg-forest-500, #25e192);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: var(--bg-amber-500, #ffcc56);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mode {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-200, #23262e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border: 1px dashed var(--bg-slate-300, #242834);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.setup-button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pending-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(35, 196, 248, 0.07);
|
||||
border: 1px solid rgba(35, 196, 248, 0.25);
|
||||
color: var(--bg-aqua-400, #4bcff9);
|
||||
}
|
||||
|
||||
.pending-text {
|
||||
font-size: 11.5px;
|
||||
color: var(--bg-vanilla-300, #e9e9e9);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Gauge, Info } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useGetMetricReductionRule } from 'api/generated/services/metrics';
|
||||
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getLabelVerb, getMatchTypeLabel } from './utils';
|
||||
import VolumeControlConfigDrawer from './VolumeControlConfigDrawer';
|
||||
import styles from './VolumeControlSection.module.scss';
|
||||
|
||||
interface VolumeControlSectionProps {
|
||||
metricName: string;
|
||||
}
|
||||
|
||||
function VolumeControlSection({
|
||||
metricName,
|
||||
}: VolumeControlSectionProps): JSX.Element | null {
|
||||
const { isVolumeControlEnabled, canManageVolumeControl } =
|
||||
useVolumeControlFeatureGate();
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useGetMetricReductionRule(
|
||||
{ metricName },
|
||||
{
|
||||
query: {
|
||||
enabled: isVolumeControlEnabled && !!metricName,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!isVolumeControlEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = data?.data;
|
||||
const hasRule = !!rule && !error;
|
||||
|
||||
const openConfig = (): void => setIsConfigOpen(true);
|
||||
const closeConfig = (): void => setIsConfigOpen(false);
|
||||
|
||||
return (
|
||||
<div className={styles.section} data-testid="volume-control-section">
|
||||
<div className={styles.header}>
|
||||
<Gauge size={14} />
|
||||
<Typography.Text className={styles.title}>Volume control</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
|
||||
|
||||
{!isLoading && hasRule && rule && !rule.active && (
|
||||
<div
|
||||
className={styles.pendingBanner}
|
||||
data-testid="volume-control-pending-banner"
|
||||
>
|
||||
<Info size={13} />
|
||||
<Typography.Text className={styles.pendingText}>
|
||||
This metric's configuration was recently updated. Reduced volumes
|
||||
will take effect within a few minutes.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && hasRule && rule && (
|
||||
<div className={styles.card} data-testid="volume-control-active">
|
||||
<div className={styles.cardRow}>
|
||||
<span
|
||||
className={`${styles.statusDot} ${
|
||||
rule.active ? styles.statusActive : styles.statusPending
|
||||
}`}
|
||||
/>
|
||||
<Typography.Text className={styles.cardTitle}>
|
||||
{rule.active
|
||||
? 'Aggregation rule active'
|
||||
: 'Aggregation rule pending activation'}
|
||||
</Typography.Text>
|
||||
{canManageVolumeControl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.editButton}
|
||||
onClick={openConfig}
|
||||
data-testid="volume-control-edit"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text className={styles.mode}>
|
||||
{getMatchTypeLabel(rule.matchType)}
|
||||
</Typography.Text>
|
||||
<div className={styles.chips}>
|
||||
{(rule.labels ?? []).map((label) => (
|
||||
<span className={styles.chip} key={label}>
|
||||
{getLabelVerb(rule.matchType)} {label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasRule && (
|
||||
<div className={styles.empty} data-testid="volume-control-empty">
|
||||
<Typography.Text className={styles.emptyText}>
|
||||
No volume control rule. All series are retained. Aggregate away
|
||||
high-cardinality attributes to reduce cost.
|
||||
</Typography.Text>
|
||||
{canManageVolumeControl && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.setupButton}
|
||||
onClick={openConfig}
|
||||
data-testid="volume-control-setup"
|
||||
>
|
||||
Set up volume control
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManageVolumeControl && isConfigOpen && (
|
||||
<VolumeControlConfigDrawer
|
||||
metricName={metricName}
|
||||
existingRule={rule ?? null}
|
||||
open={isConfigOpen}
|
||||
onClose={closeConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeControlSection;
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
MetricreductionruletypesMatchTypeDTO,
|
||||
MetricreductionruletypesGettableReductionRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { RuleMode } from './types';
|
||||
|
||||
export function modeFromRule(
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO | null | undefined,
|
||||
): { mode: RuleMode; labels: string[] } {
|
||||
if (!rule) {
|
||||
return { mode: 'all', labels: [] };
|
||||
}
|
||||
return {
|
||||
mode:
|
||||
rule.matchType === MetricreductionruletypesMatchTypeDTO.keep
|
||||
? 'include'
|
||||
: 'exclude',
|
||||
labels: rule.labels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function matchTypeForMode(
|
||||
mode: RuleMode,
|
||||
): MetricreductionruletypesMatchTypeDTO {
|
||||
return mode === 'include'
|
||||
? MetricreductionruletypesMatchTypeDTO.keep
|
||||
: MetricreductionruletypesMatchTypeDTO.drop;
|
||||
}
|
||||
|
||||
export function formatCompact(value: number): string {
|
||||
if (value >= 1e9) {
|
||||
return `${(value / 1e9).toFixed(1)}B`;
|
||||
}
|
||||
if (value >= 1e6) {
|
||||
return `${(value / 1e6).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1e3) {
|
||||
return `${(value / 1e3).toFixed(1)}K`;
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type RuleMode = 'all' | 'include' | 'exclude';
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
invalidateGetMetricReductionRule,
|
||||
invalidateListMetricReductionRules,
|
||||
invalidateListMetrics,
|
||||
useDeleteMetricReductionRule,
|
||||
useGetMetricAttributes,
|
||||
usePreviewMetricReductionRule,
|
||||
useUpsertMetricReductionRule,
|
||||
} from 'api/generated/services/metrics';
|
||||
import {
|
||||
MetricreductionruletypesGettableReductionRulePreviewDTO,
|
||||
MetricreductionruletypesGettableReductionRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { matchTypeForMode, modeFromRule } from './configUtils';
|
||||
import { RuleMode } from './types';
|
||||
|
||||
interface UseVolumeControlConfigParams {
|
||||
metricName: string;
|
||||
existingRule: MetricreductionruletypesGettableReductionRuleDTO | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface UseVolumeControlConfigResult {
|
||||
mode: RuleMode;
|
||||
setMode: (mode: RuleMode) => void;
|
||||
labels: string[];
|
||||
setLabels: (labels: string[]) => void;
|
||||
attributeKeys: string[];
|
||||
isLoadingAttributes: boolean;
|
||||
preview?: MetricreductionruletypesGettableReductionRulePreviewDTO;
|
||||
isPreviewLoading: boolean;
|
||||
save: () => void;
|
||||
remove: () => void;
|
||||
isSaving: boolean;
|
||||
isRemoving: boolean;
|
||||
hasExistingRule: boolean;
|
||||
isSaveDisabled: boolean;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export function useVolumeControlConfig({
|
||||
metricName,
|
||||
existingRule,
|
||||
open,
|
||||
onClose,
|
||||
}: UseVolumeControlConfigParams): UseVolumeControlConfigResult {
|
||||
const { notifications } = useNotifications();
|
||||
const queryClient = useQueryClient();
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const initial = useMemo(() => modeFromRule(existingRule), [existingRule]);
|
||||
const [mode, setMode] = useState<RuleMode>(initial.mode);
|
||||
const [labels, setLabels] = useState<string[]>(initial.labels);
|
||||
|
||||
const attributesQuery = useGetMetricAttributes(
|
||||
{ metricName },
|
||||
{
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
},
|
||||
{ query: { enabled: open && !!metricName } },
|
||||
);
|
||||
const attributeKeys = useMemo(
|
||||
() => (attributesQuery.data?.data.attributes ?? []).map((attr) => attr.key),
|
||||
[attributesQuery.data],
|
||||
);
|
||||
|
||||
const previewMutation = usePreviewMetricReductionRule();
|
||||
const { mutate: previewMutate, reset: previewReset } = previewMutation;
|
||||
const [isPreviewPending, setIsPreviewPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode === 'all' || labels.length === 0) {
|
||||
previewReset();
|
||||
setIsPreviewPending(false);
|
||||
return undefined;
|
||||
}
|
||||
setIsPreviewPending(true);
|
||||
const timer = setTimeout(() => {
|
||||
previewMutate(
|
||||
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
|
||||
{ onSettled: () => setIsPreviewPending(false) },
|
||||
);
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return (): void => clearTimeout(timer);
|
||||
}, [open, mode, labels, metricName, previewMutate, previewReset]);
|
||||
|
||||
const upsertMutation = useUpsertMetricReductionRule();
|
||||
const deleteMutation = useDeleteMetricReductionRule();
|
||||
|
||||
const invalidate = useCallback((): void => {
|
||||
void invalidateGetMetricReductionRule(queryClient, { metricName });
|
||||
void invalidateListMetricReductionRules(queryClient);
|
||||
void invalidateListMetrics(queryClient);
|
||||
}, [queryClient, metricName]);
|
||||
|
||||
const removeRule = useCallback((): void => {
|
||||
deleteMutation.mutate(
|
||||
{ pathParams: { metricName } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({ message: 'Volume control rule removed' });
|
||||
invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) =>
|
||||
notifications.error({
|
||||
message: error.response?.data?.error?.message ?? REMOVE_ERROR_MESSAGE,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}, [deleteMutation, metricName, notifications, invalidate, onClose]);
|
||||
|
||||
const save = useCallback((): void => {
|
||||
if (mode === 'all') {
|
||||
if (!existingRule) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
removeRule();
|
||||
return;
|
||||
}
|
||||
upsertMutation.mutate(
|
||||
{
|
||||
pathParams: { metricName },
|
||||
data: { matchType: matchTypeForMode(mode), labels },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({ message: 'Volume control rule saved' });
|
||||
invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) =>
|
||||
notifications.error({
|
||||
message: error.response?.data?.error?.message ?? SAVE_ERROR_MESSAGE,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}, [
|
||||
mode,
|
||||
labels,
|
||||
metricName,
|
||||
existingRule,
|
||||
upsertMutation,
|
||||
removeRule,
|
||||
notifications,
|
||||
invalidate,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
labels,
|
||||
setLabels,
|
||||
attributeKeys,
|
||||
isLoadingAttributes: attributesQuery.isLoading,
|
||||
preview: previewMutation.data?.data,
|
||||
isPreviewLoading: isPreviewPending,
|
||||
save,
|
||||
remove: removeRule,
|
||||
isSaving: upsertMutation.isLoading || deleteMutation.isLoading,
|
||||
isRemoving: deleteMutation.isLoading,
|
||||
hasExistingRule: !!existingRule,
|
||||
isSaveDisabled: mode !== 'all' && labels.length === 0,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { MetricreductionruletypesMatchTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export function isKeepMode(
|
||||
matchType: MetricreductionruletypesMatchTypeDTO,
|
||||
): boolean {
|
||||
return matchType === MetricreductionruletypesMatchTypeDTO.keep;
|
||||
}
|
||||
|
||||
export function getMatchTypeLabel(
|
||||
matchType: MetricreductionruletypesMatchTypeDTO,
|
||||
): string {
|
||||
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
|
||||
}
|
||||
|
||||
export function getLabelVerb(
|
||||
matchType: MetricreductionruletypesMatchTypeDTO,
|
||||
): string {
|
||||
return isKeepMode(matchType) ? 'include' : 'exclude';
|
||||
}
|
||||
@@ -77,14 +77,6 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/VolumeControl/VolumeControlSection',
|
||||
() =>
|
||||
function MockVolumeControlSection(): JSX.Element {
|
||||
return <div data-testid="volume-control-section-mock">Volume Control</div>;
|
||||
},
|
||||
);
|
||||
|
||||
const useGetMetricMetadataMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricMetadata',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnType as ColumnType } from 'antd';
|
||||
import {
|
||||
MetricsexplorertypesStatDTO,
|
||||
MetricsexplorertypesTreemapEntryDTO,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--bg-forest-400, #50e7a7);
|
||||
background: rgba(37, 225, 146, 0.1);
|
||||
border: 1px solid rgba(37, 225, 146, 0.22);
|
||||
}
|
||||
|
||||
.pending {
|
||||
color: var(--bg-amber-400, #ffd778);
|
||||
background: rgba(255, 204, 86, 0.1);
|
||||
border: 1px solid rgba(255, 204, 86, 0.25);
|
||||
}
|
||||
|
||||
.none {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Gauge } from '@signozhq/icons';
|
||||
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './VolumeControlBadge.module.scss';
|
||||
|
||||
interface VolumeControlBadgeProps {
|
||||
rule?: MetricreductionruletypesGettableReductionRuleDTO;
|
||||
}
|
||||
|
||||
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
|
||||
if (!rule) {
|
||||
return (
|
||||
<span className={styles.none} data-testid="vc-badge-none">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`${styles.badge} ${rule.active ? styles.active : styles.pending}`}
|
||||
data-testid="vc-badge-active"
|
||||
>
|
||||
<Gauge size={11} />
|
||||
{rule.active ? 'Active' : 'Pending'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeControlBadge;
|
||||
@@ -1,95 +0,0 @@
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400, #7190f9);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 160px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-ink-400, #121317);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.attributes {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-300, #e9e9e9);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.reduction {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-forest-400, #50e7a7);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.unavailable {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { Gauge } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Table } from 'antd';
|
||||
import type { TableColumnsType, TableProps } from 'antd';
|
||||
import { useListMetricReductionRules } from 'api/generated/services/metrics';
|
||||
import {
|
||||
ListMetricReductionRulesParams,
|
||||
MetricreductionruletypesGettableReductionRuleDTO,
|
||||
MetricreductionruletypesOrderDTO,
|
||||
MetricreductionruletypesReductionRuleOrderByDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { formatCompact } from '../MetricDetails/VolumeControl/configUtils';
|
||||
import {
|
||||
getLabelVerb,
|
||||
getMatchTypeLabel,
|
||||
} from '../MetricDetails/VolumeControl/utils';
|
||||
import VolumeControlConfigDrawer from '../MetricDetails/VolumeControl/VolumeControlConfigDrawer';
|
||||
import VolumeControlBadge from './VolumeControlBadge';
|
||||
import styles from './VolumeControlTab.module.scss';
|
||||
|
||||
const OrderBy = MetricreductionruletypesReductionRuleOrderByDTO;
|
||||
const SortOrder = MetricreductionruletypesOrderDTO;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
const DEFAULT_PARAMS: Required<ListMetricReductionRulesParams> = {
|
||||
orderBy: OrderBy.reduction,
|
||||
order: SortOrder.desc,
|
||||
offset: 0,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
|
||||
function VolumeControlTab(): JSX.Element {
|
||||
const { isVolumeControlEnabled, canManageVolumeControl } =
|
||||
useVolumeControlFeatureGate();
|
||||
const [selectedRule, setSelectedRule] =
|
||||
useState<MetricreductionruletypesGettableReductionRuleDTO | null>(null);
|
||||
const [params, setParams] =
|
||||
useState<Required<ListMetricReductionRulesParams>>(DEFAULT_PARAMS);
|
||||
|
||||
const { data, isLoading } = useListMetricReductionRules(params, {
|
||||
query: { enabled: isVolumeControlEnabled },
|
||||
});
|
||||
|
||||
const rules = data?.data.rules ?? [];
|
||||
const total = data?.data.total ?? 0;
|
||||
|
||||
const sortOrderFor = useCallback(
|
||||
(
|
||||
key: MetricreductionruletypesReductionRuleOrderByDTO,
|
||||
): 'ascend' | 'descend' | undefined => {
|
||||
if (params.orderBy !== key) {
|
||||
return undefined;
|
||||
}
|
||||
return params.order === SortOrder.desc ? 'descend' : 'ascend';
|
||||
},
|
||||
[params],
|
||||
);
|
||||
|
||||
const columns: TableColumnsType<MetricreductionruletypesGettableReductionRuleDTO> =
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
title: 'METRIC',
|
||||
dataIndex: 'metricName',
|
||||
key: OrderBy.metricname,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.metricname),
|
||||
render: (metricName: string): JSX.Element => (
|
||||
<span className={styles.metricName}>{metricName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'STATUS',
|
||||
key: 'status',
|
||||
width: 130,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => <VolumeControlBadge rule={rule} />,
|
||||
},
|
||||
{
|
||||
title: 'MODE',
|
||||
key: 'mode',
|
||||
width: 160,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => <span>{getMatchTypeLabel(rule.matchType)}</span>,
|
||||
},
|
||||
{
|
||||
title: 'ATTRIBUTES',
|
||||
key: 'attributes',
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<span className={styles.attributes}>
|
||||
{getLabelVerb(rule.matchType)} {(rule.labels ?? []).join(', ') || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'INGESTED',
|
||||
key: OrderBy.ingestedvolume,
|
||||
width: 130,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.ingestedvolume),
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<span className={styles.muted}>{formatCompact(rule.ingestedSeries)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'REDUCED',
|
||||
key: OrderBy.reducedvolume,
|
||||
width: 130,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.reducedvolume),
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => <span>{formatCompact(rule.reducedSeries)}</span>,
|
||||
},
|
||||
{
|
||||
title: 'CHANGE',
|
||||
key: OrderBy.reduction,
|
||||
width: 110,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.reduction),
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => {
|
||||
if (rule.reductionPercent <= 0) {
|
||||
return <span className={styles.muted}>—</span>;
|
||||
}
|
||||
return (
|
||||
<span className={styles.reduction}>
|
||||
−{Math.round(rule.reductionPercent)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'LAST CONFIGURED',
|
||||
key: OrderBy.lastupdated,
|
||||
width: 240,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.lastupdated),
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<span className={styles.muted}>
|
||||
{dayjs(rule.updatedAt).format('MMM D, YYYY · h:mm A')}
|
||||
{rule.updatedBy ? ` · ${rule.updatedBy}` : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(canManageVolumeControl
|
||||
? ([
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 110,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => setSelectedRule(rule)}
|
||||
data-testid={`vc-manage-${rule.metricName}`}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
] as TableColumnsType<MetricreductionruletypesGettableReductionRuleDTO>)
|
||||
: []),
|
||||
],
|
||||
[canManageVolumeControl, sortOrderFor],
|
||||
);
|
||||
|
||||
const handleTableChange: TableProps<MetricreductionruletypesGettableReductionRuleDTO>['onChange'] =
|
||||
(pagination, _filters, sorter): void => {
|
||||
const active = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const pageSize = pagination.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
const current = pagination.current ?? 1;
|
||||
|
||||
setParams({
|
||||
orderBy: active?.order
|
||||
? (active.columnKey as MetricreductionruletypesReductionRuleOrderByDTO)
|
||||
: DEFAULT_PARAMS.orderBy,
|
||||
order: active?.order === 'descend' ? SortOrder.desc : SortOrder.asc,
|
||||
limit: pageSize,
|
||||
offset: (current - 1) * pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isVolumeControlEnabled) {
|
||||
return (
|
||||
<div className={styles.unavailable} data-testid="volume-control-unavailable">
|
||||
<Typography.Text>
|
||||
Volume control is available on enterprise and cloud plans.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tab} data-testid="volume-control-tab">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Gauge size={18} />
|
||||
<Typography.Title level={4} className={styles.title}>
|
||||
Volume Control
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Aggregate away high-cardinality attributes to reduce stored metric volume
|
||||
and cost.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>Active rules</span>
|
||||
<span className={styles.statValue}>{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<MetricreductionruletypesGettableReductionRuleDTO>
|
||||
rowKey="metricName"
|
||||
loading={isLoading}
|
||||
dataSource={rules}
|
||||
columns={columns}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: Math.floor(params.offset / params.limit) + 1,
|
||||
pageSize: params.limit,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className={styles.empty} data-testid="volume-control-tab-empty">
|
||||
No volume control rules yet. Open a metric and set one up to start
|
||||
reducing its series volume.
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{canManageVolumeControl && selectedRule && (
|
||||
<VolumeControlConfigDrawer
|
||||
metricName={selectedRule.metricName}
|
||||
existingRule={selectedRule}
|
||||
open={!!selectedRule}
|
||||
onClose={(): void => setSelectedRule(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeControlTab;
|
||||
@@ -116,7 +116,8 @@ function CreateRoleModal({
|
||||
} else {
|
||||
const data: AuthtypesPostableRoleDTO = {
|
||||
name: values.name,
|
||||
...(values.description ? { description: values.description } : {}),
|
||||
description: values.description || '',
|
||||
transactionGroups: [],
|
||||
};
|
||||
createRole({ data });
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
import { routeConfig } from './config';
|
||||
import { getQueryString } from './helper';
|
||||
import { buildNavUrl, getQueryString } from './helper';
|
||||
import {
|
||||
defaultMoreMenuItems,
|
||||
getUserSettingsDropdownMenuItems,
|
||||
@@ -486,12 +486,13 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const availableParams = routeConfig[key];
|
||||
|
||||
const queryString = getQueryString(availableParams || [], params);
|
||||
const url = buildNavUrl(key, queryString);
|
||||
|
||||
if (pathname !== key) {
|
||||
if (event && isModifierKeyPressed(event)) {
|
||||
openInNewTab(`${key}?${queryString.join('&')}`);
|
||||
openInNewTab(url);
|
||||
} else {
|
||||
history.push(`${key}?${queryString.join('&')}`, {
|
||||
history.push(url, {
|
||||
from: pathname,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,3 +8,14 @@ export const getQueryString = (
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated This should be removed after https://github.com/SigNoz/engineering-pod/issues/5322 is done
|
||||
*/
|
||||
export const buildNavUrl = (key: string, queryString: string[]): string => {
|
||||
if (key.includes('?')) {
|
||||
const extra = queryString.filter(Boolean).join('&');
|
||||
return extra ? `${key}&${extra}` : key;
|
||||
}
|
||||
return `${key}?${queryString.join('&')}`;
|
||||
};
|
||||
|
||||
@@ -337,6 +337,7 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: true,
|
||||
itemKey: 'account',
|
||||
},
|
||||
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
|
||||
{
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
|
||||
61
frontend/src/hooks/__tests__/useConfirmableAction.test.ts
Normal file
61
frontend/src/hooks/__tests__/useConfirmableAction.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useConfirmableAction } from '../useConfirmableAction';
|
||||
|
||||
describe('useConfirmableAction', () => {
|
||||
it('starts closed and idle', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
|
||||
);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('request() opens the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirm() runs the action and closes on success', async () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await result.current.confirm();
|
||||
});
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the prompt open and resets pending when the action rejects', async () => {
|
||||
const action = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
await act(async () => {
|
||||
await expect(result.current.confirm()).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel() closes the prompt without running the action', () => {
|
||||
const action = jest.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useConfirmableAction(action));
|
||||
|
||||
act(() => result.current.request());
|
||||
act(() => result.current.cancel());
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
interface VolumeControlFeatureGate {
|
||||
isVolumeControlEnabled: boolean;
|
||||
canManageVolumeControl: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useVolumeControlFeatureGate(): VolumeControlFeatureGate {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const { user, isFetchingActiveLicense, activeLicense } = useAppContext();
|
||||
|
||||
const isVolumeControlEnabled = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
const isAdmin = user?.role === USER_ROLES.ADMIN;
|
||||
|
||||
return {
|
||||
isVolumeControlEnabled,
|
||||
canManageVolumeControl: isVolumeControlEnabled && isAdmin,
|
||||
isLoading: isFetchingActiveLicense && !activeLicense,
|
||||
};
|
||||
}
|
||||
45
frontend/src/hooks/useConfirmableAction.ts
Normal file
45
frontend/src/hooks/useConfirmableAction.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export interface ConfirmableAction {
|
||||
/** Whether the confirmation prompt is open. */
|
||||
open: boolean;
|
||||
/** The confirmed action is in flight. */
|
||||
isPending: boolean;
|
||||
/** Open the confirmation prompt (e.g. from a menu item / button). */
|
||||
request: () => void;
|
||||
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
|
||||
confirm: () => Promise<void>;
|
||||
/** Dismiss the prompt without acting. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic two-step confirm flow for a (usually destructive) async action.
|
||||
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
|
||||
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
|
||||
* confirm state machine — what renders the prompt (dialog, popover) is the
|
||||
* caller's concern, so it stays reusable across confirm surfaces.
|
||||
*/
|
||||
export function useConfirmableAction(
|
||||
action: () => Promise<void>,
|
||||
): ConfirmableAction {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const request = useCallback((): void => setOpen(true), []);
|
||||
const cancel = useCallback((): void => setOpen(false), []);
|
||||
const confirm = useCallback(async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
await action();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ open, isPending, request, confirm, cancel }),
|
||||
[open, isPending, request, confirm, cancel],
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
@@ -15,6 +17,10 @@
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
@@ -33,6 +39,11 @@
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -67,18 +78,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
if (fillMode === FillMode.Solid) {
|
||||
lineConfig.fill = finalFillColor;
|
||||
lineConfig.fill = `${finalFillColor}70`;
|
||||
} else if (fillMode === FillMode.Gradient) {
|
||||
lineConfig.fill = (self: uPlot): CanvasGradient =>
|
||||
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
@@ -4,14 +4,17 @@ import { Tabs, TabsProps } from 'antd';
|
||||
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AllAlertChannels from 'container/AllAlertChannels';
|
||||
import AllAlertRules from 'container/ListAlertRules';
|
||||
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
||||
import RoutingPolicies from 'container/RoutingPolicies';
|
||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { Cable, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import AlertDetails from 'pages/AlertDetails';
|
||||
import ChannelsEdit from 'pages/ChannelsEdit';
|
||||
import ChannelsNew from 'pages/ChannelsNew';
|
||||
|
||||
import { AlertListSubTabs, AlertListTabs } from './types';
|
||||
|
||||
@@ -26,6 +29,9 @@ function AllAlertList(): JSX.Element {
|
||||
const subTab = urlQuery.get('subTab');
|
||||
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
|
||||
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
|
||||
const isChannelsNew = location.pathname === ROUTES.CHANNELS_NEW;
|
||||
const isChannelsEdit = location.pathname.startsWith('/alerts/channels/edit/');
|
||||
const isChannelDetails = isChannelsNew || isChannelsEdit;
|
||||
|
||||
const handleConfigurationTabChange = useCallback(
|
||||
(subTab: string): void => {
|
||||
@@ -86,6 +92,22 @@ function AllAlertList(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="periscope-tab top-level-tab">
|
||||
<Cable size={14} />
|
||||
Notification Channels
|
||||
</div>
|
||||
),
|
||||
key: AlertListTabs.CHANNELS,
|
||||
children: (
|
||||
<div className="alert-rules-container">
|
||||
{isChannelsNew && <ChannelsNew />}
|
||||
{isChannelsEdit && <ChannelsEdit />}
|
||||
{!isChannelDetails && <AllAlertChannels />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="periscope-tab top-level-tab">
|
||||
@@ -98,11 +120,21 @@ function AllAlertList(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
const getActiveKey = (): string => {
|
||||
if (isAlertHistory || isAlertOverview) {
|
||||
return AlertListTabs.ALERT_RULES;
|
||||
}
|
||||
if (isChannelDetails) {
|
||||
return AlertListTabs.CHANNELS;
|
||||
}
|
||||
return tab || AlertListTabs.ALERT_RULES;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
destroyInactiveTabPane
|
||||
items={items}
|
||||
activeKey={tab || AlertListTabs.ALERT_RULES}
|
||||
activeKey={getActiveKey()}
|
||||
onChange={(tab): void => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
@@ -120,7 +152,9 @@ function AllAlertList(): JSX.Element {
|
||||
safeNavigate(`/alerts?${queryParams.toString()}`);
|
||||
}}
|
||||
className={`alerts-container ${
|
||||
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
|
||||
isAlertHistory || isAlertOverview || isChannelDetails
|
||||
? 'alert-details-tabs'
|
||||
: ''
|
||||
}`}
|
||||
tabBarExtraContent={
|
||||
<HeaderRightSection
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum AlertListTabs {
|
||||
TRIGGERED_ALERTS = 'TriggeredAlerts',
|
||||
ALERT_RULES = 'AlertRules',
|
||||
CONFIGURATION = 'Configuration',
|
||||
CHANNELS = 'Channels',
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import get from 'api/channels/get';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
MsTeamsChannel,
|
||||
@@ -22,9 +24,9 @@ import './ChannelsEdit.styles.scss';
|
||||
function ChannelsEdit(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Extract channelId from URL pathname since useParams doesn't work in nested routing
|
||||
// Extract channelId from URL pathname
|
||||
const { pathname } = window.location;
|
||||
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
|
||||
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
|
||||
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
|
||||
|
||||
const { isFetching, isError, data, error } = useQuery<
|
||||
@@ -135,17 +137,25 @@ function ChannelsEdit(): JSX.Element {
|
||||
const target = prepChannelConfig();
|
||||
|
||||
return (
|
||||
<div className="edit-alert-channels-container">
|
||||
<EditAlertChannels
|
||||
{...{
|
||||
initialValue: {
|
||||
...target.channel,
|
||||
type: target.type,
|
||||
name: value.name,
|
||||
},
|
||||
}}
|
||||
<>
|
||||
<AlertBreadcrumb
|
||||
items={[
|
||||
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
|
||||
{ title: value.name || 'Edit Channel', isLast: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-alert-channels-container">
|
||||
<EditAlertChannels
|
||||
{...{
|
||||
initialValue: {
|
||||
...target.channel,
|
||||
type: target.type,
|
||||
name: value.name,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
23
frontend/src/pages/ChannelsNew/index.tsx
Normal file
23
frontend/src/pages/ChannelsNew/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import ROUTES from 'constants/routes';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
function ChannelsNew(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<AlertBreadcrumb
|
||||
items={[
|
||||
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
|
||||
{ title: 'New Channel', isLast: true },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<CreateAlertChannels preType={ChannelType.Slack} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsNew;
|
||||
4
frontend/src/pages/ChannelsNew/styles.module.scss
Normal file
4
frontend/src/pages/ChannelsNew/styles.module.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.content {
|
||||
padding: var(--spacing-8);
|
||||
padding-top: 0px;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import APIError from 'types/api/error';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import VariablesBar from '../VariablesBar/VariablesBar';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
|
||||
// drives the public-access badge.
|
||||
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -76,3 +77,26 @@ export function emptyVariableFormModel(): VariableFormModel {
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--l1-background-60);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const discard = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => onClose(), [onClose]),
|
||||
);
|
||||
|
||||
// Confirm before closing with unsaved edits; a pristine panel closes straight away.
|
||||
const handleCloseClick = useCallback((): void => {
|
||||
if (isDirty) {
|
||||
discard.request();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [isDirty, onClose, discard]);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
suffix={<X size={14} />}
|
||||
data-testid="panel-editor-v2-close"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={discard.open}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
discard.cancel();
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={discard.confirm}
|
||||
onCancel={discard.cancel}
|
||||
data-testid="panel-editor-v2-discard-modal"
|
||||
>
|
||||
<Typography>Your unsaved edits to this panel will be lost.</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,28 @@
|
||||
// Full-page editor: fills the route's content area as a header-over-split
|
||||
// column (the editor is its own page now, not a modal overlay).
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: var(--l1-border);
|
||||
&:hover {
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '../../../../../styles/scrollbar' as *;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--l1-background);
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
:global(.ant-tabs-tab-active) {
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
.queryTypeTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.runQueryBtnContainer {
|
||||
padding: 4px 0 8px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch (the button's cancel action). */
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder UI for the V2 panel editor's left pane: queryType tabs (Query Builder /
|
||||
* ClickHouse / PromQL) plus the Stage & Run button, all reading/writing the global
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelType,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
|
||||
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
|
||||
// capture phase so it still fires for fields that stop bubbling (filter search,
|
||||
// CodeMirror).
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onStageRunQuery();
|
||||
}
|
||||
},
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
|
||||
[],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={panelType !== PANEL_TYPES.LIST}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: {
|
||||
icon: <Terminal size={14} />,
|
||||
label: 'ClickHouse Query',
|
||||
component: <ClickHouseQueryContainer />,
|
||||
},
|
||||
[EQueryType.PROM]: {
|
||||
icon: (
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
),
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
data-testid="panel-editor-v2-query-builder"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
role="presentation"
|
||||
>
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorQueryBuilder;
|
||||
@@ -0,0 +1,59 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queryType {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--l2-forground);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (drives PanelBody's error-state retry). */
|
||||
refetch: () => void;
|
||||
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
|
||||
* is the production render path. The query result is owned by the editor root.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{panelDef ? (
|
||||
<PanelBody
|
||||
panelDefinition={panelDef}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewPane;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorDraft } from '../usePanelEditorDraft';
|
||||
|
||||
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name, description },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorDraft', () => {
|
||||
it('exposes the panel spec and starts clean', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
expect(result.current.spec).toBe(result.current.draft.spec);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
display: { ...result.current.spec.display, name: 'Memory' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(result.current.draft.spec?.display?.name).toBe('Memory');
|
||||
});
|
||||
|
||||
it('flags dirty on a plugin-spec (non-display) edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
expect(
|
||||
(
|
||||
result.current.draft.spec?.plugin?.spec as {
|
||||
formatting?: { unit?: string };
|
||||
}
|
||||
)?.formatting?.unit,
|
||||
).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
queries: [{ id: 'committed-by-builder' }],
|
||||
} as unknown as typeof result.current.spec),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() =>
|
||||
result.current.setSpec({
|
||||
...result.current.spec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'ms' } },
|
||||
},
|
||||
} as typeof result.current.spec),
|
||||
);
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
expect(result.current.spec.display?.name).toBe('CPU');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
getIsQueryModified: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
fromPerses: jest.fn(),
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
// commitQuery's no-op guard compares queries at the envelope level; with the
|
||||
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
|
||||
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
|
||||
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
|
||||
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
|
||||
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
|
||||
const mockFromPerses = fromPerses as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
|
||||
// Opaque fixtures — the adapters are mocked, so only identity matters here.
|
||||
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
|
||||
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
|
||||
|
||||
function makeDraft(
|
||||
queries = SAVED_QUERIES,
|
||||
kind = 'signoz/TimeSeriesPanel',
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function builderState(
|
||||
overrides: Partial<{
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
}> = {},
|
||||
): {
|
||||
currentQuery: Query;
|
||||
stagedQuery: Query | null;
|
||||
handleRunQuery: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
|
||||
stagedQuery: STAGED_V1,
|
||||
handleRunQuery: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFromPerses.mockReturnValue(SEED_V1);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState());
|
||||
});
|
||||
|
||||
function setup(
|
||||
opts: {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
current: {
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
};
|
||||
};
|
||||
setSpec: jest.Mock;
|
||||
refetch: jest.Mock;
|
||||
rerender: () => void;
|
||||
} {
|
||||
const setSpec = opts.setSpec ?? jest.fn();
|
||||
const refetch = opts.refetch ?? jest.fn();
|
||||
const draft = opts.draft ?? makeDraft();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the draft on mount for an unedited panel', () => {
|
||||
const { setSpec, refetch } = setup();
|
||||
// Mount runs the type-change effect once; an unedited query must no-op.
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compares the live query against the saved query (seed), not the staged query', () => {
|
||||
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
|
||||
|
||||
const { result } = setup();
|
||||
result.current.runQuery();
|
||||
|
||||
// Baseline is the saved seed — a stale staged/URL query must not be the
|
||||
// reference, or a real datasource switch would read as "unchanged".
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
|
||||
});
|
||||
|
||||
describe('runQuery', () => {
|
||||
it('stages the query (handleRunQuery)', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
|
||||
const { result } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(handleRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a modified query into the draft and does not force a refetch', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
|
||||
// A stale staged query (e.g. URL-restored after refresh) must not be used
|
||||
// as the baseline; the switch is detected against the saved seed and the
|
||||
// live query is committed so the preview fetches it.
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result, setSpec, refetch } = setup();
|
||||
|
||||
result.current.runQuery();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query-type switch', () => {
|
||||
it('commits the active query when the query type changes', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch query type → the effect should commit.
|
||||
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the active query type is unchanged', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same query type, different object → effect must not re-fire.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Switch datasource logs → traces → the effect should commit (→ refetch).
|
||||
state.currentQuery = withSource('b', 'traces');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when the datasource is unchanged', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'logs') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Same datasource, different object → effect must not re-fire.
|
||||
state.currentQuery = withSource('b', 'logs');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
);
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New title', description: 'desc' },
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: 'bytes' } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
expect(result.current.isSaving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
|
||||
/**
|
||||
* Owns the editable draft of a single panel, seeded once from the loaded panel and
|
||||
* mutated locally until save. Kept in the perses `DashboardtypesPanelDTO` shape so the
|
||||
* preview renders it through the dashboard's renderer registry and the save hook
|
||||
* patches it without conversion. Everything the config pane edits flows through the
|
||||
* single `spec`/`setSpec` pair.
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
|
||||
setDraft((prev) => ({ ...prev, spec: next }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
// Deep compare, ignoring `spec.queries`: the query is owned by the builder and
|
||||
// re-serialized into the draft as a preview cache, so its representation drifts
|
||||
// without a real edit. Query dirtiness is tracked separately; here we only flag
|
||||
// divergence in the display + plugin spec slices.
|
||||
const isSpecDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, initialPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec: draft.spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
interface UsePanelEditorQuerySyncArgs {
|
||||
draft: DashboardtypesPanelDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
|
||||
* builder from the saved panel, then commits the active query into `draft.spec.queries`
|
||||
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding any
|
||||
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
|
||||
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty check
|
||||
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
|
||||
// which can carry stale state across a refresh and make a real switch read as
|
||||
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
|
||||
// the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so comparing them structurally would falsely dirty the draft.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
}
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
const commitRef = useRef(commitQuery);
|
||||
commitRef.current = commitQuery;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is force-reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
[currentQuery.builder],
|
||||
);
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
commitRef.current(queryRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
if (!commitQuery(currentQuery)) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset). Comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
|
||||
// captured once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
|
||||
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
|
||||
* title/description). `add` doubles as create-or-replace, avoiding a separate
|
||||
* existence check.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
|
||||
* split with the live preview + query builder on the left and the config pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type, which drives the query builder and preview.
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
});
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
});
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelType={panelType}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorContainer;
|
||||
@@ -0,0 +1,15 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
|
||||
/**
|
||||
* `Storage`-shaped adapter for `useDefaultLayout`, backed by the scoped localStorage
|
||||
* wrappers that prefix keys with the URL base path so layout stays isolated per deployment.
|
||||
*/
|
||||
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
|
||||
getItem: (key: string): string | null => getLocalStorageApi(key),
|
||||
setItem: (key: string, value: string): void => {
|
||||
setLocalStorageApi(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default layoutStorage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Local draft state for the panel being edited, kept as a perses `DashboardtypesPanelDTO`
|
||||
* so the live preview and the save patch share one shape (no intermediate translation).
|
||||
*/
|
||||
export interface PanelEditorDraftApi {
|
||||
/** The current (possibly edited) panel. Always defined once seeded. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** The panel spec — the single editing surface for the config pane. */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
/** Replace the whole panel spec (the registry lens returns a new one per edit). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/**
|
||||
* True when the draft's display/plugin-spec slices diverge from the loaded panel.
|
||||
* Excludes `spec.queries` — owned by the shared builder, tracked via
|
||||
* `usePanelEditorQuerySync.isQueryDirty`.
|
||||
*/
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -42,9 +42,7 @@ function BarPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
@@ -55,9 +53,8 @@ function BarPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data (falls back
|
||||
// to the global picker inside the helper). The generated request DTO is
|
||||
// structurally the hand-written V5 request; the cast is the boundary.
|
||||
// X-scale clamps come from the request that produced the data. The generated
|
||||
// request DTO is structurally the V5 request; the cast is the boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
@@ -100,10 +97,8 @@ function BarPanelRenderer({
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
|
||||
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
@@ -126,10 +121,8 @@ function BarPanelRenderer({
|
||||
[panelId],
|
||||
);
|
||||
|
||||
// The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
|
||||
// to these preferences trigger a fresh chart instance, preventing stale
|
||||
// sync wiring from being inherited.
|
||||
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
|
||||
// settings aren't inherited — the only way to fully reset the uPlot instance.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { stacked: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -16,10 +16,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
export interface BuildBarChartConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
@@ -34,14 +31,7 @@ export interface BuildBarChartConfigArgs {
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
|
||||
* one bar series per result row.
|
||||
*/
|
||||
/** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */
|
||||
export function buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
@@ -97,11 +87,8 @@ interface AddSeriesArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
|
||||
* when `spec.visualization.stackedBarChart` is set. Each series receives its
|
||||
* own per-query step interval so bar widths line up with the actual
|
||||
* sampling cadence reported by the backend.
|
||||
*
|
||||
* Adds one bar series per flattened V5 series (plus stacking bands). Each gets its
|
||||
* own per-query step interval so bar widths match the backend's sampling cadence.
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
|
||||
@@ -34,9 +34,7 @@ function HistogramPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,12 +22,10 @@ const BUCKET_OFFSET = 0;
|
||||
const sortAscending = (a: number, b: number): number => a - b;
|
||||
|
||||
/**
|
||||
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
|
||||
* either from `bucketWidth` (explicit override) or the smallest predefined
|
||||
* Grafana bucket that fits the data's `range / bucketCount` target while
|
||||
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
|
||||
* the resolution of the input).
|
||||
*
|
||||
* Bins raw series values into a uPlot-aligned histogram. Bucket size is the
|
||||
* `bucketWidth` override, else the smallest predefined Grafana bucket that fits
|
||||
* the `range / bucketCount` target while staying ≥ the input's smallest non-zero
|
||||
* delta (never sub-dividing below the input resolution).
|
||||
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
|
||||
*/
|
||||
export function prepareHistogramData({
|
||||
@@ -58,10 +56,9 @@ export function prepareHistogramData({
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(series, mergeAllActiveQueries);
|
||||
// Merged mode folds every query into frame 0 and leaves trailing empty
|
||||
// frames — drop those. Per-query mode must keep one column per result row
|
||||
// (even empty queries), or the data column count drifts below the series
|
||||
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
// Merged mode leaves trailing empty frames — drop those. Per-query mode keeps
|
||||
// one column per result row (even empty ones), else the column count falls below
|
||||
// the series count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
const histograms: AlignedData[] = frames
|
||||
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
|
||||
@@ -76,7 +73,7 @@ export function prepareHistogramData({
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
|
||||
/** Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity). */
|
||||
function toBinnableValue(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@@ -128,8 +125,10 @@ function selectBucketSize({
|
||||
return 0;
|
||||
}
|
||||
|
||||
// When merging is on, fold all frames into the first; the trailing empty
|
||||
// frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
/**
|
||||
* When merging is on, fold all frames into the first; the trailing empty
|
||||
* frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
*/
|
||||
function buildFrames(
|
||||
series: PanelSeries[],
|
||||
mergeAllActiveQueries: boolean,
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'buckets', controls: { count: true } },
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
(spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO).histogramBuckets
|
||||
?.mergeAllActiveQueries,
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'buckets',
|
||||
controls: { count: true, width: true, mergeQueries: true },
|
||||
},
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const POINT_SIZE = 5;
|
||||
const BAR_WIDTH_FACTOR = 1;
|
||||
// Merged-series colors mirror the V1 default — single histogram bin gets a
|
||||
// fixed blue-ish pair so the merged view looks the same as before.
|
||||
// Merged-series colors mirror the V1 default so the merged view looks unchanged.
|
||||
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
|
||||
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
|
||||
|
||||
@@ -30,13 +29,9 @@ export interface BuildHistogramConfigArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
|
||||
*
|
||||
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
|
||||
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
|
||||
* axes, click plugin) but then override the X/Y scales to be auto-linear
|
||||
* (`time: false, auto: true`) and install a histogram-specific cursor that
|
||||
* disables drag-pan and tightens focus proximity.
|
||||
* Builds a `UPlotConfigBuilder` for a Histogram panel. Unlike time-axis panels,
|
||||
* histograms have no time scale or drag-to-zoom: reuses `buildBaseConfig`, then
|
||||
* overrides the scales to auto-linear and installs a drag-disabled cursor.
|
||||
*/
|
||||
export function buildHistogramConfig({
|
||||
panelId,
|
||||
@@ -47,8 +42,6 @@ export function buildHistogramConfig({
|
||||
timezone,
|
||||
panelMode,
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
// Histograms have no time axis — no stepIntervals, and no click plugin
|
||||
// (the renderer passes no onClick), so the base config needs no response.
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
@@ -62,8 +55,7 @@ export function buildHistogramConfig({
|
||||
focus: { prox: 1e3 },
|
||||
});
|
||||
|
||||
// Override the time-axis scales from `buildBaseConfig` — histograms are
|
||||
// distribution plots, not time series.
|
||||
// Override the time-axis scales — histograms are distribution plots, not time series.
|
||||
builder.addScale({ scaleKey: 'x', time: false, auto: true });
|
||||
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
|
||||
|
||||
@@ -81,10 +73,9 @@ interface AddSeriesArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
|
||||
* set, `prepareHistogramData` produces a single Y column, so we add exactly
|
||||
* one series with the fixed merged-mode colors. Otherwise one series per
|
||||
* result row, with labels resolved via the standard legend matrix.
|
||||
* Adds histogram bar series. In `mergeAllActiveQueries` mode `prepareHistogramData`
|
||||
* produces a single Y column, so we add exactly one series with the fixed merged-mode
|
||||
* colors; otherwise one series per result row.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
|
||||
@@ -17,9 +17,7 @@ function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
|
||||
@@ -24,9 +24,7 @@ interface ValueDisplayProps {
|
||||
|
||||
/**
|
||||
* Renders a single large scalar with optional prefix/suffix units and threshold
|
||||
* recoloring (text or background). A V2-native replacement for the V1
|
||||
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
|
||||
* typography primitives.
|
||||
* recoloring (text or background). V2-native replacement for the V1 `ValueGraph`.
|
||||
*/
|
||||
function ValueDisplay({
|
||||
value,
|
||||
|
||||
@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
/**
|
||||
* Reduces the scalar tables of a V5 response to the single number a
|
||||
* NumberPanel renders.
|
||||
*
|
||||
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
|
||||
* is a scalar table per query (see `prepareScalarTables`). The value is the
|
||||
* first row's `isValueColumn` cell of the first table that has rows —
|
||||
* falling back to the row's first cell when no column is marked as the
|
||||
* value (mirrors the V1 `formatForWeb` fallback read).
|
||||
*
|
||||
* Returns `null` when there is no numeric value to show, which the renderer
|
||||
* maps to the "No Data" state.
|
||||
* Reduces the scalar tables of a V5 response to the single number a NumberPanel
|
||||
* renders: the first row's `isValueColumn` cell of the first table with rows,
|
||||
* falling back to the row's first cell (mirrors the V1 `formatForWeb` read).
|
||||
* Returns `null` when there is no numeric value (renderer shows "No Data").
|
||||
*/
|
||||
export function prepareNumberData(tables: PanelTable[]): number | null {
|
||||
for (const table of tables) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// A number panel renders one scalar — no axes, legend, or stacking. Just value
|
||||
// formatting and thresholds that recolor the value/background.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesComparisonThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../../types/threshold';
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation
|
||||
// uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
|
||||
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
|
||||
* evaluation. No dependency on the V1 `ThresholdProps` shape.
|
||||
*/
|
||||
/** Maps spec `ComparisonThresholdDTO`s onto the V2-native `PanelThreshold` (no V1 `ThresholdProps` dependency). */
|
||||
export function mapNumberThresholds(
|
||||
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
|
||||
): PanelThreshold[] {
|
||||
@@ -44,11 +11,5 @@ export function mapNumberThresholds(
|
||||
return [];
|
||||
}
|
||||
|
||||
return thresholds.map((threshold) => ({
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
}));
|
||||
return thresholds.map(toPanelThreshold);
|
||||
}
|
||||
|
||||
@@ -24,9 +24,7 @@ function PiePanelRenderer({
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user