Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
d104e150e8 Merge branch 'main' into nv/layout-validations 2026-07-01 12:50:02 +05:30
Naman Verma
c16c4ab863 test: add integratino test for layout validation 2026-07-01 12:49:27 +05:30
Naman Verma
3b73bbc46d chore: validate layout size and positioning in backend 2026-07-01 12:32:08 +05:30
168 changed files with 3602 additions and 6210 deletions

View File

@@ -618,6 +618,13 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesPostableAuthDomain:
properties:
config:
@@ -2529,6 +2536,22 @@ components:
- resource
- selectors
type: object
CoretypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
required:
- additions
- deletions
type: object
CoretypesResourceRef:
properties:
kind:
@@ -2714,14 +2737,6 @@ components:
type: string
dashboardName:
type: string
filterBy:
items:
type: string
type: array
groupBy:
items:
type: string
type: array
panelId:
type: string
panelName:
@@ -3571,7 +3586,7 @@ components:
- user
- system
- integration
type: string
type: object
DashboardtypesSpanGaps:
properties:
fillLessThan:
@@ -5397,9 +5412,6 @@ components:
type: string
id:
type: string
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
@@ -5412,9 +5424,9 @@ components:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
metricName:
type: string
retainedSamples:
minimum: 0
type: integer
reductionPercent:
format: double
type: number
retainedSeries:
minimum: 0
type: integer
@@ -5432,8 +5444,7 @@ components:
- active
- ingestedSeries
- retainedSeries
- ingestedSamples
- retainedSamples
- reductionPercent
type: object
MetricreductionruletypesGettableReductionRulePreview:
properties:
@@ -5476,23 +5487,15 @@ components:
estimatedMonthlySavingsUsd:
format: double
type: number
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
retainedSamples:
minimum: 0
type: integer
retainedSeries:
minimum: 0
type: integer
required:
- ingestedSeries
- retainedSeries
- ingestedSamples
- retainedSamples
- estimatedMonthlySavingsUsd
type: object
MetricreductionruletypesGettableReductionRules:
@@ -5558,6 +5561,7 @@ components:
- metric
- ingested_volume
- reduced_volume
- reduction
- last_updated
type: string
MetricreductionruletypesUpdatableReductionRule:
@@ -11821,6 +11825,68 @@ paths:
summary: Get role
tags:
- role
patch:
deprecated: true
description: This endpoint patches a role
operationId: PatchRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
@@ -11883,6 +11949,158 @@ paths:
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
description: Gets all objects connected to the specified role via a given relation
type
operationId: GetObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:read
- tokenizer:
- role:read
summary: Get objects for a role by relation
tags:
- role
patch:
deprecated: true
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch objects for a role by relation
tags:
- role
/api/v1/route_policies:
get:
deprecated: false

View File

@@ -260,6 +260,40 @@ func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID va
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
relation,
objectType,
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -290,6 +324,39 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Update(ctx, orgID, role)
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2230,6 +2230,13 @@ export interface AuthtypesOrgSessionContextDTO {
warning?: ErrorsJSONDTO;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
/**
@@ -3242,6 +3249,17 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
*/
additions: CoretypesObjectGroupDTO[] | null;
/**
* @type array,null
*/
deletions: CoretypesObjectGroupDTO[] | null;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -3977,14 +3995,6 @@ export interface DashboardtypesDashboardPanelRefDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
filterBy?: string[];
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -6909,11 +6919,6 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
* @type string
*/
id: string;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6929,10 +6934,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
*/
metricName: string;
/**
* @type integer
* @minimum 0
* @type number
* @format double
*/
retainedSamples: number;
reductionPercent: number;
/**
* @type integer
* @minimum 0
@@ -6991,21 +6996,11 @@ export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
* @format double
*/
estimatedMonthlySavingsUsd: number;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -7061,6 +7056,7 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
metric = 'metric',
ingested_volume = 'ingested_volume',
reduced_volume = 'reduced_volume',
reduction = 'reduction',
last_updated = 'last_updated',
}
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
@@ -10252,9 +10248,31 @@ export type GetRole200 = {
status: string;
};
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;
};
export type GetObjects200 = {
/**
* @type array
*/
data: CoretypesObjectGroupDTO[];
/**
* @type string
*/
status: string;
};
export type PatchObjectsPathParameters = {
id: string;
relation: string;
};
export type GetAllRoutePolicies200 = {
/**
* @type array

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { Gauge } from '@signozhq/icons';
import { Tooltip } from 'antd';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { Badge } from '@signozhq/ui/badge';
@@ -9,26 +8,16 @@ interface VolumeControlBadgeProps {
}
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
const badge = (
return (
<Badge
data-testid="vc-badge-active"
variant="outline"
color={rule.active ? 'success' : 'warning'}
color={!rule.active ? 'success' : 'warning'}
>
<Gauge size={12} />
{rule.active ? 'Active' : 'Pending'}
{!rule.active ? 'Active' : 'Pending'}
</Badge>
);
if (rule.active) {
return badge;
}
return (
<Tooltip title="Takes about 5 minutes to take effect">
<span>{badge}</span>
</Tooltip>
);
}
export default VolumeControlBadge;

View File

@@ -7,12 +7,6 @@
padding: 12px 16px 0 16px;
}
.chartHeader {
display: flex;
flex-direction: column;
gap: 2px;
}
.chartTitle {
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -21,11 +15,3 @@
.chartBody {
height: 340px;
}
.chartStatus {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}

View File

@@ -1,6 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
@@ -26,9 +25,7 @@ interface VolumeControlChartProps {
}
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
const { data, isLoading, isError } = useGetMetricReductionRuleTimeseries({
query: { enabled },
});
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
@@ -68,34 +65,11 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
return (
<div className={styles.chart} data-testid="volume-control-chart">
<div className={styles.chartHeader}>
<Typography.Text className={styles.chartTitle} size={'small'}>
Sample volume · ingested vs retained
</Typography.Text>
<Typography.Text size="small" color="muted">
Last 6 hours
</Typography.Text>
</div>
<Typography.Text className={styles.chartTitle} size={'small'}>
Series volume over time · ingested vs retained
</Typography.Text>
<div className={styles.chartBody} ref={graphRef}>
{isLoading && (
<div
className={styles.chartStatus}
data-testid="volume-control-chart-loading"
>
<Spin size="small" />
</div>
)}
{!isLoading && isError && (
<div
className={styles.chartStatus}
data-testid="volume-control-chart-error"
>
<Typography.Text size="small" color="danger">
Failed to load chart
</Typography.Text>
</div>
)}
{!isLoading && !isError && dimensions.width > 0 && (
{dimensions.width > 0 && (
<BarChart
config={config}
data={chartData}

View File

@@ -17,27 +17,11 @@
gap: 5px;
}
.meterLabelRow {
display: flex;
align-items: center;
gap: 4px;
}
.meterLabel {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meterInfo {
flex-shrink: 0;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1;
cursor: help;
}
.meterValue {
display: flex;
align-items: baseline;
gap: 6px;
font-family: 'Geist Mono', monospace;
}

View File

@@ -1,8 +1,6 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Spin, Tooltip } from 'antd';
import { Spin } from 'antd';
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
import { popupContainer } from 'utils/selectPopupContainer';
import { formatCompact } from '../../../configUtils';
import { RuleMode } from '../../../types';
@@ -29,7 +27,6 @@ function ImpactPanel({
);
}
const full = preview?.ingestedSeries ?? 0;
const current = preview?.currentRetainedSeries ?? 0;
const proposed = preview?.retainedSeries ?? 0;
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
@@ -43,59 +40,31 @@ function ImpactPanel({
{!isLoading && preview && (
<div className={styles.meterGrid}>
<div className={styles.meter}>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Full series
</Typography.Text>
<Tooltip
title="Total number of series for this metric before any reduction."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(full)}
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current series
</Typography.Text>
</div>
<div className={styles.meter}>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current retained
</Typography.Text>
<Tooltip
title="Series kept today under the metric's existing rule, or all of them if it has no rule yet."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(current)}
</Typography.Text>
</div>
<div className={styles.meter}>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Potential retained
</Typography.Text>
<Tooltip
title="Series that would be kept if you save this rule, with the reduction vs what's retained today."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Proposed series
</Typography.Text>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(proposed)}
{deltaPct !== 0 && (
<Typography.Text
size="small"
color={deltaPct >= 0 ? 'success' : undefined}
>
{reductionLabel}
</Typography.Text>
)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Reduction
</Typography.Text>
<Typography.Text
size="2xl"
color={deltaPct >= 0 ? 'success' : undefined}
className={styles.meterValue}
>
{reductionLabel}
</Typography.Text>
</div>
</div>

View File

@@ -18,12 +18,12 @@ const MODE_OPTIONS: ModeOption[] = [
},
{
mode: 'include',
title: 'Include',
title: 'Include attributes',
description: 'Allowlist: only the selected attributes stay queryable.',
},
{
mode: 'exclude',
title: 'Exclude',
title: 'Exclude attributes',
description: 'Blocklist: the selected attributes are aggregated away.',
},
];

View File

@@ -53,12 +53,12 @@ function RelatedAssetsWarning({
<div className={styles.warning} data-testid="volume-control-warning">
<Info size={14} />
<div className={styles.warningBody}>
<Typography.Text as="div" size="base" weight="semibold" color="warning">
<Typography.Text as="div" size="small" weight="semibold" color="warning">
This rule affects {impacted.length} related asset
{impacted.length > 1 ? 's' : ''}.
</Typography.Text>
{impactedLabels.length > 0 && (
<Typography.Text as="div" size="base" color="muted">
<Typography.Text as="div" size="sm" color="muted">
{impactedLabels.join(', ')} will no longer be queryable; affected panels
fall back to aggregated data once the rule applies.
</Typography.Text>
@@ -73,7 +73,7 @@ function RelatedAssetsWarning({
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
{href ? (
<Typography.Link
size="base"
size="sm"
href={href}
target="_blank"
rel="noopener noreferrer"
@@ -81,7 +81,7 @@ function RelatedAssetsWarning({
{label}
</Typography.Link>
) : (
<Typography.Text size="base" color="muted">
<Typography.Text size="sm" color="muted">
{label}
</Typography.Text>
)}

View File

@@ -42,10 +42,6 @@ function VolumeControlConfigDrawer({
const footer = (
<div className={styles.footer}>
<Typography.Text size="small" color="muted">
Changes take effect about 5 minutes after saving.
</Typography.Text>
<div className={styles.footerSpacer} />
<Button
variant="outlined"
color="secondary"
@@ -54,6 +50,7 @@ function VolumeControlConfigDrawer({
>
Cancel
</Button>
<div className={styles.footerSpacer} />
{hasExistingRule && (
<Button
variant="ghost"

View File

@@ -8,8 +8,8 @@ function PendingActivationBanner(): JSX.Element {
<div className={styles.banner} data-testid="volume-control-pending-banner">
<Info size={13} />
<Typography.Text size="sm" color="muted">
This metric&apos;s configuration was recently updated. Volume changes take
effect within about 5 minutes.
This metric&apos;s configuration was recently updated. Volume changes will
take effect within a few minutes.
</Typography.Text>
</div>
);

View File

@@ -22,7 +22,7 @@ function VolumeControlSection({
useVolumeControlFeatureGate();
const [isConfigOpen, setIsConfigOpen] = useState(false);
const { data, isLoading, isError } = useListMetricReductionRules(
const { data, isLoading, error } = useListMetricReductionRules(
{ metricName },
{
query: {
@@ -37,7 +37,7 @@ function VolumeControlSection({
}
const rule = data?.data.rules?.[0];
const hasRule = !!rule && !isError;
const hasRule = !!rule && !error;
const openConfig = (): void => setIsConfigOpen(true);
const closeConfig = (): void => setIsConfigOpen(false);
@@ -53,16 +53,6 @@ function VolumeControlSection({
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
{!isLoading && isError && (
<Typography.Text
size="small"
color="danger"
data-testid="volume-control-section-error"
>
Failed to load volume control. Please try again.
</Typography.Text>
)}
{!isLoading && hasRule && rule && !rule.active && (
<PendingActivationBanner />
)}
@@ -75,7 +65,7 @@ function VolumeControlSection({
/>
)}
{!isLoading && !isError && !hasRule && (
{!isLoading && !hasRule && (
<NoRuleEmptyState canManage={canManageVolumeControl} onSetup={openConfig} />
)}

View File

@@ -1,9 +1,3 @@
.statsSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.stats {
display: flex;
flex-wrap: wrap;
@@ -27,24 +21,11 @@
background: var(--callout-success-background);
}
.statCardLabelRow {
display: flex;
align-items: center;
gap: 4px;
}
.statCardLabel {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.statCardInfo {
flex-shrink: 0;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1;
cursor: help;
}
.statCardValue {
display: flex;
align-items: baseline;

View File

@@ -1,6 +1,4 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton, Tooltip } from 'antd';
import cx from 'classnames';
import { formatCompact, formatUsd } from '../../../configUtils';
@@ -10,17 +8,12 @@ interface VolumeControlStatsProps {
activeRules: number;
ingestedSeries: number;
retainedSeries: number;
ingestedSamples: number;
retainedSamples: number;
estimatedMonthlySavingsUsd: number;
isLoading?: boolean;
isError?: boolean;
}
interface StatItem {
label: string;
value: string;
tooltip: string;
delta?: string;
unit?: string;
highlighted?: boolean;
@@ -31,53 +24,20 @@ function VolumeControlStats({
activeRules,
ingestedSeries,
retainedSeries,
ingestedSamples,
retainedSamples,
estimatedMonthlySavingsUsd,
isLoading = false,
isError = false,
}: VolumeControlStatsProps): JSX.Element {
const overallReduction =
ingestedSeries > 0
? Math.round((1 - retainedSeries / ingestedSeries) * 100)
: 0;
const sampleReduction =
ingestedSamples > 0
? Math.round((1 - retainedSamples / ingestedSamples) * 100)
: 0;
const items: StatItem[] = [
{
label: 'Configured rules',
value: String(activeRules),
tooltip: 'Volume-control rules currently configured for this workspace.',
},
{
label: 'Ingested series',
value: formatCompact(ingestedSeries),
tooltip:
'Distinct time series across all metrics in the last 1 hour, before any reduction.',
},
{ label: 'Active rules', value: String(activeRules) },
{ label: 'Ingested series', value: formatCompact(ingestedSeries) },
{
label: 'Retained series',
value: formatCompact(retainedSeries),
delta: overallReduction > 0 ? `${overallReduction}%` : undefined,
tooltip:
'Distinct time series kept across all metrics in the last 1 hour; everything except what the rules reduce away. Lower than ingested means more reduction.',
},
{
label: 'Ingested samples',
value: formatCompact(ingestedSamples),
tooltip:
'Sample data points across all metrics in the last 1 hour, before any reduction.',
},
{
label: 'Retained samples',
value: formatCompact(retainedSamples),
delta: sampleReduction > 0 ? `${sampleReduction}%` : undefined,
tooltip:
'Sample data points kept across all metrics in the last 1 hour; everything except what the rules reduce. Samples reduce more than series because series do not all carry the same sample volume.',
},
{
label: 'Est. monthly savings',
@@ -85,73 +45,42 @@ function VolumeControlStats({
unit: '/mo',
highlighted: true,
valueGood: true,
tooltip:
'Rough monthly estimate: the samples the rules reduced in the last 1 hour, scaled to a month at 1-month standard retention. It is extrapolated from a single rolling hour.',
},
];
return (
<div className={styles.statsSection}>
<Typography.Text size="small" color="muted">
Last 1 hour
</Typography.Text>
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
>
<Typography.Text size="sm" color="muted" className={styles.statCardLabel}>
{item.label}
</Typography.Text>
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
>
<div className={styles.statCardLabelRow}>
<Typography.Text
size="sm"
color="muted"
className={styles.statCardLabel}
>
{item.label}
</Typography.Text>
<Tooltip title={item.tooltip}>
<Info size={12} className={styles.statCardInfo} />
</Tooltip>
</div>
{isLoading && (
<Skeleton.Button active size="small" className={styles.statCardValue} />
)}
{!isLoading && isError && (
<Typography.Text
as="div"
size="small"
color="danger"
className={styles.statCardValue}
>
Failed to load
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
</Typography.Text>
)}
{!isLoading && !isError && (
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
>
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
</Typography.Text>
)}
</div>
))}
</div>
</Typography.Text>
</div>
))}
</div>
);
}

View File

@@ -13,10 +13,8 @@
font-family: 'Geist Mono', monospace;
}
.volumeCell {
display: flex;
flex-direction: column;
gap: 2px;
.reductionCell {
font-family: 'Geist Mono', monospace;
}
.empty {

View File

@@ -36,7 +36,7 @@ type VolumeControlTableParams = Required<
>;
const DEFAULT_PARAMS: VolumeControlTableParams = {
orderBy: OrderBy.ingested_volume,
orderBy: OrderBy.reduction,
order: SortOrder.desc,
search: '',
offset: 0,
@@ -60,20 +60,11 @@ function VolumeControlTab(): JSX.Element {
);
}, [debouncedSearch]);
const {
data,
isLoading,
isError: isListError,
} = useListMetricReductionRules(params, {
const { data, isLoading } = useListMetricReductionRules(params, {
query: { enabled: isVolumeControlEnabled },
});
const {
data: statsData,
isLoading: isStatsLoading,
isFetching: isStatsFetching,
isError: isStatsError,
} = useGetMetricReductionRuleStats({
const { data: statsData } = useGetMetricReductionRuleStats({
query: { enabled: isVolumeControlEnabled },
});
const stats = statsData?.data;
@@ -120,7 +111,7 @@ function VolumeControlTab(): JSX.Element {
{
title: 'MODE',
key: 'mode',
width: 110,
width: 160,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
@@ -147,14 +138,7 @@ function VolumeControlTab(): JSX.Element {
),
},
{
title: (
<>
INGESTED{' '}
<Typography.Text size="small" color="muted">
(1h)
</Typography.Text>
</>
),
title: 'INGESTED',
key: OrderBy.ingested_volume,
width: 130,
sorter: true,
@@ -163,28 +147,13 @@ function VolumeControlTab(): JSX.Element {
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<div className={styles.volumeCell}>
<Typography.Text size="small">
{formatCompact(rule.ingestedSeries)}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSamples)} samples
</Typography.Text>
</div>
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSeries)}
</Typography.Text>
),
},
{
title: (
<>
RETAINED{' '}
<Typography.Text size="small" color="muted">
(1h)
</Typography.Text>
</>
),
title: 'RETAINED',
key: OrderBy.reduced_volume,
width: 130,
sorter: true,
@@ -193,35 +162,22 @@ function VolumeControlTab(): JSX.Element {
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<div className={styles.volumeCell}>
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{formatCompact(rule.retainedSamples)} samples
</Typography.Text>
</div>
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}
</Typography.Text>
),
},
{
title: 'CHANGE',
width: 140,
key: OrderBy.reduction,
width: 110,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reduction),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => {
const seriesReduction =
rule.ingestedSeries > 0
? (1 - rule.retainedSeries / rule.ingestedSeries) * 100
: 0;
const samplesReduction =
rule.ingestedSamples > 0
? (1 - rule.retainedSamples / rule.ingestedSamples) * 100
: 0;
if (seriesReduction <= 0 && samplesReduction <= 0) {
if (rule.reductionPercent <= 0) {
return (
<Typography.Text size="small" color="muted">
@@ -229,18 +185,14 @@ function VolumeControlTab(): JSX.Element {
);
}
return (
<div className={styles.volumeCell}>
<Typography.Text size="small" weight="semibold" color="success">
{seriesReduction > 0 ? `${Math.round(seriesReduction)}%` : '0%'}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{samplesReduction > 0 ? `${Math.round(samplesReduction)}%` : '0%'}{' '}
samples
</Typography.Text>
</div>
<Typography.Text
size="small"
weight="semibold"
color="success"
className={styles.reductionCell}
>
{Math.round(rule.reductionPercent)}%
</Typography.Text>
);
},
},
@@ -321,11 +273,7 @@ function VolumeControlTab(): JSX.Element {
activeRules={total}
ingestedSeries={stats?.ingestedSeries ?? 0}
retainedSeries={stats?.retainedSeries ?? 0}
ingestedSamples={stats?.ingestedSamples ?? 0}
retainedSamples={stats?.retainedSamples ?? 0}
estimatedMonthlySavingsUsd={stats?.estimatedMonthlySavingsUsd ?? 0}
isLoading={isStatsLoading || isStatsFetching}
isError={isStatsError}
/>
<VolumeControlChart enabled={isVolumeControlEnabled} />
@@ -345,13 +293,7 @@ function VolumeControlTab(): JSX.Element {
showSizeChanger: false,
}}
locale={{
emptyText: isListError ? (
<div className={styles.empty} data-testid="volume-control-tab-error">
<Typography.Text color="danger">
Failed to load volume control rules. Please try again.
</Typography.Text>
</div>
) : (
emptyText: (
<div className={styles.empty} data-testid="volume-control-tab-empty">
<Typography.Text color="muted">
No volume control rules yet. Open a metric and set one up to start

View File

@@ -49,7 +49,6 @@ export interface UseVolumeControlConfigResult {
const PREVIEW_DEBOUNCE_MS = 400;
const SAVE_ERROR_MESSAGE = 'Failed to save volume control rule';
const REMOVE_ERROR_MESSAGE = 'Failed to remove volume control rule';
const PREVIEW_ERROR_MESSAGE = 'Failed to preview volume control rule';
export function useVolumeControlConfig({
metricName,
@@ -96,25 +95,11 @@ export function useVolumeControlConfig({
const timer = setTimeout(() => {
previewMutate(
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
{
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? PREVIEW_ERROR_MESSAGE,
}),
onSettled: () => setIsPreviewPending(false),
},
{ onSettled: () => setIsPreviewPending(false) },
);
}, PREVIEW_DEBOUNCE_MS);
return (): void => clearTimeout(timer);
}, [
open,
mode,
labels,
metricName,
previewMutate,
previewReset,
notifications,
]);
}, [open, mode, labels, metricName, previewMutate, previewReset]);
const createMutation = useCreateMetricReductionRule();
const updateMutation = useUpdateMetricReductionRuleByID();
@@ -157,10 +142,7 @@ export function useVolumeControlConfig({
}
const onSuccess = (): void => {
notifications.success({
message:
'Volume control rule saved. It takes about 5 minutes to take effect.',
});
notifications.success({ message: 'Volume control rule saved' });
invalidate();
onClose();
};

View File

@@ -9,7 +9,7 @@ export function isKeepMode(
export function getMatchTypeLabel(
matchType: MetricreductionruletypesMatchTypeDTO,
): string {
return isKeepMode(matchType) ? 'Include' : 'Exclude';
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
}
export function getLabelVerb(

View File

@@ -24,11 +24,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { getGraphType } from 'utils/getGraphType';
/**
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
*/
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);

View File

@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
@@ -17,7 +18,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -51,7 +51,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
@@ -89,13 +88,14 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
value: next,
},
];
await patchAsync(patch);
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[id, patchAsync, showErrorModal],
[id, refetch, showErrorModal],
);
const { isEditing, draft, setDraft, startEdit, cancel, commit } =

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -8,7 +9,7 @@ import { isEqual } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
@@ -22,7 +23,7 @@ interface OverviewProps {
function Overview({ dashboard }: OverviewProps): JSX.Element {
const id = dashboard.id;
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -95,14 +96,15 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [buildPatch, patchAsync, showErrorModal]);
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let numberOfUnsavedChanges = 0;

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
@@ -14,9 +14,14 @@ interface UseSaveVariables {
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
@@ -28,8 +33,9 @@ export function useSaveVariables(): UseSaveVariables {
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchAsync(buildVariablesPatch(dtos));
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -38,7 +44,7 @@ export function useSaveVariables(): UseSaveVariables {
setIsSaving(false);
}
},
[dashboardId, patchAsync, showErrorModal],
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };

View File

@@ -1,43 +0,0 @@
import { SquareArrowOutUpRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { ReactNode } from 'react';
import styles from './ConfigActions.module.scss';
interface ConfigActionRowProps {
/** Leading glyph for the action. */
icon: ReactNode;
label: string;
onClick: () => void;
testId?: string;
}
/**
* One row in the config pane's "Actions" list — a cross-page navigation link
* (leading icon, label, trailing external-link affordance). The whole row is the
* click target.
*/
function ConfigActionRow({
icon,
label,
onClick,
testId,
}: ConfigActionRowProps): JSX.Element {
return (
<Button
type="button"
variant="outlined"
color="secondary"
className={styles.row}
data-testid={testId}
onClick={onClick}
prefix={<span className={styles.icon}>{icon}</span>}
suffix={<SquareArrowOutUpRight size={14} />}
>
<Typography.Text className={styles.label}>{label}</Typography.Text>
</Button>
);
}
export default ConfigActionRow;

View File

@@ -1,57 +0,0 @@
/* The "Actions" group: a list of cross-page navigation links, visually separated
from the collapsible config sections above by the same hairline divider. */
.divider {
height: 1px;
background: var(--l2-border);
margin: 18px 0;
}
.container {
padding: 0 16px;
}
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
.list {
display: flex;
flex-direction: column;
}
/* A navigation-link row: leading icon, label, trailing external-link affordance. */
.row {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 44px;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
&:hover {
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
}
}
.icon {
display: grid;
place-items: center;
flex: none;
color: var(--l2-foreground);
}
.label {
flex: 1;
text-align: left;
}

View File

@@ -1,51 +0,0 @@
import { Bell } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
import ConfigActionRow from './ConfigActionRow';
import styles from './ConfigActions.module.scss';
interface ConfigActionsProps {
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
panel: DashboardtypesPanelDTO;
panelId: string;
}
/**
* The "Actions" group at the foot of the config pane: cross-page navigation links,
* kept distinct from the collapsible config sections above. Each link is gated by the
* panel kind's capabilities; the whole group hides when none apply.
*/
function ConfigActions({
panel,
panelId,
}: ConfigActionsProps): JSX.Element | null {
const createAlert = useCreateAlertFromPanel();
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
// Only kinds whose query can seed an alert offer this today; mirror the panel
// menu's create-alert capability.
if (!actions.createAlert) {
return null;
}
return (
<>
<div className={styles.divider} />
<div className={styles.container}>
<span className={styles.eyebrow}>Actions</span>
<div className={styles.list}>
<ConfigActionRow
testId="panel-editor-v2-create-alert"
icon={<Bell size={14} />}
label="Create alert"
onClick={(): void => createAlert(panel, panelId)}
/>
</div>
</div>
</>
);
}
export default ConfigActions;

View File

@@ -1,51 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigActions from '../ConfigActions';
const mockCreateAlert = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: jest.fn(() => mockCreateAlert),
}),
);
function makePanel(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('ConfigActions', () => {
beforeEach(() => jest.clearAllMocks());
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
const user = userEvent.setup();
const panel = makePanel('signoz/TimeSeriesPanel');
render(<ConfigActions panel={panel} panelId="panel-1" />);
const row = screen.getByTestId('panel-editor-v2-create-alert');
expect(row).toHaveTextContent('Create alert');
await user.click(row);
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
});
it('renders nothing for a kind that cannot seed an alert', () => {
const { container } = render(
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
);
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -1,22 +1,20 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
panelKind: PanelKind;
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
@@ -34,14 +32,6 @@ interface ConfigPaneProps {
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
* The draft panel and its id — the "Actions" group seeds cross-page links
* (Create alert) from the current query.
*/
panel: DashboardtypesPanelDTO;
panelId: string;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}
/**
@@ -51,6 +41,7 @@ interface ConfigPaneProps {
* generically via the section registry — only sections with a built editor appear.
*/
function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
@@ -58,11 +49,7 @@ function ConfigPane({
legendSeries,
tableColumns,
stepInterval,
panel,
panelId,
metricUnit,
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
@@ -121,15 +108,12 @@ function ConfigPane({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
))}
</div>
</div>
</>
)}
<ConfigActions panel={panel} panelId={panelId} />
</div>
);
}

View File

@@ -34,7 +34,6 @@ function SectionSlot({
onChangePanelKind,
queryType,
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
@@ -75,7 +74,6 @@ function SectionSlot({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</SettingsSection>
);

View File

@@ -1,20 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import ConfigPane from '../ConfigPane';
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
}),
);
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -30,14 +19,13 @@ function renderConfigPane(
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
): React.ComponentProps<typeof ConfigPane> {
const props: React.ComponentProps<typeof ConfigPane> = {
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
legendSeries: [],
tableColumns: [],
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
panelId: 'panel-1',
...overrides,
};
render(<ConfigPane {...props} />);
@@ -75,28 +63,4 @@ describe('ConfigPane', () => {
screen.getByTestId('config-section-formatting-&-units'),
).toBeInTheDocument();
});
it('renders the Actions group for a create-alert-capable panel', () => {
// renderConfigPane defaults to a TimeSeries panel, which can seed an alert.
renderConfigPane();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-create-alert'),
).toBeInTheDocument();
});
it('omits the create-alert action for a kind that cannot seed an alert', () => {
// Table panels can't seed alerts → the Actions group hides its row. Only the
// panel passed to ConfigActions needs the kind; sections are asserted elsewhere.
const panel = {
kind: 'Panel',
spec: { ...spec(), plugin: { kind: 'signoz/TablePanel', spec: {} } },
} as DashboardtypesPanelDTO;
renderConfigPane({ panel });
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
});
});

View File

@@ -19,6 +19,4 @@ export interface SectionEditorContext {
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}

View File

@@ -46,10 +46,11 @@ function DisconnectValuesField({
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
// `fillOnlyBelow` is authoritative; fall back to a stored duration for legacy panels.
const isThreshold = value?.fillOnlyBelow ?? !!duration;
// Remember the last committed threshold so Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState<string | undefined>(duration);
const isThreshold = !!duration;
// Remember the last threshold so toggling Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState(
duration ?? defaultDuration(stepInterval),
);
useEffect(() => {
if (duration) {
@@ -58,17 +59,11 @@ function DisconnectValuesField({
}, [duration]);
const handleMode = (mode: DisconnectValuesMode): void => {
if (mode === DisconnectValuesMode.THRESHOLD) {
onChange({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
});
return;
}
// Never spans every gap; drop the duration so the renderer reads a clean "span all".
onChange({ ...value, fillOnlyBelow: false, fillLessThan: undefined });
onChange(
mode === DisconnectValuesMode.THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
};
return (
@@ -84,16 +79,14 @@ function DisconnectValuesField({
onChange={handleMode}
/>
</div>
{isThreshold && duration && (
{isThreshold && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={duration}
value={lastDuration}
minValue={stepInterval}
onChange={(next): void =>
onChange({ ...value, fillOnlyBelow: true, fillLessThan: next })
}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
/>
</div>
)}

View File

@@ -14,28 +14,6 @@ interface DisconnectValuesThresholdInputProps {
onChange: (duration: string) => void;
}
/**
* Inline error for a raw duration, or `null` when valid and in range. The parse is
* guarded: `isValidTimeSpan` passes some strings `intervalToSeconds` throws on (e.g. "5x").
*/
function validationError(raw: string, minValue?: number): string | null {
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
return 'Enter a valid duration (e.g. 30s, 1m, 1h)';
}
if (minValue !== undefined && seconds < minValue) {
return `Threshold should be > ${rangeUtil.secondsToHms(minValue)}`;
}
return null;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
@@ -58,21 +36,24 @@ function DisconnectValuesThresholdInput({
setError(null);
}, [value]);
// Validate live so an invalid entry surfaces immediately, not only on blur.
const handleText = (raw: string): void => {
setText(raw);
setError(raw ? validationError(raw, minValue) : null);
};
const commit = (raw: string): void => {
// Skip no-op commits: blur fires when clicking the Never toggle, and re-emitting
// the unchanged value there would race the toggle and snap back to Threshold.
if (!raw || raw === value) {
if (!raw) {
return;
}
const message = validationError(raw, minValue);
if (message) {
setError(message);
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
return;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
@@ -88,9 +69,12 @@ function DisconnectValuesThresholdInput({
status={error ? 'error' : undefined}
prefix={<span className={styles.thresholdPrefix}>&gt;</span>}
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
handleText(e.target.value)
}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setText(e.target.value);
if (error) {
setError(null);
}
}}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {

View File

@@ -1,34 +1,9 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
DashboardtypesLineStyleDTO,
type DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
import ChartAppearanceSection from '../ChartAppearanceSection';
/** Stateful wrapper that feeds onChange back as the spec, mirroring the real editor. */
function StatefulSpanGaps({
initial,
stepInterval,
}: {
initial?: DashboardtypesTimeSeriesChartAppearanceDTO;
stepInterval?: number;
}): JSX.Element {
const [value, setValue] = useState<
DashboardtypesTimeSeriesChartAppearanceDTO | undefined
>(initial);
return (
<ChartAppearanceSection
value={value}
controls={{ spanGaps: true }}
stepInterval={stepInterval}
onChange={setValue}
/>
);
}
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
@@ -164,7 +139,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
spanGaps: { fillLessThan: '1m' },
});
});
@@ -187,7 +162,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
spanGaps: { fillLessThan: '5m' },
});
});
@@ -208,7 +183,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '300' },
spanGaps: { fillLessThan: '300' },
});
});
@@ -225,24 +200,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
});
});
it('selects Never when fillOnlyBelow is false even if a duration lingers', () => {
render(
<ChartAppearanceSection
value={{ spanGaps: { fillOnlyBelow: false, fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
// The flag is authoritative: a stale fillLessThan must not show Threshold.
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
it('shows an error and does not commit an invalid duration', async () => {
@@ -286,117 +244,4 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('seeds the threshold from the step interval when switching to Threshold', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('seeds from the step interval even when it arrives after mount', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
// The step interval is undefined until the query response carries step metadata,
// so the panel first renders without it and receives it on a later render.
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
rerender(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('shows a validation error while typing, before blur', async () => {
const user = userEvent.setup();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.clear(input);
await user.type(input, 'abc');
// No blur / Enter — the error must already be visible.
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
});
it('does not re-commit the threshold when blurred without a change', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.click(input);
await user.tab();
expect(onChange).not.toHaveBeenCalled();
});
it('fully switches from Threshold to Never (the input disappears)', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '1m' } }} />);
expect(
screen.getByTestId('panel-editor-v2-span-gaps-value'),
).toBeInTheDocument();
// Focus the input first so clicking Never also fires its blur (the toggle race).
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
await user.click(screen.getByText('Never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('remembers the last threshold when toggling Never → Threshold', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',
);
});
});

View File

@@ -14,7 +14,7 @@ import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
Pick<SectionEditorContext, 'tableColumns'>;
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
@@ -39,7 +39,6 @@ function FormattingSection({
controls,
onChange,
tableColumns = [],
metricUnit,
}: FormattingSectionProps): JSX.Element {
return (
<>
@@ -51,7 +50,6 @@ function FormattingSection({
data-testid="panel-editor-v2-unit"
source={YAxisSource.DASHBOARDS}
value={value?.unit}
initialValue={metricUnit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>

View File

@@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
@@ -73,31 +71,4 @@ describe('FormattingSection', () => {
decimalPrecision: '2',
});
});
it('warns when the selected unit mismatches the metric unit', () => {
// metric sent in seconds, but bytes is selected.
render(
<FormattingSection
value={{ unit: 'By' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the selected unit matches the metric unit', () => {
render(
<FormattingSection
value={{ unit: 's' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
@@ -82,15 +82,6 @@ function ThresholdsSection({
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
// The saved threshold captured on edit entry, restored if the edit is discarded
// (edits stream into the spec live, so Discard can't just drop a local draft).
const editSnapshot = useRef<AnyThreshold | null>(null);
const updateAt =
(index: number) =>
(next: AnyThreshold): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
};
const addThreshold = (): void => {
const nextIndex = thresholds.length;
@@ -99,11 +90,6 @@ function ThresholdsSection({
setUnsavedIndex(nextIndex);
};
const beginEdit = (index: number): void => {
editSnapshot.current = thresholds[index] ?? null;
setEditingIndex(index);
};
const saveAt =
(index: number) =>
(next: AnyThreshold): void => {
@@ -119,15 +105,11 @@ function ThresholdsSection({
};
const discardAt = (index: number) => (): void => {
// A never-saved row is removed; otherwise revert the live edits to the snapshot.
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
const original = editSnapshot.current;
if (original) {
onChange(thresholds.map((t, i) => (i === index ? original : t)));
}
setEditingIndex(null);
};
@@ -138,9 +120,8 @@ function ThresholdsSection({
index,
yAxisUnit,
isEditing: editingIndex === index,
onEdit: (): void => beginEdit(index),
onEdit: (): void => setEditingIndex(index),
onSave: saveAt(index),
onLiveChange: updateAt(index),
onDiscard: discardAt(index),
onRemove: (): void => removeAt(index),
};

View File

@@ -5,7 +5,7 @@ import {
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent } from 'tests/test-utils';
import UnifiedThresholdsSection from '../ThresholdsSection';
@@ -36,16 +36,9 @@ const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
},
];
// Stateful harness for flows that depend on the value updating (add/discard/live).
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: DashboardtypesComparisonThresholdDTO[];
}): JSX.Element {
const [value, setValue] =
useState<DashboardtypesComparisonThresholdDTO[]>(initial);
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
return (
<ComparisonThresholdsSection
value={value}
@@ -149,46 +142,24 @@ describe('ComparisonThresholdsSection', () => {
expect(valueInput).toHaveValue(5);
});
it('reflects edits live (before Save) so the preview can react', async () => {
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
// No Save click — the latest edit is pushed up (debounced) for the preview.
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]),
);
});
it('reverts the live edits to the saved value on Discard', async () => {
const user = userEvent.setup();
render(<Harness initial={THRESHOLDS} />);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', async () => {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
@@ -10,16 +10,10 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard/live);
// Stateful harness for flows that depend on the value updating (add/discard);
// omits `controls` to exercise the default `label` variant.
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: AnyThreshold[];
}): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>(initial);
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
);
@@ -43,20 +37,19 @@ describe('ThresholdsSection', () => {
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
});
it('edits a threshold value and commits it on Save', async () => {
const user = userEvent.setup();
it('edits a threshold value and commits it on Save', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-edit-0'));
const valueInput = screen.getByTestId('threshold-value-0');
expect(valueInput).toHaveValue(80);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
await user.clear(valueInput);
await user.type(valueInput, '90');
await user.click(screen.getByTestId('threshold-save-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenLastCalledWith([
expect(onChange).toHaveBeenCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]);
});
@@ -77,63 +70,43 @@ describe('ThresholdsSection', () => {
]);
});
it('reflects edits live (before Save) so the preview can react', async () => {
const user = userEvent.setup();
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-edit-0'));
await user.clear(screen.getByTestId('threshold-value-0'));
await user.type(screen.getByTestId('threshold-value-0'), '90');
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-discard-0'));
// No Save click — the edit is pushed up (debounced) for the preview to render.
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]),
);
});
it('reverts the live edits to the saved value on Discard', async () => {
const user = userEvent.setup();
render(<Harness initial={THRESHOLDS} />);
await user.click(screen.getByTestId('threshold-edit-0'));
await user.clear(screen.getByTestId('threshold-value-0'));
await user.type(screen.getByTestId('threshold-value-0'), '90');
await user.click(screen.getByTestId('threshold-discard-0'));
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
await user.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', async () => {
const user = userEvent.setup();
it('removes a threshold from view mode', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-remove-0'));
fireEvent.click(screen.getByTestId('threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', async () => {
const user = userEvent.setup();
it('adds a threshold that opens in edit mode, and discards it away', () => {
render(<Harness />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
// Discarding a never-saved row removes it entirely.
await user.click(screen.getByTestId('threshold-discard-0'));
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', async () => {
const user = userEvent.setup();
it('flags a threshold unit in a different category than the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
@@ -142,12 +115,11 @@ describe('ThresholdsSection', () => {
/>,
);
await user.click(screen.getByTestId('threshold-edit-0'));
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-unit-invalid-0')).toBeInTheDocument();
});
it('does not flag a threshold unit in the same category as the y-axis unit', async () => {
const user = userEvent.setup();
it('does not flag a threshold unit in the same category as the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
@@ -156,7 +128,7 @@ describe('ThresholdsSection', () => {
/>,
);
await user.click(screen.getByTestId('threshold-edit-0'));
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(
screen.queryByTestId('threshold-unit-invalid-0'),
).not.toBeInTheDocument();

View File

@@ -27,7 +27,6 @@ interface ComparisonThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onLiveChange: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -43,15 +42,10 @@ function ComparisonThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (

View File

@@ -20,7 +20,6 @@ interface LabelThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -33,15 +32,10 @@ function LabelThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Persist an empty-string label when none was entered — the spec requires a string.
const handleSave = useCallback((): void => {

View File

@@ -28,7 +28,6 @@ interface TableThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesTableThresholdDTO) => void;
onLiveChange: (next: DashboardtypesTableThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -46,15 +45,10 @@ function TableThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: TableThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Stored columnName is the query key; resolve its label + configured unit.
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;

View File

@@ -1,5 +1,4 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import useDebouncedFn from 'hooks/useDebouncedFunction';
interface ThresholdDraft<T> {
draft: T;
@@ -8,25 +7,17 @@ interface ThresholdDraft<T> {
setValue: (raw: string) => void;
}
const LIVE_PREVIEW_DEBOUNCE_MS = 150;
/**
* Local draft for a threshold row, shared by every variant. Snapshots the saved
* threshold on each entry into edit mode and exposes the numeric `value` setter all
* variants use. `onLiveChange` mirrors the draft into the spec as the user edits, so the
* panel preview updates live (without Save); the section reverts it on Discard.
* threshold on each entry into edit mode (so Discard simply drops the draft and the
* next edit starts clean) and exposes the numeric `value` setter all variants use.
*/
export function useThresholdDraft<T extends { value: number }>(
threshold: T,
isEditing: boolean,
onLiveChange?: (draft: T) => void,
): ThresholdDraft<T> {
const [draft, setDraft] = useState<T>(threshold);
const emitLiveChange = useDebouncedFn((next) => {
onLiveChange?.(next as T);
}, LIVE_PREVIEW_DEBOUNCE_MS);
useEffect(() => {
if (isEditing) {
setDraft(threshold);
@@ -34,20 +25,6 @@ export function useThresholdDraft<T extends { value: number }>(
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
useEffect(() => {
if (isEditing) {
emitLiveChange(draft);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- propagate on draft change only
}, [draft]);
useEffect(() => {
if (!isEditing) {
emitLiveChange.cancel();
}
return (): void => emitLiveChange.cancel();
}, [isEditing, emitLiveChange]);
const setValue = (raw: string): void => {
const next = Number(raw);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));

View File

@@ -71,8 +71,10 @@ function PreviewPane({
<div className={styles.container}>
<div className={styles.surface}>
<PanelHeader
name={panel.spec.display.name}
description={panel.spec.display.description}
panelId={panelId}
panel={panel}
panelKind={panel.spec.plugin.kind}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}

View File

@@ -1,8 +1,6 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -97,141 +95,4 @@ describe('getSwitchedPluginSpec', () => {
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

@@ -1,18 +1,13 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
type TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
SectionKind,
type ThresholdVariant,
type PanelFormattingSlice,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
@@ -29,73 +24,13 @@ import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
thresholds?: AnyThreshold[];
}
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
function getThresholdVariant(
sections: SectionConfig[],
): ThresholdVariant | undefined {
const section = sections.find(
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
s.kind === SectionKind.Thresholds,
);
return section ? (section.controls.variant ?? 'label') : undefined;
}
/**
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
* the carried threshold stays functional (a comparison/table threshold needs an operator
* to match, a table threshold a column).
*/
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === 'comparison') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === 'table') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
* new kind supports them (remapped to its variant). Switching into a List seeds the
* current signal's default columns so the columns control isn't empty.
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
@@ -131,19 +66,5 @@ export function getSwitchedPluginSpec(
}
}
const thresholdVariant = getThresholdVariant(sections);
if (thresholdVariant) {
const oldThresholds = (
oldSpec.plugin.spec as {
thresholds?: AnyThreshold[] | null;
}
).thresholds;
if (oldThresholds && oldThresholds.length > 0) {
result.thresholds = oldThresholds.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
);
}
}
return result;
}

View File

@@ -1,103 +0,0 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -1,36 +1,40 @@
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 mockPatchAsync = jest.fn().mockResolvedValue(undefined);
let mockIsPatching = false;
jest.mock('../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): {
patchAsync: jest.Mock;
isPatching: boolean;
error: Error | null;
} => ({ patchAsync: mockPatchAsync, isPatching: mockIsPatching, error: null }),
}));
// The hook reads getQueryData only for the isNew branch; a stub client is enough here.
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { getQueryData: jest.Mock } => ({
getQueryData: jest.fn(),
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();
mockIsPatching = false;
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('optimistically patches an add replacing the whole panel spec', async () => {
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' }),
);
@@ -46,17 +50,28 @@ describe('usePanelEditorSave', () => {
await result.current.save(spec);
expect(mockPatchAsync).toHaveBeenCalledWith([
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: 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 patch in-flight state as isSaving', () => {
mockIsPatching = true;
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' }),

View File

@@ -1,36 +0,0 @@
import { useEffect } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
interface UseMetricYAxisUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
isNewPanel: boolean;
unit: string | undefined;
onSelectUnit: (unit: string) => void;
}
interface UseMetricYAxisUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
* from it (V1 parity); returns the unit for the selector's mismatch warning.
*/
export function useMetricYAxisUnit({
isNewPanel,
unit,
onSelectUnit,
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
useEffect(() => {
if (isNewPanel && metricUnit && metricUnit !== unit) {
onSelectUnit(metricUnit);
}
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel, metricUnit]);
return { metricUnit, isLoading };
}

View File

@@ -1,7 +1,10 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { v4 as uuid } from 'uuid';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
@@ -10,7 +13,6 @@ import {
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { createPanelOps } from '../../patchOps';
interface UsePanelEditorSaveArgs {
@@ -41,14 +43,15 @@ export function usePanelEditorSave({
layoutIndex,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
let ops: DashboardtypesJSONPatchOperationDTO[];
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
ops = createPanelOps({
@@ -67,11 +70,11 @@ export function usePanelEditorSave({
];
}
// Optimistic cache write + settle refetch (replaces the manual invalidate).
await patchAsync(ops);
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(dashboardQueryKey);
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
);
return { save, isSaving: isPatching, error };
return { save, isSaving: isLoading, error: (error as Error) ?? null };
}

View File

@@ -8,8 +8,6 @@ import {
import { toast } from '@signozhq/ui/sonner';
import {
type DashboardtypesPanelDTO,
type DashboardtypesPanelFormattingDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -32,7 +30,6 @@ import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
@@ -126,33 +123,6 @@ function PanelEditorContainer({
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
}
).formatting?.unit;
const seedFormattingUnit = useCallback(
(unit: string): void => {
const pluginSpec = spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
};
setSpec({
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
},
} as DashboardtypesPanelSpecDTO);
},
[spec, setSpec],
);
const { metricUnit } = useMetricYAxisUnit({
isNewPanel: isNew,
unit: formattingUnit,
onSelectUnit: seedFormattingUnit,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
@@ -272,8 +242,7 @@ function PanelEditorContainer({
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
panelKind={draft.spec.plugin.kind}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
@@ -281,7 +250,6 @@ function PanelEditorContainer({
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -1,130 +0,0 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { preparePieData } from '../prepareData';
function tableWith(
columns: PanelTable['columns'],
rows: PanelTable['rows'],
overrides: Partial<PanelTable> = {},
): PanelTable {
return { queryName: 'A', legend: '', columns, rows, ...overrides };
}
const args = (tables: PanelTable[]): Parameters<typeof preparePieData>[0] => ({
tables,
isDarkMode: true,
});
describe('preparePieData', () => {
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 23399927, col2: 588691297 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['col1', 23399927],
['col2', 588691297],
]);
});
it('keeps one slice per group row for a single value column', () => {
const table = tableWith(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['adservice', 100],
['cartservice', 200],
]);
});
it('prefixes the group when multiple value columns are grouped', () => {
const table = tableWith(
[
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'prod · col1',
'prod · col2',
]);
});
it('falls back to legend/query name when a single value column has no group', () => {
const table = tableWith(
[{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' }],
[{ data: { A: 42 } }],
{ legend: 'requests' },
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['requests', 42],
]);
});
it('honours customColors over the generated palette', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 10, col2: 20 } }],
);
const slices = preparePieData({
tables: [table],
isDarkMode: true,
customColors: { col1: '#ff0000' },
});
expect(slices[0].color).toBe('#ff0000');
expect(slices[1].color).not.toBe('#ff0000');
});
it('drops non-positive and non-numeric values', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
],
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
});
it('returns no slices for empty tables', () => {
expect(preparePieData(args([]))).toStrictEqual([]);
});
});

View File

@@ -11,7 +11,11 @@ export interface PreparePieDataArgs {
isDarkMode: boolean;
}
/** One pie slice per (row × value column); column name labels slices when a query has several value columns. */
/**
* Turns the scalar tables of a V5 response into pie slices (one per group row):
* value column → value, group column(s) → label. Colours honour `customColors`
* then fall back to the deterministic palette; non-positive/non-numeric dropped.
*/
export function preparePieData({
tables,
customColors,
@@ -23,35 +27,26 @@ export function preparePieData({
const slices: PieSlice[] = [];
tables.forEach((table) => {
const valueColumns = table.columns.filter((column) => column.isValueColumn);
if (valueColumns.length === 0) {
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
const hasMultipleValueColumns = valueColumns.length > 1;
table.rows.forEach((row) => {
const groupLabel = labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ');
valueColumns.forEach((column) => {
let label: string;
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
label = groupLabel || table.legend || table.queryName || '';
}
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({
label,
value: Number(row.data[column.id || column.name]),
color,
});
});
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ') ||
table.legend ||
table.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});

View File

@@ -108,9 +108,7 @@ function addSeries({
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance?.spanGaps)
: true;
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]

View File

@@ -1,35 +1,22 @@
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('parses a duration string into seconds when thresholding', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '5s' })).toBe(5);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '10m' })).toBe(
600,
);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '1h' })).toBe(
3600,
);
it('spans all gaps (true) when unset', () => {
expect(resolveSpanGaps(undefined)).toBe(true);
expect(resolveSpanGaps('')).toBe(true);
});
it('parses a duration string into seconds', () => {
expect(resolveSpanGaps('5s')).toBe(5);
expect(resolveSpanGaps('10m')).toBe(600);
expect(resolveSpanGaps('1h')).toBe(3600);
});
it('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '600' })).toBe(
600,
);
expect(resolveSpanGaps('600')).toBe(600);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: 'abc' })).toBe(
true,
);
});
it('spans all gaps when fillOnlyBelow is explicitly false, ignoring any duration', () => {
expect(resolveSpanGaps({ fillOnlyBelow: false, fillLessThan: '5m' })).toBe(
true,
);
});
it('treats a duration with no fillOnlyBelow flag as a threshold (legacy panels)', () => {
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
expect(resolveSpanGaps('abc')).toBe(true);
});
});

View File

@@ -2,7 +2,6 @@ import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
type DashboardtypesSpanGapsDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
@@ -40,14 +39,15 @@ export function resolveDecimalPrecision(
}
/**
* Resolves `spanGaps` to uPlot's value. `fillOnlyBelow: false` spans every gap regardless
* of `fillLessThan`; a duration with no flag still thresholds (panels predating the flag).
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
* bare seconds number for back-compat.
*/
export function resolveSpanGaps(
spanGaps: DashboardtypesSpanGapsDTO,
fillLessThan: string | undefined,
): boolean | number {
const fillLessThan = spanGaps.fillLessThan;
if (spanGaps.fillOnlyBelow === false || !fillLessThan) {
if (!fillLessThan) {
return true;
}
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)

View File

@@ -41,6 +41,10 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const name = panel.spec.display.name;
const description = panel.spec.display?.description;
const fullKind = panel.spec.plugin.kind;
// A per-panel time preference is surfaced as a header pill. `visualization` is
// common to every plugin-spec variant — localized cast reads it without
// narrowing on kind.
@@ -51,8 +55,7 @@ function Panel({
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const panelDefinition = getPanelDefinition(fullKind);
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
@@ -74,8 +77,10 @@ function Panel({
data-panel-visible={isVisible ? 'true' : 'false'}
>
<PanelHeader
name={name}
description={description}
panelId={panelId}
panel={panel}
panelKind={fullKind}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}

View File

@@ -1,17 +1,17 @@
import { EllipsisVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import styles from './PanelActionsMenu.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
interface PanelActionsMenuProps {
panelId: string;
/** The panel itself — its query seeds "Create Alerts". */
panel: DashboardtypesPanelDTO;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
panelKind: PanelKind;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
@@ -23,12 +23,12 @@ interface PanelActionsMenuProps {
*/
function PanelActionsMenu({
panelId,
panel,
panelKind,
panelActions,
}: PanelActionsMenuProps): JSX.Element | null {
const { items, deleteConfirm } = usePanelActionItems({
panelId,
panel,
panelKind,
panelActions,
});

View File

@@ -1,10 +1,10 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
const mockOpenEditor = jest.fn();
jest.mock(
@@ -29,11 +29,6 @@ jest.mock('../../hooks/useClonePanel', () => ({
useClonePanel: (): jest.Mock => mockClonePanel,
}));
const mockCreateAlert = jest.fn();
jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
@@ -60,20 +55,9 @@ const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
// translation it drives is covered by buildCreateAlertUrl's own tests.
const mockPanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const baseArgs = {
panelId: 'panel-1',
panel: mockPanel,
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
@@ -131,18 +115,29 @@ describe('usePanelActionItems', () => {
]);
});
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
const { result } = renderHook(() =>
// A kind with no registered definition — exercises the "unsupported kind"
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
// so it drops too; only the kind-agnostic layout actions remain.
usePanelActionItems({
...baseArgs,
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
}),
);
expect(itemKeys(result.current)).toStrictEqual([
'move',
'divider',
'delete-panel',
]);
});
it('read-only dashboard keeps only View (V1 parity)', () => {
useDashboardStore.setState({ isEditable: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
// Create Alerts opens a new tab and never mutates the dashboard, so it
// isn't gated on edit access — matching V1's locked-dashboard menu.
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
]);
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
});
it('move is disabled when there is no other titled section to move to', () => {
@@ -264,26 +259,18 @@ describe('usePanelActionItems', () => {
});
});
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const view = result.current.items.find(
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
['view-panel', 'create-alert'].forEach((key) => {
const item = result.current.items.find((i) => 'key' in i && i.key === key);
(item as { onClick: () => void }).onClick();
});
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(alertSpy).toHaveBeenCalledTimes(2);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
alertSpy.mockRestore();
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
});

View File

@@ -10,7 +10,6 @@ import {
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import {
type ConfirmableAction,
@@ -24,13 +23,13 @@ import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -104,8 +103,8 @@ function buildMoveItems({
interface UsePanelActionItemsArgs {
panelId: string;
/** The panel itself — its query seeds the "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
panelKind: PanelKind;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}
@@ -129,10 +128,9 @@ export interface PanelActionItems {
*/
export function usePanelActionItems({
panelId,
panel,
panelKind,
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
@@ -145,7 +143,6 @@ export function usePanelActionItems({
);
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
@@ -154,7 +151,7 @@ export function usePanelActionItems({
const deletePanel = useDeletePanel({ sections });
const clonePanel = useClonePanel({ sections });
const panelCapabilities = getPanelDefinition(panelKind).actions;
const kindActions = getPanelDefinition(panelKind)?.actions;
// Delete runs on confirm, not on click — the menu item opens a prompt.
const deleteConfirm = useConfirmableAction(
@@ -173,7 +170,7 @@ export function usePanelActionItems({
const items = useMemo<MenuItem[]>(() => {
const panelGroup: MenuItem[] = [];
if (panelCapabilities.view) {
if (kindActions?.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
@@ -181,7 +178,7 @@ export function usePanelActionItems({
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && panelCapabilities.edit) {
if (isEditable && canEditWidget && kindActions?.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
@@ -191,7 +188,7 @@ export function usePanelActionItems({
}
// Clone needs the section context (source spec + dimensions) to place the
// copy, so — unlike Edit — it requires panelActions.
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
@@ -205,7 +202,7 @@ export function usePanelActionItems({
}
const dataGroup: MenuItem[] = [];
if (panelCapabilities.download) {
if (kindActions?.download) {
dataGroup.push({
key: 'download-panel',
label: 'Download as CSV',
@@ -213,15 +210,12 @@ export function usePanelActionItems({
onClick: (): void => notImplementedYet('Download'),
});
}
// Seeding an alert opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
// on locked dashboards too).
if (panelCapabilities.createAlert) {
if (isEditable && kindActions?.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
onClick: (): void => createAlert(panel, panelId),
onClick: (): void => notImplementedYet('Create Alerts'),
});
}
@@ -258,13 +252,11 @@ export function usePanelActionItems({
canEditWidget,
canMove,
canDelete,
panelCapabilities,
panel,
kindActions,
panelActions,
sections,
panelId,
openPanelEditor,
createAlert,
movePanel,
clonePanel,
requestDelete,

View File

@@ -1,10 +1,7 @@
import { useMemo } from 'react';
import { Info, Loader } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
@@ -17,12 +14,15 @@ import {
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
name: string;
description?: string;
panelId: string;
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Full plugin kind — drives kind-gated menu actions. */
panelKind: PanelKind;
/** Background refresh in flight — shows a spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error — surfaced as a header error indicator. */
@@ -49,8 +49,10 @@ interface PanelHeaderProps {
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
name,
description,
panelId,
panel,
panelKind,
isFetching,
error,
warning,
@@ -61,8 +63,6 @@ function PanelHeader({
onSearchChange,
hideActions,
}: PanelHeaderProps): JSX.Element {
const name = panel.spec.display.name;
const description = panel.spec.display.description;
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
const warningDetail = useMemo(
@@ -116,7 +116,7 @@ function PanelHeader({
{!hideActions && (
<PanelActionsMenu
panelId={panelId}
panel={panel}
panelKind={panelKind}
panelActions={panelActions}
/>
)}

View File

@@ -1,11 +1,11 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader/PanelHeader';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Status indicators use a radix tooltip, which needs a TooltipProvider ancestor
// (supplied globally by AppLayout at runtime).
@@ -22,26 +22,9 @@ jest.mock(
},
);
// The header reads its name/description/kind off the panel itself.
function makePanel(overrides?: {
name?: string;
description?: string;
}): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: {
name: overrides?.name ?? 'My panel',
description: overrides?.description,
},
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
panel: makePanel(),
name: 'My panel',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panelId: 'panel-1',
isFetching: false,
};
@@ -61,10 +44,7 @@ describe('PanelHeader title and description', () => {
it('shows the description info icon when a description is provided', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanel({ description: 'What this panel measures' })}
/>,
<PanelHeader {...baseProps} description="What this panel measures" />,
);
expect(screen.getByTestId('panel-header-info-icon')).toBeInTheDocument();
});

View File

@@ -1,15 +1,12 @@
import { renderHook } from '@testing-library/react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import type { DashboardSection } from '../../../../utils';
import { useClonePanel } from '../useClonePanel';
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
jest.mock('../../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
patchAsync: mockPatchAsync,
isPatching: false,
}),
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
}));
const mockToastPromise = jest.fn();
@@ -19,6 +16,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
const sourcePanel = {
kind: 'Panel',
spec: {
@@ -46,7 +45,7 @@ function sections(): DashboardSection[] {
describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
@@ -54,7 +53,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockPatchAsync).toHaveBeenCalledWith([
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
{
op: 'add',
path: '/spec/panels/cloned-id',
@@ -93,7 +92,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatchAsync.mock.calls[0][0];
const ops = mockPatch.mock.calls[0][1];
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
});
@@ -103,7 +102,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatchAsync.mock.calls[0][0];
const ops = mockPatch.mock.calls[0][1];
expect(ops[0].value).toStrictEqual(sourcePanel);
expect(ops[0].value).not.toBe(sourcePanel);
});
@@ -113,7 +112,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'missing', layoutIndex: 0 });
expect(mockPatchAsync).not.toHaveBeenCalled();
expect(mockPatch).not.toHaveBeenCalled();
expect(mockToastPromise).not.toHaveBeenCalled();
});
@@ -133,7 +132,7 @@ describe('useClonePanel', () => {
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
mockPatch.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await expect(

View File

@@ -1,69 +0,0 @@
import { renderHook } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useCreateAlertFromPanel } from '../useCreateAlertFromPanel';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
// The V5→V1 query→URL translation is covered by buildCreateAlertUrl's own tests;
// stub it so this asserts only the hook's side effects (analytics + navigation).
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
}));
const mockLogEvent = logEvent as jest.Mock;
const panel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
describe('useCreateAlertFromPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
});
it('opens the seeded alert builder in a new tab', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=1', {
newTab: true,
});
});
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockLogEvent).toHaveBeenCalledWith(
'Dashboard Detail: Panel action',
expect.objectContaining({
action: 'createAlerts',
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: 'dash-1',
widgetId: 'panel-1',
}),
);
});
});

View File

@@ -3,7 +3,8 @@ import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import {
addPanelToSectionOps,
findFreeSlot,
@@ -31,7 +32,7 @@ export function useClonePanel({
sections,
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
@@ -44,7 +45,8 @@ export function useClonePanel({
const newPanelId = uuid();
const { x, y } = findFreeSlot(section.items, source.width);
const clone = patchAsync(
const clone = patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId: newPanelId,
panel: cloneDeep(source.panel),
@@ -66,14 +68,15 @@ export function useClonePanel({
position: 'top-center',
});
// toast.promise owns the error UX; swallow here to avoid an unhandled
// rejection (the optimistic cache write + settle refetch handle state).
// Refetch only on success; toast.promise owns the error UX, so swallow
// the rejection to avoid an unhandled rejection.
try {
await clone;
refetch();
} catch {
// no-op
}
},
[sections, dashboardId, patchAsync],
[sections, dashboardId, refetch],
);
}

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
/**
* Returns a callback that opens the alert builder in a new tab, seeded from a
* panel's query, and logs the action — mirroring V1's `useCreateAlerts`
* ('dashboardView' caller). The panel is supplied at call time so the callback
* stays stable across panels (and the dashboard's react-query refetches).
*/
export function useCreateAlertFromPanel(): (
panel: DashboardtypesPanelDTO,
panelId: string,
) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
});
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
},
[dashboardId, safeNavigate],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -25,7 +25,7 @@ export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -40,14 +40,15 @@ export function useDeletePanel({
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchAsync([
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -27,7 +27,7 @@ export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -60,7 +60,8 @@ export function useMovePanelToSection({
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchAsync(
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
@@ -68,10 +69,11 @@ export function useMovePanelToSection({
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,103 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { buildCreateAlertUrl } from '../buildCreateAlertUrl';
// The V5→V1 translation has its own coverage; stub it so this asserts only the
// URL assembly (params, encoding, unit) buildCreateAlertUrl owns.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
fromPerses: jest.fn(),
}),
);
const mockFromPerses = fromPerses as jest.Mock;
const translatedQuery: Query = {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
id: 'q1',
};
function makePanel(
overrides?: Partial<{ unit: string; queries: unknown[] }>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: overrides?.unit ? { formatting: { unit: overrides.unit } } : {},
},
queries: overrides?.queries ?? [{ some: 'query' }],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('buildCreateAlertUrl', () => {
beforeEach(() => {
mockFromPerses.mockReset();
mockFromPerses.mockReturnValue({ ...translatedQuery });
});
function parse(url: string): URLSearchParams {
expect(url.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
return new URLSearchParams(url.slice(url.indexOf('?') + 1));
}
it('translates the panel queries with the mapped panel type', () => {
const panel = makePanel();
buildCreateAlertUrl(panel);
expect(mockFromPerses).toHaveBeenCalledWith(
panel.spec.queries,
PANEL_TYPES.TIME_SERIES,
);
});
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('encodes the translated query as the compositeQuery param', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const raw = params.get(QueryParams.compositeQuery);
expect(raw).toBeTruthy();
const decoded = JSON.parse(decodeURIComponent(raw as string));
expect(decoded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(decoded.id).toBe('q1');
});
it('carries the panel formatting unit onto the alert query when set', () => {
const params = parse(buildCreateAlertUrl(makePanel({ unit: 'bytes' })));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBe('bytes');
});
it('leaves the query unit unset when the panel has no formatting unit', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBeUndefined();
});
});

View File

@@ -1,54 +0,0 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/NumberPanel':
case 'signoz/PieChartPanel':
return plugin.spec.formatting?.unit;
default:
return undefined;
}
}
/**
* Builds the `/alerts/new` URL that seeds the alert builder from a panel's query,
* mirroring V1's `useCreateAlerts`: the panel's V5 queries are translated to the
* V1 `Query` the alert page reads from `compositeQuery`, tagged with the panel
* type, entity version, and a `dashboards` source.
*
* Unlike V1 there is no `/substitute_vars` round-trip — V2 has no query-variable
* plumbing yet, so any dashboard-variable references travel through verbatim.
*/
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
if (unit) {
query.unit = unit;
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import {
addSectionOp,
newGridLayout,
@@ -15,9 +15,9 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the appended section to render, then scrolls it into view.
* Polls because the optimistic cache write commits to the DOM a frame or two after
* the patch call; bails after ~40 frames.
* Waits (via rAF) for the refetch to render the appended section, then scrolls
* it into view. Polls because `refetch` resolves before React commits the new
* section to the DOM; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
@@ -49,7 +49,7 @@ interface Result {
*/
export function useAddSection({ layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -66,7 +66,8 @@ export function useAddSection({ layouts }: Params): Result {
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchAsync([op]);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
@@ -74,7 +75,7 @@ export function useAddSection({ layouts }: Params): Result {
setIsSaving(false);
}
},
[layouts, dashboardId, patchAsync, showErrorModal],
[layouts, dashboardId, refetch, showErrorModal],
);
return { addSection, isSaving };

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { removePanelOp, removeSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -24,7 +24,7 @@ interface Result {
*/
export function useDeleteSection({ section }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -38,13 +38,14 @@ export function useDeleteSection({ section }: Params): Result {
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, patchAsync, showErrorModal]);
}, [section, dashboardId, refetch, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -26,7 +26,7 @@ interface Result {
*/
export function useFirstSectionMigration({ sections }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -49,14 +49,15 @@ export function useFirstSectionMigration({ sections }: Params): Result {
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
return { migrate, isSaving };

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
@@ -65,7 +65,7 @@ function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
*/
export function usePersistLayout({ layoutIndex, items }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -80,14 +80,17 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
}
try {
setIsSaving(true);
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, patchAsync, showErrorModal],
[dashboardId, items, layoutIndex, refetch, showErrorModal],
);
return { handleLayoutChange, isSaving };

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { renameSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
@@ -19,7 +19,7 @@ interface Result {
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({ layoutIndex }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -31,7 +31,10 @@ export function useRenameSection({ layoutIndex }: Params): Result {
}
try {
setIsSaving(true);
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -40,7 +43,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, patchAsync, showErrorModal],
[dashboardId, layoutIndex, refetch, showErrorModal],
);
return { rename, isSaving };

View File

@@ -9,11 +9,11 @@ import {
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { reorderLayoutsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -43,7 +43,7 @@ interface Result {
*/
export function useSectionDragReorder({ sections, layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
@@ -99,13 +99,14 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchAsync([reorderLayoutsOp(newLayouts)]);
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, patchAsync, showErrorModal],
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
);
const activeSection = useMemo(

View File

@@ -1,107 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useMutation, useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- the hook's own test mocks and asserts the underlying patchDashboardV2 call.
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { useOptimisticPatch } from '../useOptimisticPatch';
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
jest.mock('react-query', () => ({
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string }) => unknown) =>
selector({ dashboardId: 'dash-1' }),
),
}));
const queryClient = {
cancelQueries: jest.fn().mockResolvedValue(undefined),
getQueryData: jest.fn(),
setQueryData: jest.fn(),
invalidateQueries: jest.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let captured: { fn: (ops: any) => unknown; options: any };
function dashboardEnvelope(name: string): GetDashboardV2200 {
return {
data: { spec: { display: { name } } },
} as unknown as GetDashboardV2200;
}
const replaceNameOp = {
op: DashboardtypesPatchOpDTO.replace,
path: '/spec/display/name',
value: 'B',
};
beforeEach(() => {
jest.clearAllMocks();
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
(useMutation as jest.Mock).mockImplementation((fn, options) => {
captured = { fn, options };
return { mutateAsync: jest.fn(), isLoading: false };
});
renderHook(() => useOptimisticPatch());
});
describe('useOptimisticPatch', () => {
it('mutationFn sends the ops to patchDashboardV2 for the current dashboard', () => {
captured.fn([replaceNameOp]);
expect(patchDashboardV2).toHaveBeenCalledWith({ id: 'dash-1' }, [
replaceNameOp,
]);
});
it('onMutate cancels fetches, snapshots, and writes the patched dashboard to the cache', async () => {
const previous = dashboardEnvelope('A');
queryClient.getQueryData.mockReturnValue(previous);
const context = await captured.options.onMutate([replaceNameOp]);
expect(queryClient.cancelQueries).toHaveBeenCalledWith(QUERY_KEY);
// Optimistic write reflects the op immediately.
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, {
data: { spec: { display: { name: 'B' } } },
});
// Snapshot returned for rollback; original left untouched.
expect(context).toStrictEqual({ previous });
expect(previous.data).toStrictEqual({ spec: { display: { name: 'A' } } });
});
it('onMutate is a no-op write when there is no cached dashboard', async () => {
queryClient.getQueryData.mockReturnValue(undefined);
const context = await captured.options.onMutate([replaceNameOp]);
expect(queryClient.setQueryData).not.toHaveBeenCalled();
expect(context).toStrictEqual({ previous: undefined });
});
it('onError rolls the cache back to the snapshot', () => {
const previous = dashboardEnvelope('A');
captured.options.onError(new Error('boom'), [replaceNameOp], { previous });
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, previous);
});
it('onError without a snapshot does not touch the cache', () => {
captured.options.onError(new Error('boom'), [replaceNameOp], {});
expect(queryClient.setQueryData).not.toHaveBeenCalled();
});
it('onSettled invalidates the dashboard query to reconcile', () => {
captured.options.onSettled();
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(QUERY_KEY);
});
});

View File

@@ -1,67 +0,0 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { selectResolvedVariables } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useResolvedVariables } from '../useResolvedVariables';
// A text variable is the simplest envelope (no list plugin); the builder's full
// type/value matrix is covered in buildVariablesPayload.test.ts. The envelope is
// cast at the boundary — its kind discriminant is the literal 'TextVariable'.
function textVariable(name: string, value: string): DashboardtypesVariableDTO {
return {
kind: 'TextVariable',
spec: { name, value, display: { name } },
} as unknown as DashboardtypesVariableDTO;
}
function dashboard(
id: string,
variables: DashboardtypesVariableDTO[],
): DashboardtypesGettableDashboardV2DTO {
return {
id,
spec: { variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('useResolvedVariables', () => {
afterEach(() => {
useDashboardStore.setState({ variableValues: {}, resolvedVariables: {} });
});
it('publishes the resolved V5 payload for the dashboard to the store', () => {
renderHook(() =>
useResolvedVariables(dashboard('d1', [textVariable('env', 'prod')])),
);
expect(
selectResolvedVariables('d1')(useDashboardStore.getState()),
).toStrictEqual({ env: { type: 'text', value: 'prod' } });
});
it('reflects the runtime selection over the configured default', () => {
useDashboardStore
.getState()
.setVariableValues('d2', { env: { value: 'staging', allSelected: false } });
renderHook(() =>
useResolvedVariables(dashboard('d2', [textVariable('env', 'prod')])),
);
expect(
selectResolvedVariables('d2')(useDashboardStore.getState()),
).toStrictEqual({ env: { type: 'text', value: 'staging' } });
});
it('publishes an empty payload when the dashboard has no variables', () => {
renderHook(() => useResolvedVariables(dashboard('d3', [])));
expect(
selectResolvedVariables('d3')(useDashboardStore.getState()),
).toStrictEqual({});
});
});

View File

@@ -1,73 +0,0 @@
import { useMutation, useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
// eslint-disable-next-line no-restricted-imports -- this hook is the one sanctioned caller of patchDashboardV2; everything else goes through patchAsync.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { useDashboardStore } from '../store/useDashboardStore';
/** Cached dashboard snapshot, kept for rollback on error. */
interface OptimisticPatchContext {
previous?: GetDashboardV2200;
}
export interface UseOptimisticPatch {
patchAsync: (ops: DashboardtypesJSONPatchOperationDTO[]) => Promise<unknown>;
isPatching: boolean;
error: Error | null;
}
/**
* Central optimistic mutation for V2 dashboard spec edits: writes the ops to the
* cached dashboard immediately, rolls back on error, reconciles on settle.
* `dashboardId` defaults to the edit-context store; the panel editor passes its own.
*/
export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const mutation = useMutation<
Awaited<ReturnType<typeof patchDashboardV2>>,
APIError,
DashboardtypesJSONPatchOperationDTO[],
OptimisticPatchContext
>((ops) => patchDashboardV2({ id: dashboardId }, ops), {
onMutate: async (ops) => {
await queryClient.cancelQueries(queryKey);
const previous = queryClient.getQueryData<GetDashboardV2200>(queryKey);
if (previous?.data) {
// Ops are rooted at the DTO's `/spec`, so patch `.data`, keep the envelope.
queryClient.setQueryData<GetDashboardV2200>(queryKey, {
...previous,
data: applyJsonPatch(previous.data, ops),
});
}
return { previous };
},
onError: (_error, _ops, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
void queryClient.invalidateQueries(queryKey);
},
});
return {
patchAsync: mutation.mutateAsync,
isPatching: mutation.isLoading,
error: mutation.error ?? null,
};
}

View File

@@ -17,8 +17,6 @@ import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
@@ -67,9 +65,8 @@ export interface UsePanelQueryResult {
/**
* Fetches query-range data for a V2 panel over the pure-V5 contract: builds the request DTO
* from the panel's perses queries (no V1 `Query` intermediary), reads global time from Redux,
* substitutes the dashboard's resolved variable values (published to the store by
* `useResolvedVariables`), and posts via `useGetQueryRangeV5`. Renderers consume the raw
* response through the `queryV5` prep utils.
* and posts via `useGetQueryRangeV5`. Variable substitution is deferred until V2 has its own
* variable plumbing. Renderers consume the raw response through the `queryV5` prep utils.
*/
export function usePanelQuery({
panel,
@@ -108,11 +105,6 @@ export function usePanelQuery({
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
// Substituted into the request and keyed into the cache so a selection change refetches.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
const pluginSpec = panel.spec.plugin.spec;
@@ -149,19 +141,8 @@ export function usePanelQuery({
endMs,
fillGaps,
pagination: isPaginated ? { offset, limit: pageSize } : undefined,
variables,
}),
[
queries,
panelType,
startMs,
endMs,
fillGaps,
isPaginated,
offset,
pageSize,
variables,
],
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
@@ -186,8 +167,6 @@ export function usePanelQuery({
// Each page is its own cache entry (0/default for non-paged kinds).
offset,
pageSize,
// Variable selection changes the request, so it must re-key the cache (refetch).
variables,
],
[
panelId,
@@ -203,7 +182,6 @@ export function usePanelQuery({
queries,
offset,
pageSize,
variables,
],
);

View File

@@ -1,42 +0,0 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import { buildVariablesPayload } from '../queryV5/buildVariablesPayload';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Resolves the dashboard's variable selection into the V5 query payload and
* publishes it to the store, so `usePanelQuery` reads it by dashboardId without
* the spec being threaded through the panel tree (the `setEditContext` pattern).
*
* Definitions come from the spec; values come from the runtime selection (seeded
* by the variable bar). Re-publishes whenever either changes, which re-keys the
* panel queries and triggers a refetch with the new values.
*/
export function useResolvedVariables(
dashboard: DashboardtypesGettableDashboardV2DTO,
): void {
const dashboardId = dashboard.id ?? '';
const definitions = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setResolvedVariables = useDashboardStore((s) => s.setResolvedVariables);
const resolved = useMemo(
() => buildVariablesPayload(definitions, selection),
[definitions, selection],
);
useEffect(() => {
if (!dashboardId) {
return;
}
setResolvedVariables(dashboardId, resolved);
}, [dashboardId, resolved, setResolvedVariables]);
}

View File

@@ -7,7 +7,6 @@ import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
@@ -51,10 +50,6 @@ function DashboardContainer({
setEditContext,
]);
// Resolve the variable selection into the V5 query payload and publish it to
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;

View File

@@ -1,138 +0,0 @@
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { applyJsonPatch } from '../applyJsonPatch';
const { add, replace, remove, move, test: testOp } = DashboardtypesPatchOpDTO;
function op(
o: DashboardtypesPatchOpDTO,
path: string,
value?: unknown,
): DashboardtypesJSONPatchOperationDTO {
return { op: o, path, value };
}
// A trimmed dashboard-spec shape; the applier is structural, so this stands in
// for the full DTO.
function spec(): Record<string, unknown> {
return {
spec: {
display: { name: 'dash' },
panels: { p1: { spec: { display: { name: 'A' } } } },
layouts: [
{ spec: { display: { title: 'S1' }, items: [{ x: 0 }] } },
{ spec: { items: [] } },
],
variables: [{ name: 'env' }],
},
};
}
describe('applyJsonPatch', () => {
it('does not mutate the input document', () => {
const doc = spec();
const snapshot = JSON.stringify(doc);
applyJsonPatch(doc, [op(replace, '/spec/display/name', 'renamed')]);
expect(JSON.stringify(doc)).toBe(snapshot);
});
it('replaces a leaf string', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/layouts/0/spec/display/title', 'S1-renamed'),
]);
const layouts = (next.spec as any).layouts;
expect(layouts[0].spec.display.title).toBe('S1-renamed');
});
it('adds a new object member (panel by id)', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/panels/p2', { spec: { display: { name: 'B' } } }),
]);
expect((next.spec as any).panels.p2.spec.display.name).toBe('B');
// existing member untouched
expect((next.spec as any).panels.p1.spec.display.name).toBe('A');
});
it('appends to an array with the "-" token', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/-', { spec: { items: [] } }),
]);
expect((next.spec as any).layouts).toHaveLength(3);
});
it('appends an item into a nested section array', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/1/spec/items/-', { x: 5 }),
]);
expect((next.spec as any).layouts[1].spec.items).toStrictEqual([{ x: 5 }]);
});
it('replaces a whole array', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/variables', [{ name: 'region' }, { name: 'pod' }]),
]);
expect((next.spec as any).variables).toStrictEqual([
{ name: 'region' },
{ name: 'pod' },
]);
});
it('removes an array element by index (section)', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/layouts/0')]);
const layouts = (next.spec as any).layouts;
expect(layouts).toHaveLength(1);
expect(layouts[0].spec.items).toStrictEqual([]);
});
it('removes an object member (panel by id)', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/p1')]);
expect((next.spec as any).panels).toStrictEqual({});
});
it('adds a missing object parent for an add op (title untitled section)', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/1/spec/display', { title: 'S2' }),
]);
expect((next.spec as any).layouts[1].spec.display).toStrictEqual({
title: 'S2',
});
});
it('is lenient: remove on a missing path is a no-op', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/ghost')]);
expect((next.spec as any).panels.p1).toBeDefined();
});
it('is lenient: a path through a missing node is skipped', () => {
const next = applyJsonPatch(spec(), [op(replace, '/spec/nope/deep/leaf', 1)]);
expect(next).toStrictEqual(spec());
});
it('unescapes ~1 and ~0 in reference tokens', () => {
const doc = { spec: { m: { 'a/b': 1, 'c~d': 2 } } };
const next = applyJsonPatch(doc, [
op(replace, '/spec/m/a~1b', 9),
op(replace, '/spec/m/c~0d', 8),
]);
expect(next.spec.m).toStrictEqual({ 'a/b': 9, 'c~d': 8 });
});
it('applies multiple ops in order', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/panels/p2', { spec: {} }),
op(remove, '/spec/panels/p1'),
op(replace, '/spec/display/name', 'z'),
]);
expect(Object.keys((next.spec as any).panels)).toStrictEqual(['p2']);
expect((next.spec as any).display.name).toBe('z');
});
it('treats move/copy/test as no-ops', () => {
const next = applyJsonPatch(spec(), [
op(move, '/spec/display/name'),
op(testOp, '/spec/display/name', 'dash'),
]);
expect(next).toStrictEqual(spec());
});
});

View File

@@ -1,146 +0,0 @@
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
/**
* Applies the RFC-6902 ops our `patchOps` builders emit to a document, so a
* dashboard edit can be reflected in the react-query cache optimistically before
* the server responds. Pure: deep-clones and returns a new document, never
* mutating the input.
*
* Deliberately lenient — mirrors the backend's apply (a `remove`/`replace` on a
* missing path is a no-op, `add` creates missing object parents) rather than
* throwing as strict RFC-6902 would. This is safe because the mutation always
* refetches on settle, so any mis-applied edge op self-corrects; the applier only
* needs to be right for the common case to kill the perceived lag.
*
* Scope: `add` / `replace` / `remove` (the only ops the builders produce).
* `move` / `copy` / `test` are never emitted, so they are treated as no-ops.
*/
export function applyJsonPatch<T>(
doc: T,
ops: DashboardtypesJSONPatchOperationDTO[],
): T {
const next = cloneDeep(doc);
ops.forEach((op) => applyOperation(next as unknown, op));
return next;
}
type JsonRecord = Record<string, unknown>;
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Unescape one JSON-Pointer reference token (RFC-6901): `~1`→`/`, `~0`→`~`. */
function unescapeToken(token: string): string {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
/** Parse a JSON Pointer into its reference tokens (`""`/`"/"` → root, `[]`). */
function parsePointer(path: string): string[] {
if (!path || path === '/') {
return [];
}
return path.slice(1).split('/').map(unescapeToken);
}
/**
* Walks to the container that holds the pointer's last token. Returns `undefined`
* when the path can't be resolved (lenient skip). For `add`, missing intermediate
* object nodes are created (backend parity); array steps are never auto-created.
*/
function navigateToParent(
root: unknown,
tokens: string[],
createMissing: boolean,
): unknown {
let current: unknown = root;
for (let i = 0; i < tokens.length - 1; i += 1) {
const token = tokens[i];
if (isArray(current)) {
const index = token === '-' ? current.length : Number(token);
current = current[index];
} else if (isRecord(current)) {
if (current[token] === undefined && createMissing) {
current[token] = {};
}
current = current[token];
} else {
return undefined;
}
if (current === undefined || current === null) {
return undefined;
}
}
return current;
}
/** `add`: array-insert (`-` = append) or object-set. */
function addAt(parent: unknown, key: string, value: unknown): void {
if (isArray(parent)) {
const index = key === '-' ? parent.length : Number(key);
parent.splice(index, 0, value);
} else if (isRecord(parent)) {
parent[key] = value;
}
}
/** `replace`: overwrite an in-range array index or an object key. */
function replaceAt(parent: unknown, key: string, value: unknown): void {
if (isArray(parent)) {
const index = Number(key);
if (index >= 0 && index < parent.length) {
parent[index] = value;
}
} else if (isRecord(parent)) {
parent[key] = value;
}
}
/** `remove`: splice an in-range array index or delete an object key (lenient). */
function removeAt(parent: unknown, key: string): void {
if (isArray(parent)) {
const index = Number(key);
if (index >= 0 && index < parent.length) {
parent.splice(index, 1);
}
} else if (isRecord(parent)) {
delete parent[key];
}
}
function applyOperation(
root: unknown,
op: DashboardtypesJSONPatchOperationDTO,
): void {
const tokens = parsePointer(op.path);
// Whole-document ops would need to reassign the root reference — our builders
// never target root, so skip rather than complicate the contract.
if (tokens.length === 0) {
return;
}
const parent = navigateToParent(
root,
tokens,
op.op === DashboardtypesPatchOpDTO.add,
);
if (parent === undefined || parent === null) {
return;
}
const key = tokens[tokens.length - 1];
// move / copy / test are never emitted by our builders → no-op (reconciled by refetch).
if (op.op === DashboardtypesPatchOpDTO.add) {
addAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
replaceAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
removeAt(parent, key);
}
}

View File

@@ -1,103 +0,0 @@
import {
emptyVariableFormModel,
type VariableFormModel,
type VariableType,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
import { buildVariablesPayload } from '../buildVariablesPayload';
function variable(
name: string,
type: VariableType,
overrides: Partial<VariableFormModel> = {},
): VariableFormModel {
return { ...emptyVariableFormModel(), name, type, ...overrides };
}
describe('buildVariablesPayload', () => {
it('returns an empty map when there are no definitions', () => {
expect(buildVariablesPayload([], {})).toStrictEqual({});
});
it('maps each UI variable type to its V5 wire type', () => {
const definitions = [
variable('q', 'QUERY'),
variable('c', 'CUSTOM'),
variable('t', 'TEXT'),
variable('d', 'DYNAMIC'),
];
const selection: VariableSelectionMap = {
q: { value: 'a', allSelected: false },
c: { value: 'b', allSelected: false },
t: { value: 'c', allSelected: false },
d: { value: 'e', allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
q: { type: 'query', value: 'a' },
c: { type: 'custom', value: 'b' },
t: { type: 'text', value: 'c' },
d: { type: 'dynamic', value: 'e' },
});
});
it('passes a multi-select array value through verbatim', () => {
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
const selection: VariableSelectionMap = {
svc: { value: ['a', 'b'], allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
svc: { type: 'query', value: ['a', 'b'] },
});
});
it('collapses a multi-select dynamic ALL selection to the __all__ sentinel', () => {
const definitions = [variable('pod', 'DYNAMIC', { multiSelect: true })];
const selection: VariableSelectionMap = {
pod: { value: null, allSelected: true },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
pod: { type: 'dynamic', value: '__all__' },
});
});
it('does NOT collapse a query ALL selection — it sends the full value array', () => {
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
const selection: VariableSelectionMap = {
svc: { value: ['a', 'b'], allSelected: true },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
svc: { type: 'query', value: ['a', 'b'] },
});
});
it('falls back to a text variable configured value when unselected', () => {
const definitions = [variable('env', 'TEXT', { textValue: 'prod' })];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
env: { type: 'text', value: 'prod' },
});
});
it('falls back to a list variable configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', {
defaultValue: { value: 'us-east' },
} as unknown as Partial<VariableFormModel>),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: 'us-east' },
});
});
it('omits a variable with no selection and no default', () => {
const definitions = [variable('q', 'QUERY')];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});
});
it('omits an unnamed variable', () => {
const definitions = [variable('', 'QUERY')];
const selection: VariableSelectionMap = {
'': { value: 'x', allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({});
});
});

View File

@@ -6,7 +6,6 @@ import type {
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
@@ -203,13 +202,11 @@ export interface BuildQueryRangeRequestArgs {
fillGaps?: boolean;
/** Server-side paging for raw/list panels, written onto the builder queries' `offset`/`limit`. */
pagination?: { offset: number; limit: number };
/** Runtime variable values (name → {type,value}) substituted server-side; built by `buildVariablesPayload`. */
variables?: Querybuildertypesv5QueryRangeRequestDTOVariables;
}
/**
* Builds the V5 query-range request DTO directly from the panel's perses queries (no V1 `Query`
* intermediary). `variables` carries the runtime selection (empty when the dashboard has none).
* intermediary). Variables are absent (`variables: {}`) until V2 grows its own variable plumbing.
*/
export function buildQueryRangeRequest({
queries,
@@ -218,7 +215,6 @@ export function buildQueryRangeRequest({
endMs,
fillGaps = false,
pagination,
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (panelType === PANEL_TYPES.BAR) {
@@ -238,7 +234,7 @@ export function buildQueryRangeRequest({
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
fillGaps,
},
variables,
variables: {},
};
}

View File

@@ -1,105 +0,0 @@
import type {
Querybuildertypesv5QueryRangeRequestDTOVariables,
Querybuildertypesv5VariableItemDTOValue,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from '../VariablesBar/selectionTypes';
/**
* Backend sentinel for "every value selected" on a multi-select dynamic variable.
* V1 parity (`getDashboardVariables`): only dynamic vars collapse to `__all__`;
* query/custom multi-selects send the full value array instead. Lowercase — the
* URL/store `__ALL__` sentinel is a separate serialization concern.
*/
const ALL_VALUES_SENTINEL = '__all__';
/** UI variable grouping → the V5 wire `variables[].type`. */
const VARIABLE_TYPE_TO_DTO: Record<
VariableType,
Querybuildertypesv5VariableTypeDTO
> = {
QUERY: Querybuildertypesv5VariableTypeDTO.query,
CUSTOM: Querybuildertypesv5VariableTypeDTO.custom,
TEXT: Querybuildertypesv5VariableTypeDTO.text,
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
};
/** The variable's configured default, used when nothing is selected yet. */
function configuredDefault(
definition: VariableFormModel,
): SelectedVariableValue | undefined {
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
return (
definition.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
}
/**
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
* user's selection, else the configured default. Returns `undefined` when there
* is nothing meaningful to send (the variable is then omitted from the payload).
*/
function resolveValue(
definition: VariableFormModel,
selection: VariableSelection | undefined,
): Querybuildertypesv5VariableItemDTOValue | undefined {
if (
definition.type === 'DYNAMIC' &&
definition.multiSelect &&
selection?.allSelected
) {
return ALL_VALUES_SENTINEL;
}
const selected = selection?.value;
const hasSelection =
selected !== null &&
selected !== undefined &&
!(typeof selected === 'string' && selected === '');
if (hasSelection) {
return selected as Querybuildertypesv5VariableItemDTOValue;
}
const fallback = configuredDefault(definition);
return fallback == null
? undefined
: (fallback as Querybuildertypesv5VariableItemDTOValue);
}
/**
* Builds the V5 `variables` map from the dashboard's variable definitions and the
* runtime selection, so a panel query substitutes the values the user picked in
* the variable bar (V1 parity with `getDashboardVariables` + the V5 prep). The
* definition list supplies the wire `type` (the selection map carries only values).
*/
export function buildVariablesPayload(
definitions: VariableFormModel[],
selection: VariableSelectionMap,
): Querybuildertypesv5QueryRangeRequestDTOVariables {
const payload: Querybuildertypesv5QueryRangeRequestDTOVariables = {};
definitions.forEach((definition) => {
if (!definition.name) {
return;
}
const value = resolveValue(definition, selection[definition.name]);
if (value === undefined) {
return;
}
payload[definition.name] = {
type: VARIABLE_TYPE_TO_DTO[definition.type],
value,
};
});
return payload;
}

View File

@@ -1,4 +1,3 @@
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
import type { StateCreator } from 'zustand';
import type {
@@ -13,19 +12,9 @@ import type { DashboardStore } from '../useDashboardStore';
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*
* `resolvedVariables` is the same selection resolved into the V5 query payload
* shape (`{ name: { type, value } }`), published by `useResolvedVariables` so
* `usePanelQuery` reads it without threading the dashboard spec down the tree
* (the edit-context publish pattern). Transient — not persisted (it is derived
* from `variableValues` + the spec on every load).
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
resolvedVariables: Record<
string,
Querybuildertypesv5QueryRangeRequestDTOVariables
>;
setVariableValue: (
dashboardId: string,
name: string,
@@ -33,11 +22,6 @@ export interface VariableSelectionSlice {
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
/** Publish the resolved V5 variables payload for a dashboard. */
setResolvedVariables: (
dashboardId: string,
variables: Querybuildertypesv5QueryRangeRequestDTOVariables,
) => void;
}
export const createVariableSelectionSlice: StateCreator<
@@ -47,7 +31,6 @@ export const createVariableSelectionSlice: StateCreator<
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
resolvedVariables: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
@@ -63,12 +46,6 @@ export const createVariableSelectionSlice: StateCreator<
variableValues: { ...variableValues, [dashboardId]: values },
});
},
setResolvedVariables: (dashboardId, variables): void => {
const { resolvedVariables } = get();
set({
resolvedVariables: { ...resolvedVariables, [dashboardId]: variables },
});
},
});
/**
@@ -83,13 +60,3 @@ export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? EMPTY_SELECTION_MAP;
/** Stable empty payload — same rationale as {@link EMPTY_SELECTION_MAP}. */
const EMPTY_RESOLVED_VARIABLES: Querybuildertypesv5QueryRangeRequestDTOVariables =
{};
/** Selector: the resolved V5 variables payload for a dashboard (empty if none). */
export const selectResolvedVariables =
(dashboardId: string) =>
(state: DashboardStore): Querybuildertypesv5QueryRangeRequestDTOVariables =>
state.resolvedVariables[dashboardId] ?? EMPTY_RESOLVED_VARIABLES;

View File

@@ -145,5 +145,86 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -39,6 +39,15 @@ type AuthZ interface {
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
// Gets the objects associated with the given role and relation.
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*coretypes.Object, error)
// Patches the role.
Patch(context.Context, valuer.UUID, *authtypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*coretypes.Object, []*coretypes.Object) error
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
@@ -93,8 +102,14 @@ type Handler interface {
Get(http.ResponseWriter, *http.Request)
GetObjects(http.ResponseWriter, *http.Request)
List(http.ResponseWriter, *http.Request)
Patch(http.ResponseWriter, *http.Request)
PatchObjects(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
Check(http.ResponseWriter, *http.Request)

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