mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-01 20:30:37 +01:00
Compare commits
12 Commits
nv/layout-
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
488435e81c | ||
|
|
550887a223 | ||
|
|
4341d78727 | ||
|
|
0ae7b74753 | ||
|
|
ab51fc7aa7 | ||
|
|
690f88d649 | ||
|
|
88ae48c8ed | ||
|
|
1648fce5b1 | ||
|
|
f93a70884a | ||
|
|
e1c586e0dc | ||
|
|
984b2d0138 | ||
|
|
3ea62d3d50 |
@@ -618,13 +618,6 @@ components:
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesPatchableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- description
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
@@ -2536,22 +2529,6 @@ components:
|
||||
- resource
|
||||
- selectors
|
||||
type: object
|
||||
CoretypesPatchableObjects:
|
||||
properties:
|
||||
additions:
|
||||
items:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
deletions:
|
||||
items:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- additions
|
||||
- deletions
|
||||
type: object
|
||||
CoretypesResourceRef:
|
||||
properties:
|
||||
kind:
|
||||
@@ -2737,6 +2714,14 @@ components:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
filterBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
panelId:
|
||||
type: string
|
||||
panelName:
|
||||
@@ -5412,6 +5397,9 @@ components:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
ingestedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5424,9 +5412,9 @@ components:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
retainedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
retainedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -5444,7 +5432,8 @@ components:
|
||||
- active
|
||||
- ingestedSeries
|
||||
- retainedSeries
|
||||
- reductionPercent
|
||||
- ingestedSamples
|
||||
- retainedSamples
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRulePreview:
|
||||
properties:
|
||||
@@ -5487,15 +5476,23 @@ components:
|
||||
estimatedMonthlySavingsUsd:
|
||||
format: double
|
||||
type: number
|
||||
ingestedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
retainedSamples:
|
||||
minimum: 0
|
||||
type: integer
|
||||
retainedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- ingestedSeries
|
||||
- retainedSeries
|
||||
- ingestedSamples
|
||||
- retainedSamples
|
||||
- estimatedMonthlySavingsUsd
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRules:
|
||||
@@ -5561,7 +5558,6 @@ components:
|
||||
- metric
|
||||
- ingested_volume
|
||||
- reduced_volume
|
||||
- reduction
|
||||
- last_updated
|
||||
type: string
|
||||
MetricreductionruletypesUpdatableReductionRule:
|
||||
@@ -11825,68 +11821,6 @@ paths:
|
||||
summary: Get role
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: true
|
||||
description: This endpoint patches a role
|
||||
operationId: PatchRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- role:update
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a role
|
||||
@@ -11949,158 +11883,6 @@ paths:
|
||||
summary: Update role
|
||||
tags:
|
||||
- role
|
||||
/api/v1/roles/{id}/relations/{relation}/objects:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Gets all objects connected to the specified role via a given relation
|
||||
type
|
||||
operationId: GetObjects
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: relation
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:read
|
||||
- tokenizer:
|
||||
- role:read
|
||||
summary: Get objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: true
|
||||
description: Patches the objects connected to the specified role via a given
|
||||
relation type
|
||||
operationId: PatchObjects
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: relation
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- role:update
|
||||
summary: Patch objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
/api/v1/route_policies:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -260,40 +260,6 @@ func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID va
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableRole, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objects := make([]*coretypes.Object, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resourceObjects, err := provider.
|
||||
ListObjects(
|
||||
ctx,
|
||||
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
|
||||
relation,
|
||||
objectType,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objects = append(objects, resourceObjects...)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
@@ -324,39 +290,6 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
}
|
||||
|
||||
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, role)
|
||||
}
|
||||
|
||||
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -286,7 +286,7 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ var (
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
|
||||
const sampleBucketExpr = "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalMinute(10)))) * 1000 AS bucket"
|
||||
|
||||
type volumeRow struct {
|
||||
MetricName string
|
||||
Ingested uint64
|
||||
@@ -289,12 +291,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
orderExpr := "ingested"
|
||||
switch orderBy {
|
||||
case metricreductionruletypes.OrderByReducedVolume:
|
||||
orderExpr = "reduced"
|
||||
case metricreductionruletypes.OrderByReduction:
|
||||
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
|
||||
orderExpr := "ifNull(i.samples, 0)"
|
||||
if orderBy == metricreductionruletypes.OrderByReducedVolume {
|
||||
orderExpr = "if(ifNull(d.samples, 0) = 0 OR ifNull(d.samples, 0) > ifNull(i.samples, 0), ifNull(i.samples, 0), ifNull(d.samples, 0))"
|
||||
}
|
||||
direction := "ASC"
|
||||
if order == metricreductionruletypes.OrderDesc {
|
||||
@@ -310,17 +309,17 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
|
||||
"(SELECT metric_name, uniq(fingerprint) AS cnt, count() AS samples FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
|
||||
"base.metric_name = i.metric_name",
|
||||
)
|
||||
// Reduced series are spread across two type-specific tables; union the per-table distinct
|
||||
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
"(SELECT metric_name, sum(cnt) AS cnt, sum(samples) AS samples FROM ("+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
" UNION ALL "+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
") GROUP BY metric_name) AS d",
|
||||
"base.metric_name = d.metric_name",
|
||||
)
|
||||
@@ -347,122 +346,186 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
|
||||
func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return 0, 0, nil
|
||||
return map[string]volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return ingested, min(last+sum, ingested), nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count()")
|
||||
sb.From(table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var count uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
|
||||
sb.Select("uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var count uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
|
||||
// to each metric's strict effective_from (see strictEffectiveFrom).
|
||||
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
|
||||
if len(allMetrics) == 0 {
|
||||
return []volumePoint{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
|
||||
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retained := make(map[int64]uint64)
|
||||
if len(reducedMetrics) > 0 {
|
||||
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
|
||||
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]volumeRow, len(metricNames))
|
||||
for _, name := range metricNames {
|
||||
out[name] = volumeRow{MetricName: name, Ingested: ingested[name], Reduced: last[name] + sum[name]}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countSamplesByMetric(ctx context.Context, table, countExpr string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", countExpr)
|
||||
sb.From(table)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count samples")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for rows.Next() {
|
||||
var (
|
||||
metricName string
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&metricName, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
|
||||
}
|
||||
out[metricName] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uint64, uint64, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint)", "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var series, samples uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&series, &samples); err != nil {
|
||||
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count total ingested volume")
|
||||
}
|
||||
return series, samples, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.totalSamplesByBucket(ctx, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruledIngested := make(map[int64]uint64)
|
||||
ruledRetained := make(map[int64]uint64)
|
||||
if len(ruledMetrics) > 0 {
|
||||
ruledIngested, err = c.ruledIngestedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range reduced {
|
||||
retained[ts] += count
|
||||
}
|
||||
}
|
||||
reducedSet := make(map[string]struct{}, len(reducedMetrics))
|
||||
for _, name := range reducedMetrics {
|
||||
reducedSet[name] = struct{}{}
|
||||
}
|
||||
nonReduced := make([]string, 0, len(allMetrics))
|
||||
for _, name := range allMetrics {
|
||||
if _, ok := reducedSet[name]; !ok {
|
||||
nonReduced = append(nonReduced, name)
|
||||
}
|
||||
}
|
||||
if len(nonReduced) > 0 {
|
||||
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
|
||||
ruledRetained, err = c.ruledRetainedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range nonReducedIngested {
|
||||
retained[ts] += count
|
||||
}
|
||||
|
||||
retained := make(map[int64]uint64, len(ingested))
|
||||
for ts, total := range ingested {
|
||||
shed := uint64(0)
|
||||
if ri := ruledIngested[ts]; ri > ruledRetained[ts] {
|
||||
shed = ri - ruledRetained[ts]
|
||||
}
|
||||
if total > shed {
|
||||
retained[ts] = total - shed
|
||||
} else {
|
||||
retained[ts] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return mergeVolumePoints(ingested, retained), nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
return c.scanBuckets(ctx, sb)
|
||||
}
|
||||
|
||||
func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
return c.scanBuckets(ctx, sb)
|
||||
}
|
||||
|
||||
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
|
||||
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
counts, err := c.scanBuckets(ctx, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range counts {
|
||||
out[ts] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
|
||||
buckets := make(map[int64]struct{}, len(ingested))
|
||||
for ts := range ingested {
|
||||
@@ -488,60 +551,6 @@ func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
|
||||
return points
|
||||
}
|
||||
|
||||
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
|
||||
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
|
||||
sb.Select(bucketExpr, "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
return c.scanBuckets(ctx, sb)
|
||||
}
|
||||
|
||||
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
|
||||
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
|
||||
// exact).
|
||||
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
|
||||
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
counts, err := c.scanBuckets(ctx, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range counts {
|
||||
out[ts] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -28,9 +27,16 @@ import (
|
||||
|
||||
const (
|
||||
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
|
||||
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
|
||||
effectiveFromMargin = 5 * time.Minute
|
||||
defaultPreviewLookback = 24 * time.Hour
|
||||
// goes live; it must be >= the collector's rule-refresh interval (~2m worst case,
|
||||
// see signoz-otel-collector#839).
|
||||
effectiveFromMargin = 2 * time.Minute
|
||||
// uiActivationDelay keeps a rule shown as "pending" in the UI for a while after it goes live to
|
||||
// the collector, so the user doesn't see "active" before reduced data is actually flowing. The
|
||||
// user-facing pending window is effectiveFromMargin + uiActivationDelay (~5m).
|
||||
uiActivationDelay = 3 * time.Minute
|
||||
defaultPreviewLookback = 1 * time.Hour
|
||||
statsLookback = 1 * time.Hour
|
||||
timeseriesLookback = 6 * time.Hour
|
||||
|
||||
pricePerMillionSamplesUSD = 0.1
|
||||
monthDuration = 30 * 24 * time.Hour
|
||||
@@ -80,7 +86,7 @@ func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricredu
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
startMs := now.Add(-statsLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
switch params.OrderBy {
|
||||
@@ -107,10 +113,14 @@ func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, para
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
|
||||
for _, rule := range domainRules {
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName], sampleVolumes[rule.MetricName]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
@@ -139,13 +149,24 @@ func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, para
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pageMetricNames := make([]string, 0, len(ranked))
|
||||
for _, row := range ranked {
|
||||
pageMetricNames = append(pageMetricNames, row.MetricName)
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): do we need to run this query? can we just get the same from RankByVolume?
|
||||
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, pageMetricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
|
||||
for _, row := range ranked {
|
||||
rule, ok := ruleByMetric[row.MetricName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), row, sampleVolumes[row.MetricName]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
@@ -288,20 +309,17 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
startMs := now.Add(-statsLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
rules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames := make([]string, len(rules))
|
||||
effectiveFrom := make(map[string]int64, len(rules))
|
||||
for i, rule := range rules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
@@ -310,31 +328,43 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ingestedSeries, retainedSeries uint64
|
||||
reducedMetricNames := make([]string, 0, len(volumes))
|
||||
reducedEffectiveFrom := make(map[string]int64, len(volumes))
|
||||
for name, volume := range volumes {
|
||||
ingestedSeries += volume.Ingested
|
||||
retained := effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
retainedSeries += retained
|
||||
if retained < volume.Ingested {
|
||||
reducedMetricNames = append(reducedMetricNames, name)
|
||||
reducedEffectiveFrom[name] = effectiveFrom[name]
|
||||
}
|
||||
var ruledIngestedSeries, ruledRetainedSeries uint64
|
||||
for _, volume := range volumes {
|
||||
ruledIngestedSeries += volume.Ingested
|
||||
ruledRetainedSeries += effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
}
|
||||
|
||||
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
|
||||
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ruledIngestedSamples, ruledRetainedSamples uint64
|
||||
for _, sv := range sampleVolumes {
|
||||
ruledIngestedSamples += sv.Ingested
|
||||
ruledRetainedSamples += effectiveRetained(sv.Ingested, sv.Reduced)
|
||||
}
|
||||
|
||||
totalSeries, totalSamples, err := m.ch.TotalVolume(ctx, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{
|
||||
IngestedSeries: ingestedSeries,
|
||||
RetainedSeries: retainedSeries,
|
||||
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
|
||||
IngestedSeries: totalSeries,
|
||||
RetainedSeries: clampSub(totalSeries, ruledIngestedSeries-ruledRetainedSeries),
|
||||
IngestedSamples: totalSamples,
|
||||
RetainedSamples: clampSub(totalSamples, ruledIngestedSamples-ruledRetainedSamples),
|
||||
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ruledIngestedSamples, ruledRetainedSamples, startMs, endMs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func clampSub(a, b uint64) uint64 {
|
||||
if a < b {
|
||||
return 0
|
||||
}
|
||||
return a - b
|
||||
}
|
||||
|
||||
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
|
||||
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
|
||||
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
|
||||
@@ -352,7 +382,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
startMs := now.Add(-timeseriesLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
@@ -366,18 +396,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reducedNames := make([]string, 0, len(volumes))
|
||||
for name, volume := range volumes {
|
||||
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
|
||||
reducedNames = append(reducedNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
|
||||
points, err := m.ch.SampleTimeseries(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -414,7 +433,7 @@ func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRange
|
||||
}
|
||||
|
||||
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
|
||||
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
|
||||
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, orgID, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -447,12 +466,12 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
|
||||
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, item := range dashboards[metricName] {
|
||||
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
|
||||
usedLabels := append(append([]string{}, item.GroupBy...), item.FilterBy...)
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeDashboard,
|
||||
ID: item["dashboard_id"],
|
||||
Name: item["dashboard_name"],
|
||||
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
|
||||
ID: item.DashboardID,
|
||||
Name: item.DashboardName,
|
||||
Widget: &metricreductionruletypes.AffectedWidget{ID: item.PanelID, Name: item.PanelName},
|
||||
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
|
||||
})
|
||||
}
|
||||
@@ -482,7 +501,7 @@ func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metri
|
||||
MatchType: rule.MatchType,
|
||||
Labels: rule.Labels,
|
||||
EffectiveFrom: rule.EffectiveFrom,
|
||||
Active: !rule.EffectiveFrom.After(time.Now()),
|
||||
Active: !rule.EffectiveFrom.Add(uiActivationDelay).After(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,12 +512,11 @@ func effectiveRetained(ingested, reduced uint64) uint64 {
|
||||
return reduced
|
||||
}
|
||||
|
||||
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
|
||||
rule.IngestedSeries = volume.Ingested
|
||||
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
if volume.Ingested > 0 {
|
||||
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
|
||||
}
|
||||
func withVolume(rule metricreductionruletypes.GettableReductionRule, series volumeRow, samples volumeRow) metricreductionruletypes.GettableReductionRule {
|
||||
rule.IngestedSeries = series.Ingested
|
||||
rule.RetainedSeries = effectiveRetained(series.Ingested, series.Reduced)
|
||||
rule.IngestedSamples = samples.Ingested
|
||||
rule.RetainedSamples = effectiveRetained(samples.Ingested, samples.Reduced)
|
||||
return rule
|
||||
}
|
||||
|
||||
@@ -518,13 +536,6 @@ func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
|
||||
ruleSet := make(map[string]struct{}, len(ruleLabels))
|
||||
for _, l := range ruleLabels {
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
|
||||
Where("org_id = ?", orgID).
|
||||
Order(column + " " + direction)
|
||||
if params.Search != "" {
|
||||
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
|
||||
query = query.Where("metric_name LIKE ? ESCAPE '\\'", "%"+s.sqlstore.Formatter().EscapeLikePattern(params.Search)+"%")
|
||||
}
|
||||
if params.MetricName != "" {
|
||||
query = query.Where("metric_name = ?", params.MetricName)
|
||||
|
||||
@@ -64,6 +64,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
signoz.Cache,
|
||||
signoz.Flagger,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,19 +18,13 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AuthtypesPatchableRoleDTO,
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
CoretypesPatchableObjectsDTO,
|
||||
CreateRole201,
|
||||
DeleteRolePathParameters,
|
||||
GetObjects200,
|
||||
GetObjectsPathParameters,
|
||||
GetRole200,
|
||||
GetRolePathParameters,
|
||||
ListRoles200,
|
||||
PatchObjectsPathParameters,
|
||||
PatchRolePathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateRolePathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -365,107 +359,6 @@ export const invalidateGetRole = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint patches a role
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const patchRole = (
|
||||
{ id }: PatchRolePathParameters,
|
||||
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPatchableRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchRole>>
|
||||
>;
|
||||
export type PatchRoleMutationBody =
|
||||
| BodyType<AuthtypesPatchableRoleDTO>
|
||||
| undefined;
|
||||
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const usePatchRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a role
|
||||
* @summary Update role
|
||||
@@ -565,205 +458,3 @@ export const useUpdateRole = <
|
||||
> => {
|
||||
return useMutation(getUpdateRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Gets all objects connected to the specified role via a given relation type
|
||||
* @summary Get objects for a role by relation
|
||||
*/
|
||||
export const getObjects = (
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetObjects200>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetObjectsQueryKey = ({
|
||||
id,
|
||||
relation,
|
||||
}: GetObjectsPathParameters) => {
|
||||
return [`/api/v1/roles/${id}/relations/${relation}/objects`] as const;
|
||||
};
|
||||
|
||||
export const getGetObjectsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getObjects>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getObjects>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetObjectsQueryKey({ id, relation });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getObjects>>> = ({
|
||||
signal,
|
||||
}) => getObjects({ id, relation }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && relation),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getObjects>>, TError, TData> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetObjectsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getObjects>>
|
||||
>;
|
||||
export type GetObjectsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get objects for a role by relation
|
||||
*/
|
||||
|
||||
export function useGetObjects<
|
||||
TData = Awaited<ReturnType<typeof getObjects>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getObjects>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetObjectsQueryOptions({ id, relation }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get objects for a role by relation
|
||||
*/
|
||||
export const invalidateGetObjects = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetObjectsQueryKey({ id, relation }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Patches the objects connected to the specified role via a given relation type
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const patchObjects = (
|
||||
{ id, relation }: PatchObjectsPathParameters,
|
||||
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: coretypesPatchableObjectsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchObjectsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchObjects'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchObjects(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchObjectsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchObjects>>
|
||||
>;
|
||||
export type PatchObjectsMutationBody =
|
||||
| BodyType<CoretypesPatchableObjectsDTO>
|
||||
| undefined;
|
||||
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const usePatchObjects = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchObjectsMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -2230,13 +2230,6 @@ export interface AuthtypesOrgSessionContextDTO {
|
||||
warning?: ErrorsJSONDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPatchableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
@@ -3249,17 +3242,6 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export interface CoretypesPatchableObjectsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
additions: CoretypesObjectGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
deletions: CoretypesObjectGroupDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardGridItemDTO {
|
||||
content?: CommonJSONRefDTO;
|
||||
/**
|
||||
@@ -3995,6 +3977,14 @@ export interface DashboardtypesDashboardPanelRefDTO {
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
filterBy?: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6919,6 +6909,11 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6934,10 +6929,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reductionPercent: number;
|
||||
retainedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6996,11 +6991,21 @@ export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
|
||||
* @format double
|
||||
*/
|
||||
estimatedMonthlySavingsUsd: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSeries: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
retainedSamples: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -7056,7 +7061,6 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
|
||||
metric = 'metric',
|
||||
ingested_volume = 'ingested_volume',
|
||||
reduced_volume = 'reduced_volume',
|
||||
reduction = 'reduction',
|
||||
last_updated = 'last_updated',
|
||||
}
|
||||
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
|
||||
@@ -10248,31 +10252,9 @@ export type GetRole200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
};
|
||||
export type GetObjects200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: CoretypesObjectGroupDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
};
|
||||
export type GetAllRoutePolicies200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
selectIsDashboardLocked,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { getChartManagerColumns } from './getChartMangerColumns';
|
||||
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
|
||||
@@ -44,7 +44,6 @@ export default function ChartManager({
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
onCancel,
|
||||
}: ChartManagerProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const { legendItemsMap } = useLegendsSync({
|
||||
config,
|
||||
subscribeToFocusChange: false,
|
||||
@@ -136,11 +135,9 @@ export default function ChartManager({
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
notifications.success({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
toast.success('The updated graphs & legends are saved');
|
||||
onCancel?.();
|
||||
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
|
||||
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
|
||||
|
||||
return (
|
||||
<div className="chart-manager-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
|
||||
import ChartManager from '../ChartManager';
|
||||
|
||||
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
const mockNotificationsSuccess = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
|
||||
usePlotContext: (): {
|
||||
@@ -46,12 +46,11 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: { success: jest.Mock } } => ({
|
||||
notifications: {
|
||||
success: mockNotificationsSuccess,
|
||||
},
|
||||
}),
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('components/ResizeTable', () => {
|
||||
@@ -160,7 +159,7 @@ describe('ChartManager', () => {
|
||||
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
|
||||
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
|
||||
render(
|
||||
<ChartManager
|
||||
config={createMockConfig() as UPlotConfigBuilder}
|
||||
@@ -172,9 +171,9 @@ describe('ChartManager', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'The updated graphs & legends are saved',
|
||||
);
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
// in the same container; size the chart region to its content so they aren't
|
||||
// pushed out. Only this case opts out of filling the height — the dashboard grid,
|
||||
// alert preview, and other charts keep 100% so they fill their container.
|
||||
&--with-layout-children {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function ChartLayout({
|
||||
className={cx('chart-layout', {
|
||||
'chart-layout--legend-right':
|
||||
legendConfig.position === LegendPosition.RIGHT,
|
||||
'chart-layout--with-layout-children': !!layoutChildren,
|
||||
})}
|
||||
>
|
||||
<div className="chart-layout__content">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Gauge } from '@signozhq/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
@@ -8,16 +9,26 @@ interface VolumeControlBadgeProps {
|
||||
}
|
||||
|
||||
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
|
||||
return (
|
||||
const badge = (
|
||||
<Badge
|
||||
data-testid="vc-badge-active"
|
||||
variant="outline"
|
||||
color={!rule.active ? 'success' : 'warning'}
|
||||
color={rule.active ? 'success' : 'warning'}
|
||||
>
|
||||
<Gauge size={12} />
|
||||
{!rule.active ? 'Active' : 'Pending'}
|
||||
{rule.active ? 'Active' : 'Pending'}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (rule.active) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Takes about 5 minutes to take effect">
|
||||
<span>{badge}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeControlBadge;
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
padding: 12px 16px 0 16px;
|
||||
}
|
||||
|
||||
.chartHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -15,3 +21,11 @@
|
||||
.chartBody {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.chartStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spin } from 'antd';
|
||||
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
@@ -25,7 +26,9 @@ interface VolumeControlChartProps {
|
||||
}
|
||||
|
||||
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
|
||||
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
|
||||
const { data, isLoading, isError } = useGetMetricReductionRuleTimeseries({
|
||||
query: { enabled },
|
||||
});
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
@@ -65,11 +68,34 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.chart} data-testid="volume-control-chart">
|
||||
<Typography.Text className={styles.chartTitle} size={'small'}>
|
||||
Series volume over time · ingested vs retained
|
||||
</Typography.Text>
|
||||
<div className={styles.chartHeader}>
|
||||
<Typography.Text className={styles.chartTitle} size={'small'}>
|
||||
Sample volume · ingested vs retained
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
Last 6 hours
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.chartBody} ref={graphRef}>
|
||||
{dimensions.width > 0 && (
|
||||
{isLoading && (
|
||||
<div
|
||||
className={styles.chartStatus}
|
||||
data-testid="volume-control-chart-loading"
|
||||
>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && isError && (
|
||||
<div
|
||||
className={styles.chartStatus}
|
||||
data-testid="volume-control-chart-error"
|
||||
>
|
||||
<Typography.Text size="small" color="danger">
|
||||
Failed to load chart
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !isError && dimensions.width > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
|
||||
@@ -17,11 +17,27 @@
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.meterLabelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meterLabel {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.meterInfo {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.meterValue {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spin } from 'antd';
|
||||
import { Spin, Tooltip } from 'antd';
|
||||
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { formatCompact } from '../../../configUtils';
|
||||
import { RuleMode } from '../../../types';
|
||||
@@ -27,6 +29,7 @@ function ImpactPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const full = preview?.ingestedSeries ?? 0;
|
||||
const current = preview?.currentRetainedSeries ?? 0;
|
||||
const proposed = preview?.retainedSeries ?? 0;
|
||||
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
|
||||
@@ -40,31 +43,59 @@ function ImpactPanel({
|
||||
{!isLoading && preview && (
|
||||
<div className={styles.meterGrid}>
|
||||
<div className={styles.meter}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Current series
|
||||
<div className={styles.meterLabelRow}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Full series
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title="Total number of series for this metric before any reduction."
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Info size={12} className={styles.meterInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text size="2xl" className={styles.meterValue}>
|
||||
{formatCompact(full)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<div className={styles.meterLabelRow}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Current retained
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title="Series kept today under the metric's existing rule, or all of them if it has no rule yet."
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Info size={12} className={styles.meterInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text size="2xl" className={styles.meterValue}>
|
||||
{formatCompact(current)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Proposed series
|
||||
</Typography.Text>
|
||||
<div className={styles.meterLabelRow}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Potential retained
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title="Series that would be kept if you save this rule, with the reduction vs what's retained today."
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Info size={12} className={styles.meterInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text size="2xl" className={styles.meterValue}>
|
||||
{formatCompact(proposed)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.meter}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
|
||||
Reduction
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="2xl"
|
||||
color={deltaPct >= 0 ? 'success' : undefined}
|
||||
className={styles.meterValue}
|
||||
>
|
||||
{reductionLabel}
|
||||
{deltaPct !== 0 && (
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={deltaPct >= 0 ? 'success' : undefined}
|
||||
>
|
||||
{reductionLabel}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,12 +18,12 @@ const MODE_OPTIONS: ModeOption[] = [
|
||||
},
|
||||
{
|
||||
mode: 'include',
|
||||
title: 'Include attributes',
|
||||
title: 'Include',
|
||||
description: 'Allowlist: only the selected attributes stay queryable.',
|
||||
},
|
||||
{
|
||||
mode: 'exclude',
|
||||
title: 'Exclude attributes',
|
||||
title: 'Exclude',
|
||||
description: 'Blocklist: the selected attributes are aggregated away.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,12 +53,12 @@ function RelatedAssetsWarning({
|
||||
<div className={styles.warning} data-testid="volume-control-warning">
|
||||
<Info size={14} />
|
||||
<div className={styles.warningBody}>
|
||||
<Typography.Text as="div" size="small" weight="semibold" color="warning">
|
||||
<Typography.Text as="div" size="base" weight="semibold" color="warning">
|
||||
This rule affects {impacted.length} related asset
|
||||
{impacted.length > 1 ? 's' : ''}.
|
||||
</Typography.Text>
|
||||
{impactedLabels.length > 0 && (
|
||||
<Typography.Text as="div" size="sm" color="muted">
|
||||
<Typography.Text as="div" size="base" color="muted">
|
||||
{impactedLabels.join(', ')} will no longer be queryable; affected panels
|
||||
fall back to aggregated data once the rule applies.
|
||||
</Typography.Text>
|
||||
@@ -73,7 +73,7 @@ function RelatedAssetsWarning({
|
||||
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
|
||||
{href ? (
|
||||
<Typography.Link
|
||||
size="sm"
|
||||
size="base"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -81,7 +81,7 @@ function RelatedAssetsWarning({
|
||||
{label}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Typography.Text size="sm" color="muted">
|
||||
<Typography.Text size="base" color="muted">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,10 @@ function VolumeControlConfigDrawer({
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text size="small" color="muted">
|
||||
Changes take effect about 5 minutes after saving.
|
||||
</Typography.Text>
|
||||
<div className={styles.footerSpacer} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
@@ -50,7 +54,6 @@ function VolumeControlConfigDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={styles.footerSpacer} />
|
||||
{hasExistingRule && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,8 +8,8 @@ function PendingActivationBanner(): JSX.Element {
|
||||
<div className={styles.banner} data-testid="volume-control-pending-banner">
|
||||
<Info size={13} />
|
||||
<Typography.Text size="sm" color="muted">
|
||||
This metric's configuration was recently updated. Volume changes will
|
||||
take effect within a few minutes.
|
||||
This metric's configuration was recently updated. Volume changes take
|
||||
effect within about 5 minutes.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ function VolumeControlSection({
|
||||
useVolumeControlFeatureGate();
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useListMetricReductionRules(
|
||||
const { data, isLoading, isError } = useListMetricReductionRules(
|
||||
{ metricName },
|
||||
{
|
||||
query: {
|
||||
@@ -37,7 +37,7 @@ function VolumeControlSection({
|
||||
}
|
||||
|
||||
const rule = data?.data.rules?.[0];
|
||||
const hasRule = !!rule && !error;
|
||||
const hasRule = !!rule && !isError;
|
||||
|
||||
const openConfig = (): void => setIsConfigOpen(true);
|
||||
const closeConfig = (): void => setIsConfigOpen(false);
|
||||
@@ -53,6 +53,16 @@ function VolumeControlSection({
|
||||
|
||||
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
|
||||
|
||||
{!isLoading && isError && (
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color="danger"
|
||||
data-testid="volume-control-section-error"
|
||||
>
|
||||
Failed to load volume control. Please try again.
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{!isLoading && hasRule && rule && !rule.active && (
|
||||
<PendingActivationBanner />
|
||||
)}
|
||||
@@ -65,7 +75,7 @@ function VolumeControlSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasRule && (
|
||||
{!isLoading && !isError && !hasRule && (
|
||||
<NoRuleEmptyState canManage={canManageVolumeControl} onSetup={openConfig} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.statsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -21,11 +27,24 @@
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
.statCardLabelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statCardLabel {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.statCardInfo {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.statCardValue {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCompact, formatUsd } from '../../../configUtils';
|
||||
@@ -8,12 +10,17 @@ interface VolumeControlStatsProps {
|
||||
activeRules: number;
|
||||
ingestedSeries: number;
|
||||
retainedSeries: number;
|
||||
ingestedSamples: number;
|
||||
retainedSamples: number;
|
||||
estimatedMonthlySavingsUsd: number;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip: string;
|
||||
delta?: string;
|
||||
unit?: string;
|
||||
highlighted?: boolean;
|
||||
@@ -24,20 +31,53 @@ function VolumeControlStats({
|
||||
activeRules,
|
||||
ingestedSeries,
|
||||
retainedSeries,
|
||||
ingestedSamples,
|
||||
retainedSamples,
|
||||
estimatedMonthlySavingsUsd,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: VolumeControlStatsProps): JSX.Element {
|
||||
const overallReduction =
|
||||
ingestedSeries > 0
|
||||
? Math.round((1 - retainedSeries / ingestedSeries) * 100)
|
||||
: 0;
|
||||
|
||||
const sampleReduction =
|
||||
ingestedSamples > 0
|
||||
? Math.round((1 - retainedSamples / ingestedSamples) * 100)
|
||||
: 0;
|
||||
|
||||
const items: StatItem[] = [
|
||||
{ label: 'Active rules', value: String(activeRules) },
|
||||
{ label: 'Ingested series', value: formatCompact(ingestedSeries) },
|
||||
{
|
||||
label: 'Configured rules',
|
||||
value: String(activeRules),
|
||||
tooltip: 'Volume-control rules currently configured for this workspace.',
|
||||
},
|
||||
{
|
||||
label: 'Ingested series',
|
||||
value: formatCompact(ingestedSeries),
|
||||
tooltip:
|
||||
'Distinct time series across all metrics in the last 1 hour, before any reduction.',
|
||||
},
|
||||
{
|
||||
label: 'Retained series',
|
||||
value: formatCompact(retainedSeries),
|
||||
delta: overallReduction > 0 ? `−${overallReduction}%` : undefined,
|
||||
tooltip:
|
||||
'Distinct time series kept across all metrics in the last 1 hour; everything except what the rules reduce away. Lower than ingested means more reduction.',
|
||||
},
|
||||
{
|
||||
label: 'Ingested samples',
|
||||
value: formatCompact(ingestedSamples),
|
||||
tooltip:
|
||||
'Sample data points across all metrics in the last 1 hour, before any reduction.',
|
||||
},
|
||||
{
|
||||
label: 'Retained samples',
|
||||
value: formatCompact(retainedSamples),
|
||||
delta: sampleReduction > 0 ? `−${sampleReduction}%` : undefined,
|
||||
tooltip:
|
||||
'Sample data points kept across all metrics in the last 1 hour; everything except what the rules reduce. Samples reduce more than series because series do not all carry the same sample volume.',
|
||||
},
|
||||
{
|
||||
label: 'Est. monthly savings',
|
||||
@@ -45,42 +85,73 @@ function VolumeControlStats({
|
||||
unit: '/mo',
|
||||
highlighted: true,
|
||||
valueGood: true,
|
||||
tooltip:
|
||||
'Rough monthly estimate: the samples the rules reduced in the last 1 hour, scaled to a month at 1-month standard retention. It is extrapolated from a single rolling hour.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.stats} data-testid="volume-control-stats">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cx(styles.statCard, {
|
||||
[styles.statCardHighlighted]: item.highlighted,
|
||||
})}
|
||||
>
|
||||
<Typography.Text size="sm" color="muted" className={styles.statCardLabel}>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="div"
|
||||
size="large"
|
||||
weight="semibold"
|
||||
color={item.valueGood ? 'success' : undefined}
|
||||
className={styles.statCardValue}
|
||||
<div className={styles.statsSection}>
|
||||
<Typography.Text size="small" color="muted">
|
||||
Last 1 hour
|
||||
</Typography.Text>
|
||||
<div className={styles.stats} data-testid="volume-control-stats">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cx(styles.statCard, {
|
||||
[styles.statCardHighlighted]: item.highlighted,
|
||||
})}
|
||||
>
|
||||
{item.value}
|
||||
{item.delta && (
|
||||
<Typography.Text size="small" weight="semibold" color="success">
|
||||
{item.delta}
|
||||
<div className={styles.statCardLabelRow}>
|
||||
<Typography.Text
|
||||
size="sm"
|
||||
color="muted"
|
||||
className={styles.statCardLabel}
|
||||
>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
<Tooltip title={item.tooltip}>
|
||||
<Info size={12} className={styles.statCardInfo} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<Skeleton.Button active size="small" className={styles.statCardValue} />
|
||||
)}
|
||||
{!isLoading && isError && (
|
||||
<Typography.Text
|
||||
as="div"
|
||||
size="small"
|
||||
color="danger"
|
||||
className={styles.statCardValue}
|
||||
>
|
||||
Failed to load
|
||||
</Typography.Text>
|
||||
)}
|
||||
{item.unit && (
|
||||
<Typography.Text size="small" weight="medium" color="muted">
|
||||
{item.unit}
|
||||
{!isLoading && !isError && (
|
||||
<Typography.Text
|
||||
as="div"
|
||||
size="large"
|
||||
weight="semibold"
|
||||
color={item.valueGood ? 'success' : undefined}
|
||||
className={styles.statCardValue}
|
||||
>
|
||||
{item.value}
|
||||
{item.delta && (
|
||||
<Typography.Text size="small" weight="semibold" color="success">
|
||||
{item.delta}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{item.unit && (
|
||||
<Typography.Text size="small" weight="medium" color="muted">
|
||||
{item.unit}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.reductionCell {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
.volumeCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -36,7 +36,7 @@ type VolumeControlTableParams = Required<
|
||||
>;
|
||||
|
||||
const DEFAULT_PARAMS: VolumeControlTableParams = {
|
||||
orderBy: OrderBy.reduction,
|
||||
orderBy: OrderBy.ingested_volume,
|
||||
order: SortOrder.desc,
|
||||
search: '',
|
||||
offset: 0,
|
||||
@@ -60,11 +60,20 @@ function VolumeControlTab(): JSX.Element {
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const { data, isLoading } = useListMetricReductionRules(params, {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError: isListError,
|
||||
} = useListMetricReductionRules(params, {
|
||||
query: { enabled: isVolumeControlEnabled },
|
||||
});
|
||||
|
||||
const { data: statsData } = useGetMetricReductionRuleStats({
|
||||
const {
|
||||
data: statsData,
|
||||
isLoading: isStatsLoading,
|
||||
isFetching: isStatsFetching,
|
||||
isError: isStatsError,
|
||||
} = useGetMetricReductionRuleStats({
|
||||
query: { enabled: isVolumeControlEnabled },
|
||||
});
|
||||
const stats = statsData?.data;
|
||||
@@ -111,7 +120,7 @@ function VolumeControlTab(): JSX.Element {
|
||||
{
|
||||
title: 'MODE',
|
||||
key: 'mode',
|
||||
width: 160,
|
||||
width: 110,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
@@ -138,7 +147,14 @@ function VolumeControlTab(): JSX.Element {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'INGESTED',
|
||||
title: (
|
||||
<>
|
||||
INGESTED{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
(1h)
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
key: OrderBy.ingested_volume,
|
||||
width: 130,
|
||||
sorter: true,
|
||||
@@ -147,13 +163,28 @@ function VolumeControlTab(): JSX.Element {
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<Typography.Text size="small" color="muted">
|
||||
{formatCompact(rule.ingestedSeries)}
|
||||
</Typography.Text>
|
||||
<div className={styles.volumeCell}>
|
||||
<Typography.Text size="small">
|
||||
{formatCompact(rule.ingestedSeries)}{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
series
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
{formatCompact(rule.ingestedSamples)} samples
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'RETAINED',
|
||||
title: (
|
||||
<>
|
||||
RETAINED{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
(1h)
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
key: OrderBy.reduced_volume,
|
||||
width: 130,
|
||||
sorter: true,
|
||||
@@ -162,22 +193,35 @@ function VolumeControlTab(): JSX.Element {
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => (
|
||||
<Typography.Text size="small">
|
||||
{formatCompact(rule.retainedSeries)}
|
||||
</Typography.Text>
|
||||
<div className={styles.volumeCell}>
|
||||
<Typography.Text size="small">
|
||||
{formatCompact(rule.retainedSeries)}{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
series
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
{formatCompact(rule.retainedSamples)} samples
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'CHANGE',
|
||||
key: OrderBy.reduction,
|
||||
width: 110,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderFor(OrderBy.reduction),
|
||||
width: 140,
|
||||
render: (
|
||||
_value: unknown,
|
||||
rule: MetricreductionruletypesGettableReductionRuleDTO,
|
||||
): JSX.Element => {
|
||||
if (rule.reductionPercent <= 0) {
|
||||
const seriesReduction =
|
||||
rule.ingestedSeries > 0
|
||||
? (1 - rule.retainedSeries / rule.ingestedSeries) * 100
|
||||
: 0;
|
||||
const samplesReduction =
|
||||
rule.ingestedSamples > 0
|
||||
? (1 - rule.retainedSamples / rule.ingestedSamples) * 100
|
||||
: 0;
|
||||
if (seriesReduction <= 0 && samplesReduction <= 0) {
|
||||
return (
|
||||
<Typography.Text size="small" color="muted">
|
||||
—
|
||||
@@ -185,14 +229,18 @@ function VolumeControlTab(): JSX.Element {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="semibold"
|
||||
color="success"
|
||||
className={styles.reductionCell}
|
||||
>
|
||||
−{Math.round(rule.reductionPercent)}%
|
||||
</Typography.Text>
|
||||
<div className={styles.volumeCell}>
|
||||
<Typography.Text size="small" weight="semibold" color="success">
|
||||
{seriesReduction > 0 ? `−${Math.round(seriesReduction)}%` : '0%'}{' '}
|
||||
<Typography.Text size="small" color="muted">
|
||||
series
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" color="muted">
|
||||
{samplesReduction > 0 ? `−${Math.round(samplesReduction)}%` : '0%'}{' '}
|
||||
samples
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -273,7 +321,11 @@ function VolumeControlTab(): JSX.Element {
|
||||
activeRules={total}
|
||||
ingestedSeries={stats?.ingestedSeries ?? 0}
|
||||
retainedSeries={stats?.retainedSeries ?? 0}
|
||||
ingestedSamples={stats?.ingestedSamples ?? 0}
|
||||
retainedSamples={stats?.retainedSamples ?? 0}
|
||||
estimatedMonthlySavingsUsd={stats?.estimatedMonthlySavingsUsd ?? 0}
|
||||
isLoading={isStatsLoading || isStatsFetching}
|
||||
isError={isStatsError}
|
||||
/>
|
||||
|
||||
<VolumeControlChart enabled={isVolumeControlEnabled} />
|
||||
@@ -293,7 +345,13 @@ function VolumeControlTab(): JSX.Element {
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
emptyText: isListError ? (
|
||||
<div className={styles.empty} data-testid="volume-control-tab-error">
|
||||
<Typography.Text color="danger">
|
||||
Failed to load volume control rules. Please try again.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty} data-testid="volume-control-tab-empty">
|
||||
<Typography.Text color="muted">
|
||||
No volume control rules yet. Open a metric and set one up to start
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface UseVolumeControlConfigResult {
|
||||
const PREVIEW_DEBOUNCE_MS = 400;
|
||||
const SAVE_ERROR_MESSAGE = 'Failed to save volume control rule';
|
||||
const REMOVE_ERROR_MESSAGE = 'Failed to remove volume control rule';
|
||||
const PREVIEW_ERROR_MESSAGE = 'Failed to preview volume control rule';
|
||||
|
||||
export function useVolumeControlConfig({
|
||||
metricName,
|
||||
@@ -95,11 +96,25 @@ export function useVolumeControlConfig({
|
||||
const timer = setTimeout(() => {
|
||||
previewMutate(
|
||||
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
|
||||
{ onSettled: () => setIsPreviewPending(false) },
|
||||
{
|
||||
onError: (error) =>
|
||||
notifications.error({
|
||||
message: error.response?.data?.error?.message ?? PREVIEW_ERROR_MESSAGE,
|
||||
}),
|
||||
onSettled: () => setIsPreviewPending(false),
|
||||
},
|
||||
);
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return (): void => clearTimeout(timer);
|
||||
}, [open, mode, labels, metricName, previewMutate, previewReset]);
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
labels,
|
||||
metricName,
|
||||
previewMutate,
|
||||
previewReset,
|
||||
notifications,
|
||||
]);
|
||||
|
||||
const createMutation = useCreateMetricReductionRule();
|
||||
const updateMutation = useUpdateMetricReductionRuleByID();
|
||||
@@ -142,7 +157,10 @@ export function useVolumeControlConfig({
|
||||
}
|
||||
|
||||
const onSuccess = (): void => {
|
||||
notifications.success({ message: 'Volume control rule saved' });
|
||||
notifications.success({
|
||||
message:
|
||||
'Volume control rule saved. It takes about 5 minutes to take effect.',
|
||||
});
|
||||
invalidate();
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export function isKeepMode(
|
||||
export function getMatchTypeLabel(
|
||||
matchType: MetricreductionruletypesMatchTypeDTO,
|
||||
): string {
|
||||
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
|
||||
return isKeepMode(matchType) ? 'Include' : 'Exclude';
|
||||
}
|
||||
|
||||
export function getLabelVerb(
|
||||
|
||||
@@ -24,6 +24,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
|
||||
/**
|
||||
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
|
||||
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
|
||||
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
|
||||
*/
|
||||
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SquareArrowOutUpRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import styles from './ConfigActions.module.scss';
|
||||
|
||||
interface ConfigActionRowProps {
|
||||
/** Leading glyph for the action. */
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the config pane's "Actions" list — a cross-page navigation link
|
||||
* (leading icon, label, trailing external-link affordance). The whole row is the
|
||||
* click target.
|
||||
*/
|
||||
function ConfigActionRow({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
}: ConfigActionRowProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.row}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
prefix={<span className={styles.icon}>{icon}</span>}
|
||||
suffix={<SquareArrowOutUpRight size={14} />}
|
||||
>
|
||||
<Typography.Text className={styles.label}>{label}</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigActionRow;
|
||||
@@ -0,0 +1,57 @@
|
||||
/* The "Actions" group: a list of cross-page navigation links, visually separated
|
||||
from the collapsible config sections above by the same hairline divider. */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin: 0 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* A navigation-link row: leading icon, label, trailing external-link affordance. */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Bell } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
|
||||
|
||||
import ConfigActionRow from './ConfigActionRow';
|
||||
import styles from './ConfigActions.module.scss';
|
||||
|
||||
interface ConfigActionsProps {
|
||||
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Actions" group at the foot of the config pane: cross-page navigation links,
|
||||
* kept distinct from the collapsible config sections above. Each link is gated by the
|
||||
* panel kind's capabilities; the whole group hides when none apply.
|
||||
*/
|
||||
function ConfigActions({
|
||||
panel,
|
||||
panelId,
|
||||
}: ConfigActionsProps): JSX.Element | null {
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
// Only kinds whose query can seed an alert offer this today; mirror the panel
|
||||
// menu's create-alert capability.
|
||||
if (!actions.createAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.container}>
|
||||
<span className={styles.eyebrow}>Actions</span>
|
||||
<div className={styles.list}>
|
||||
<ConfigActionRow
|
||||
testId="panel-editor-v2-create-alert"
|
||||
icon={<Bell size={14} />}
|
||||
label="Create alert"
|
||||
onClick={(): void => createAlert(panel, panelId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigActions;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigActions from '../ConfigActions';
|
||||
|
||||
const mockCreateAlert = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: jest.fn(() => mockCreateAlert),
|
||||
}),
|
||||
);
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ConfigActions', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
render(<ConfigActions panel={panel} panelId="panel-1" />);
|
||||
|
||||
const row = screen.getByTestId('panel-editor-v2-create-alert');
|
||||
expect(row).toHaveTextContent('Create alert');
|
||||
|
||||
await user.click(row);
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
|
||||
});
|
||||
|
||||
it('renders nothing for a kind that cannot seed an alert', () => {
|
||||
const { container } = render(
|
||||
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
@@ -32,6 +34,12 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +49,6 @@ interface ConfigPaneProps {
|
||||
* generically via the section registry — only sections with a built editor appear.
|
||||
*/
|
||||
function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
@@ -49,7 +56,10 @@ function ConfigPane({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
panel,
|
||||
panelId,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
@@ -114,6 +124,8 @@ function ConfigPane({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfigActions panel={panel} panelId={panelId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -31,22 +30,7 @@ function PanelTypeSwitcher({
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
});
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** Active query type — a kind that can't be authored in it is disabled (defaults to Query Builder). */
|
||||
queryType?: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigPane from '../ConfigPane';
|
||||
|
||||
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
@@ -19,13 +30,14 @@ function renderConfigPane(
|
||||
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
|
||||
): React.ComponentProps<typeof ConfigPane> {
|
||||
const props: React.ComponentProps<typeof ConfigPane> = {
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
|
||||
panelId: 'panel-1',
|
||||
...overrides,
|
||||
};
|
||||
render(<ConfigPane {...props} />);
|
||||
@@ -63,4 +75,28 @@ describe('ConfigPane', () => {
|
||||
screen.getByTestId('config-section-formatting-&-units'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Actions group for a create-alert-capable panel', () => {
|
||||
// renderConfigPane defaults to a TimeSeries panel, which can seed an alert.
|
||||
renderConfigPane();
|
||||
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-create-alert'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the create-alert action for a kind that cannot seed an alert', () => {
|
||||
// Table panels can't seed alerts → the Actions group hides its row. Only the
|
||||
// panel passed to ConfigActions needs the kind; sections are asserted elsewhere.
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: { ...spec(), plugin: { kind: 'signoz/TablePanel', spec: {} } },
|
||||
} as DashboardtypesPanelDTO;
|
||||
renderConfigPane({ panel });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
label="Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
// Standalone View stacks the graph-manager below the chart inside the surface (it
|
||||
// must stay within the chart's PlotContext). Let it flow out of the surface so the
|
||||
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
|
||||
.surfaceStacked {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
@@ -30,6 +32,14 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,10 @@ function PreviewPane({
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
@@ -58,23 +72,27 @@ function PreviewPane({
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
{!hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
})}
|
||||
>
|
||||
<PanelHeader
|
||||
name={panel.spec.display.name}
|
||||
description={panel.spec.display.description}
|
||||
panelId={panelId}
|
||||
panelKind={panel.spec.plugin.kind}
|
||||
panel={panel}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
@@ -92,9 +110,11 @@ function PreviewPane({
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
|
||||
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
|
||||
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
|
||||
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
|
||||
*/
|
||||
|
||||
const mockSetSpec = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
|
||||
}));
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('../../hooks/usePanelQuery', () => ({
|
||||
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
|
||||
}));
|
||||
|
||||
const mockUseQuerySync = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
|
||||
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
|
||||
}));
|
||||
|
||||
const mockUseTypeSwitch = jest.fn();
|
||||
jest.mock('../hooks/usePanelTypeSwitch', () => ({
|
||||
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
|
||||
useSwitchColumnsOnSignalChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useSeedNewListColumns', () => ({
|
||||
useSeedNewListColumns: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useLegendSeries', () => ({
|
||||
useLegendSeries: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
|
||||
() => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: {},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@signozhq/ui/resizable', () => ({
|
||||
__esModule: true,
|
||||
ResizablePanelGroup: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ResizableHandle: (): null => null,
|
||||
useDefaultLayout: (): unknown => ({
|
||||
defaultLayout: undefined,
|
||||
onLayoutChanged: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// Children mocked to capture props (and expose a Save trigger / footer slot).
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
const mockPreviewProps = jest.fn();
|
||||
jest.mock('../PreviewPane/PreviewPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockPreviewProps(props);
|
||||
return <div data-testid="preview" />;
|
||||
},
|
||||
}));
|
||||
const mockQbProps = jest.fn();
|
||||
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { footer?: React.ReactNode }): JSX.Element => {
|
||||
mockQbProps(props);
|
||||
return <div data-testid="qb">{props.footer}</div>;
|
||||
},
|
||||
}));
|
||||
const mockConfigProps = jest.fn();
|
||||
jest.mock('../ConfigPane/ConfigPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockConfigProps(props);
|
||||
return <div data-testid="config" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="list-columns" />,
|
||||
}));
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
): void {
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: false,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { response: undefined },
|
||||
isFetching: false,
|
||||
error: null,
|
||||
cancelQuery: mockCancelQuery,
|
||||
refetch: mockRefetch,
|
||||
pagination: undefined,
|
||||
});
|
||||
mockUseQuerySync.mockReturnValue({
|
||||
runQuery: jest.fn(),
|
||||
isQueryDirty: false,
|
||||
buildSaveSpec: mockBuildSaveSpec,
|
||||
});
|
||||
mockUseTypeSwitch.mockReturnValue({
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
});
|
||||
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('qb')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('config')).toBeInTheDocument();
|
||||
|
||||
expect(mockPreviewProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
spec: panel.spec,
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
|
||||
);
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel dirty and always serializes its query', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alwaysSerializeQuery: true }),
|
||||
);
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bakes the live query into the spec on save, then notifies', async () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel, { onSaved: baseProps.onSaved });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
|
||||
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('renders the list-columns editor only for list panels', () => {
|
||||
setup(makePanel('signoz/ListPanel'));
|
||||
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the list-columns editor for non-list panels', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
|
||||
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
|
||||
seedQuerySignal?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePanelEditSessionApi {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft. */
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel-editing pipeline shared by the full-page editor and the View modal's
|
||||
* drilldown editor: a local draft, its query result over the resolved time window,
|
||||
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
|
||||
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
|
||||
* time isolation + reset). Keeping the wiring here stops the two from drifting.
|
||||
*/
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionApi {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[fullKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
time,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
panelType,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
@@ -10,13 +10,7 @@ import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
@@ -27,11 +21,8 @@ import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
@@ -67,7 +58,28 @@ function PanelEditorContainer({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } = query;
|
||||
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
@@ -91,37 +103,7 @@ function PanelEditorContainer({
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type, which drives the query builder and preview.
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
signal: defaultSignal,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
@@ -242,7 +224,8 @@ function PanelEditorContainer({
|
||||
className={styles.right}
|
||||
>
|
||||
<ConfigPane
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Router location state for opening the panel editor pre-loaded with edits instead of
|
||||
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
|
||||
* drilldown-edited spec (queries/plugin) into the editor.
|
||||
*/
|
||||
export interface PanelEditorHandoffState {
|
||||
editSpec?: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -37,6 +39,7 @@ function BarPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -114,6 +117,32 @@ function BarPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -147,6 +176,7 @@ function BarPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -37,6 +39,7 @@ function TimeSeriesPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -115,6 +118,32 @@ function TimeSeriesPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -148,6 +177,7 @@ function TimeSeriesPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chartManagerContainer {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export type PanelClickEvent =
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/** Close the standalone View modal — fired by the chart's graph-manager Save/Cancel. */
|
||||
type CloseStandaloneView = () => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props — each kind exposes only the gestures it supports.
|
||||
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
|
||||
@@ -31,10 +34,12 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
@@ -50,4 +55,5 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -41,10 +41,6 @@ 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.
|
||||
@@ -55,7 +51,8 @@ function Panel({
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
// 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).
|
||||
@@ -77,10 +74,8 @@ function Panel({
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<PanelHeader
|
||||
name={name}
|
||||
description={description}
|
||||
panelId={panelId}
|
||||
panelKind={fullKind}
|
||||
panel={panel}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -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;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
|
||||
panelKind: PanelKind;
|
||||
/** The panel itself — its query seeds "Create Alerts". */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -23,12 +23,12 @@ interface PanelActionsMenuProps {
|
||||
*/
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -14,6 +14,19 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockOpenView = jest.fn();
|
||||
jest.mock('../../hooks/useViewPanel', () => ({
|
||||
useViewPanel: (): {
|
||||
openView: jest.Mock;
|
||||
closeView: jest.Mock;
|
||||
expandedPanelId: string | null;
|
||||
} => ({
|
||||
openView: mockOpenView,
|
||||
closeView: jest.fn(),
|
||||
expandedPanelId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
@@ -29,6 +42,11 @@ 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';
|
||||
@@ -55,9 +73,20 @@ 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',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panel: mockPanel,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
@@ -115,29 +144,18 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
|
||||
// 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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
@@ -259,18 +277,21 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
it('view opens the View modal for the panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const view = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'view-panel',
|
||||
);
|
||||
(view as { onClick: () => void }).onClick();
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
['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(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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -23,13 +24,14 @@ 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 { useViewPanel } from '../hooks/useViewPanel';
|
||||
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).
|
||||
@@ -103,8 +105,8 @@ function buildMoveItems({
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
|
||||
panelKind: PanelKind;
|
||||
/** The panel itself — its query seeds the "Create Alerts" action. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -128,9 +130,10 @@ export interface PanelActionItems {
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const { user } = useAppContext();
|
||||
const [canEditWidget, canMove, canDelete] = useComponentPermission(
|
||||
[
|
||||
@@ -143,6 +146,8 @@ export function usePanelActionItems({
|
||||
);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
@@ -151,7 +156,7 @@ export function usePanelActionItems({
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const kindActions = getPanelDefinition(panelKind)?.actions;
|
||||
const panelCapabilities = getPanelDefinition(panelKind).actions;
|
||||
|
||||
// Delete runs on confirm, not on click — the menu item opens a prompt.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
@@ -170,15 +175,15 @@ export function usePanelActionItems({
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (kindActions?.view) {
|
||||
if (panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
onClick: (): void => openView(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && kindActions?.edit) {
|
||||
if (isEditable && canEditWidget && panelCapabilities.edit) {
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
@@ -188,7 +193,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 && kindActions?.clone) {
|
||||
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
@@ -202,7 +207,7 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (kindActions?.download) {
|
||||
if (panelCapabilities.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
@@ -210,12 +215,15 @@ export function usePanelActionItems({
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
}
|
||||
if (isEditable && kindActions?.createAlert) {
|
||||
// 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) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
icon: <Bell size={14} />,
|
||||
onClick: (): void => notImplementedYet('Create Alerts'),
|
||||
onClick: (): void => createAlert(panel, panelId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,11 +260,14 @@ export function usePanelActionItems({
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
kindActions,
|
||||
panelCapabilities,
|
||||
panel,
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
movePanel,
|
||||
clonePanel,
|
||||
requestDelete,
|
||||
|
||||
@@ -32,6 +32,8 @@ interface PanelBodyProps {
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles — only consumed by raw/list renderers. */
|
||||
pagination?: PanelPagination;
|
||||
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,7 @@ function PanelBody({
|
||||
panelMode = PanelMode.DASHBOARD_VIEW,
|
||||
searchTerm,
|
||||
pagination,
|
||||
onCloseStandaloneView,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// react-query keeps the previous response during refetches, so its presence is
|
||||
// the "have something to show" signal — only fail hard when there's nothing.
|
||||
@@ -112,6 +115,7 @@ function PanelBody({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
|
||||
@@ -14,15 +17,12 @@ 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;
|
||||
/** Full plugin kind — drives kind-gated menu actions. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Background refresh in flight — shows a spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error — surfaced as a header error indicator. */
|
||||
@@ -49,10 +49,8 @@ interface PanelHeaderProps {
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
name,
|
||||
description,
|
||||
panelId,
|
||||
panelKind,
|
||||
panel,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
@@ -63,6 +61,8 @@ 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}
|
||||
panelKind={panelKind}
|
||||
panel={panel}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// Expanded state: a compact input that fits the header row.
|
||||
.input {
|
||||
width: 180px;
|
||||
width: min(100%, 320px);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
--button-height: 18px;
|
||||
--button-width: 18px;
|
||||
--button-padding: 0;
|
||||
}
|
||||
|
||||
.searchTrigger {
|
||||
--button-width: 24px;
|
||||
--button-height: 24px;
|
||||
--button-padding: 4px;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function PanelHeaderSearch({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
className={styles.searchTrigger}
|
||||
data-testid="panel-header-search-trigger"
|
||||
aria-label="Search"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tall, fixed-height column so the renderer's resize observer measures real
|
||||
// dimensions — the chart self-sizes to fill whatever space it's given.
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 78vh;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.queryBuilder {
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbarTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.panelTypeSelector {
|
||||
width: 240px;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Modal } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface ViewPanelModalProps {
|
||||
/**
|
||||
* The expanded panel and its id. Absent while the modal is closed — a single
|
||||
* host instance lives at the layout level and only carries a panel when open.
|
||||
*/
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
panelId?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ViewPanelModal({
|
||||
panel,
|
||||
panelId,
|
||||
open,
|
||||
onClose,
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<span className={styles.title}>{name} - (View mode)</span>
|
||||
</TooltipSimple>
|
||||
}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModal;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelEditor } from './useViewPanelEditor';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Close the modal — wired to the graph manager's Save/Cancel. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
|
||||
* the panel (preview) over a per-view time window plus the shared query builder, so the
|
||||
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
|
||||
*/
|
||||
function ViewPanelModalContent({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
}: ViewPanelModalContentProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
defaultSignal,
|
||||
queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
} = useViewPanelEditor({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
const isolatedPreference = useMemo<DashboardPreference>(
|
||||
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
|
||||
[dashboardPreference],
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
selectedInterval={selectedInterval}
|
||||
startMs={timeOverride.startMs}
|
||||
endMs={timeOverride.endMs}
|
||||
onTimeChange={onTimeChange}
|
||||
isFetching={isFetching}
|
||||
onRefresh={(): void => {
|
||||
// Relative windows re-anchor to now (new key → refetch); a fixed
|
||||
// custom window just re-runs the same query.
|
||||
if (selectedInterval === 'custom') {
|
||||
refetch();
|
||||
} else {
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={(): void =>
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
|
||||
}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
/>
|
||||
<div className={styles.queryBuilder}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
signal={signal ?? defaultSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalContent;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PenLine, RotateCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { usePanelTypeSelectItems } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
|
||||
import ConfigSelect from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Current window bounds (epoch ms) — seed the picker's modal display. */
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Any query in flight — spins the refresh icon and disables it. */
|
||||
isFetching: boolean;
|
||||
onRefresh: () => void;
|
||||
onSwitchToEdit: () => void;
|
||||
/** Draft's current kind (selected value of the panel-type selector). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — disables kinds that can't be authored in it (e.g. List under PromQL). */
|
||||
queryType?: EQueryType;
|
||||
/** Current builder datasource — disables types that don't support it. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved query + kind (drilldown reset). */
|
||||
onResetQuery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
|
||||
* visualization kind, pick a per-view time window (isolated from the dashboard), and
|
||||
* refresh. Mirrors V1's FullView header controls.
|
||||
*/
|
||||
function ViewPanelModalHeader({
|
||||
selectedInterval,
|
||||
startMs,
|
||||
endMs,
|
||||
onTimeChange,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
onSwitchToEdit,
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
onChangePanelKind,
|
||||
onResetQuery,
|
||||
}: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.panelTypeSelector}>
|
||||
<ConfigSelect<PanelKind>
|
||||
testId="view-panel-type-selector"
|
||||
value={panelKind}
|
||||
items={panelTypeItems}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={onResetQuery}
|
||||
data-testid="view-panel-reset-query"
|
||||
>
|
||||
Reset Query
|
||||
</Button>
|
||||
<div className={styles.toolbarTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection
|
||||
disableUrlSync
|
||||
onTimeChange={onTimeChange}
|
||||
modalSelectedInterval={selectedInterval as Time}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh"
|
||||
data-testid="view-panel-refresh"
|
||||
>
|
||||
<RotateCw className={cx({ 'animate-spin': isFetching })} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalHeader;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface UseViewPanelEditorArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
|
||||
time: PanelQueryTimeOverride;
|
||||
}
|
||||
|
||||
export interface UseViewPanelEditorApi {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Resolved renderer for the draft's current kind. */
|
||||
panelDefinition: RenderablePanelDefinition | undefined;
|
||||
/** Current builder datasource — drives the panel-type selector's disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** The kind's first supported signal — the query builder's fallback datasource. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
|
||||
queryType: EQueryType;
|
||||
/** Query result for the draft over the per-view window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
runQuery: () => void;
|
||||
/** Switch the draft's visualization kind (temporary; reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved panel's query + kind, discarding the drilldown edits. */
|
||||
resetQuery: () => void;
|
||||
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the View modal into a compact, drilldown panel editor on top of the shared
|
||||
* `usePanelEditSession`: the same draft/query/query-sync/type-switch pipeline the
|
||||
* full editor uses, scoped to a per-view time window, plus drilldown-only extras
|
||||
* (the saved-query snapshot for Reset, and the builder signal for the type selector).
|
||||
* Edits are temporary — they live in the builder/URL and the draft, never the
|
||||
* dashboard, matching V1.
|
||||
*/
|
||||
export function useViewPanelEditor({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
}: UseViewPanelEditorArgs): UseViewPanelEditorApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
buildSaveSpec,
|
||||
reset,
|
||||
} = usePanelEditSession({ panel, panelId, time });
|
||||
|
||||
// The saved panel's query, captured once — the restore target for Reset Query.
|
||||
const savedQuery = useMemo(
|
||||
() =>
|
||||
fromPerses(
|
||||
panel.spec.queries,
|
||||
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
[],
|
||||
);
|
||||
|
||||
const resetQuery = useCallback((): void => {
|
||||
// Draft back to the saved panel (query + kind); builder back to the saved query.
|
||||
reset();
|
||||
redirectWithQueryBuilderData(savedQuery);
|
||||
}, [reset, redirectWithQueryBuilderData, savedQuery]);
|
||||
|
||||
// Current builder datasource for the panel-type disabled rule — resolved the same
|
||||
// way as the full editor's ConfigPane so the two selectors stay in sync.
|
||||
const signal = resolveSignal(draft.spec.queries, defaultSignal);
|
||||
|
||||
return {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
defaultSignal,
|
||||
queryType: currentQuery.queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
export interface ViewPanelTimeWindow {
|
||||
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
|
||||
timeOverride: PanelQueryTimeOverride;
|
||||
/** Interval shown in the picker — a relative `Time` or `'custom'`. */
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Apply a selection from DateTimeSelectionV2 (modal mode). */
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Re-anchor a relative window to "now" (manual refresh); no-op for custom. */
|
||||
refreshWindow: () => void;
|
||||
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view time window for the panel View modal, isolated from the dashboard's
|
||||
* global time (V1 parity: the modal's time selector doesn't move the grid). Seeded
|
||||
* once from the current global window, then owned locally. Relative intervals
|
||||
* resolve to an absolute ms window via the same `GetMinMax` the app-wide picker uses.
|
||||
*/
|
||||
export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const [selectedInterval, setSelectedInterval] = useState<
|
||||
Time | CustomTimeType
|
||||
>(selectedTime as Time);
|
||||
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
startMs: Math.floor(minTime / NS_PER_MS),
|
||||
endMs: Math.floor(maxTime / NS_PER_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, range?: [number, number]): void => {
|
||||
setSelectedInterval(interval);
|
||||
// Absolute range comes through directly (already epoch ms).
|
||||
if (interval === 'custom' && range) {
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(range[0]),
|
||||
endMs: Math.floor(range[1]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// GetMinMax returns nanoseconds — convert to the ms window we work in.
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshWindow = useCallback((): void => {
|
||||
// A custom window is fixed; only relative intervals re-anchor to now.
|
||||
if (selectedInterval === 'custom') {
|
||||
return;
|
||||
}
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
}, [selectedInterval]);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
// Drag values are already epoch ms (same as the global custom range).
|
||||
const startMs = Math.floor(start);
|
||||
const endMs = Math.floor(end);
|
||||
// Ignore a click / zero-width or inverted selection.
|
||||
if (startMs >= endMs) {
|
||||
return;
|
||||
}
|
||||
setSelectedInterval('custom');
|
||||
setTimeOverride({ startMs, endMs });
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
}),
|
||||
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
|
||||
);
|
||||
}
|
||||
@@ -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,9 +22,26 @@ 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 = {
|
||||
name: 'My panel',
|
||||
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
|
||||
panel: makePanel(),
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
};
|
||||
@@ -44,7 +61,10 @@ describe('PanelHeader title and description', () => {
|
||||
|
||||
it('shows the description info icon when a description is provided', () => {
|
||||
renderWithProvider(
|
||||
<PanelHeader {...baseProps} description="What this panel measures" />,
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
panel={makePanel({ description: 'What this panel measures' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('panel-header-info-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
|
||||
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
|
||||
// path); stub it (capturing props) so this suite asserts the modal shell + what it
|
||||
// threads down, not the preview internals (PreviewPane/PanelHeader own those).
|
||||
const mockPreviewPaneRender = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
|
||||
() =>
|
||||
function MockPreviewPane(props: Record<string, unknown>): ReactElement {
|
||||
mockPreviewPaneRender(props);
|
||||
return <div data-testid="preview-pane" />;
|
||||
},
|
||||
);
|
||||
|
||||
// Isolate from the draft/query-builder plumbing (its own suite covers it).
|
||||
jest.mock('../ViewPanelModal/useViewPanelEditor', () => ({
|
||||
useViewPanelEditor: (args: {
|
||||
panel: { spec: { plugin: { kind: string } } };
|
||||
}): unknown => {
|
||||
const { kind } = args.panel.spec.plugin;
|
||||
return {
|
||||
draft: args.panel,
|
||||
panelDefinition: {
|
||||
kind,
|
||||
actions: { search: kind === 'signoz/ListPanel' },
|
||||
Renderer: (): null => null,
|
||||
},
|
||||
query: {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
},
|
||||
runQuery: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
signal: undefined,
|
||||
defaultSignal: 'logs',
|
||||
buildSaveSpec: (spec: unknown): unknown => spec,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// The View modal reuses the edit page's query builder, which reads the global
|
||||
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder',
|
||||
() =>
|
||||
function MockPanelEditorQueryBuilder(): ReactElement {
|
||||
return <div data-testid="panel-editor-v2-query-builder" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../hooks/usePanelInteractions', () => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: { syncMode: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
// The header mounts DateTimeSelectionV2 (redux + router + heavy deps); stub it so
|
||||
// this suite asserts the modal body, not the toolbar internals.
|
||||
jest.mock(
|
||||
'../ViewPanelModal/ViewPanelModalHeader',
|
||||
() =>
|
||||
function MockViewPanelModalHeader(): ReactElement {
|
||||
return <div data-testid="view-panel-header" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
|
||||
useViewPanelTimeWindow: (): unknown => ({
|
||||
timeOverride: { startMs: 0, endMs: 0 },
|
||||
selectedInterval: '5m',
|
||||
onTimeChange: jest.fn(),
|
||||
refreshWindow: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
function makePanel(kind: string, name = 'My panel'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ViewPanelModal', () => {
|
||||
it('renders nothing until opened', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open={false}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-modal-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header, query builder, and preview when open', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel', 'CPU usage')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('view-panel-modal-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-query-builder'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onClose when the modal is dismissed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Charts share one global cursor-sync key and uPlot replays drag across the
|
||||
// group; the modal must opt out so a drag here can't move the dashboard's time.
|
||||
it('opts the chart out of the dashboard cursor-sync group', () => {
|
||||
mockPreviewPaneRender.mockClear();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
|
||||
dashboardPreference?: { syncMode?: unknown };
|
||||
};
|
||||
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import { useViewPanelTimeWindow } from '../ViewPanelModal/useViewPanelTimeWindow';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
// Global time is stored in nanoseconds; the hook must surface milliseconds.
|
||||
const mockState = {
|
||||
globalTime: {
|
||||
selectedTime: '6h',
|
||||
minTime: 6_000_000 * NS_PER_MS,
|
||||
maxTime: 7_000_000 * NS_PER_MS,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector(mockState),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetMinMax = GetMinMax as unknown as jest.Mock;
|
||||
|
||||
describe('useViewPanelTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('seeds the window from global time, converting ns → ms', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: mockState.globalTime.minTime / NS_PER_MS,
|
||||
endMs: mockState.globalTime.maxTime / NS_PER_MS,
|
||||
});
|
||||
expect(result.current.selectedInterval).toBe('6h');
|
||||
});
|
||||
|
||||
it('converts GetMinMax (ns) to ms on a relative selection', () => {
|
||||
mockGetMinMax.mockReturnValue({
|
||||
minTime: 1_700_000_000_000 * NS_PER_MS,
|
||||
maxTime: 1_700_000_300_000 * NS_PER_MS,
|
||||
});
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('5m'));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('5m');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1_700_000_000_000,
|
||||
endMs: 1_700_000_300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an absolute custom range as-is (already ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('custom', [111, 222]));
|
||||
|
||||
expect(mockGetMinMax).not.toHaveBeenCalled();
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 111,
|
||||
endMs: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets a custom window from a drag selection (modal-local, ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onDragSelect(1000, 5000));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('custom');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1000,
|
||||
endMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a zero-width or inverted drag selection', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
const initial = result.current.timeOverride;
|
||||
|
||||
act(() => result.current.onDragSelect(5000, 5000));
|
||||
act(() => result.current.onDragSelect(9000, 1000));
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual(initial);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
/** Open the View modal for a panel by writing its id to the URL. */
|
||||
openView: (panelId: string) => void;
|
||||
/** Close the View modal by clearing the URL param. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
|
||||
* the open state is shareable, survives refresh, and the browser back-button
|
||||
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
|
||||
|
||||
const openView = useCallback(
|
||||
(panelId: string): void => {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const closeView = useCallback((): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.delete(QueryParams.expandedWidgetId);
|
||||
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, closeView };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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()}`;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { layoutsToSections } from '../utils';
|
||||
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import { useViewPanel } from './Panel/hooks/useViewPanel';
|
||||
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
|
||||
import Section from './Section/Section/Section';
|
||||
import SectionList from './Section/SectionList';
|
||||
import styles from './PanelsAndSectionsLayout.module.scss';
|
||||
@@ -26,6 +28,12 @@ function PanelsAndSectionsLayout({
|
||||
}: PanelsAndSectionsLayoutProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
// Single View-modal host for the whole dashboard, driven by the URL
|
||||
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
|
||||
// subscriptions, and the expanded panel is looked up by id from the map.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
@@ -56,7 +64,17 @@ function PanelsAndSectionsLayout({
|
||||
));
|
||||
};
|
||||
|
||||
return <div className={styles.body}>{renderContent()}</div>;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
{renderContent()}
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanel}
|
||||
panel={expandedPanel}
|
||||
panelId={expandedPanelId ?? undefined}
|
||||
onClose={closeView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelsAndSectionsLayout;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -3,21 +3,28 @@ import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id.
|
||||
* caller can open the editor with just the panel id. The optional `state` is passed as router
|
||||
* location state — the View modal uses it to hand off its drilldown-edited spec so the editor
|
||||
* opens on those edits rather than the saved panel.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (panelId: string) => void {
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
state?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string): void => {
|
||||
(panelId: string, state?: PanelEditorHandoffState): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
state ? { state } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
|
||||
@@ -17,6 +17,8 @@ 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';
|
||||
|
||||
@@ -65,8 +67,9 @@ 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,
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function usePanelQuery({
|
||||
panel,
|
||||
@@ -105,6 +108,11 @@ 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;
|
||||
@@ -141,8 +149,19 @@ export function usePanelQuery({
|
||||
endMs,
|
||||
fillGaps,
|
||||
pagination: isPaginated ? { offset, limit: pageSize } : undefined,
|
||||
variables,
|
||||
}),
|
||||
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
|
||||
[
|
||||
queries,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
isPaginated,
|
||||
offset,
|
||||
pageSize,
|
||||
variables,
|
||||
],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
|
||||
@@ -167,6 +186,8 @@ 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,
|
||||
@@ -182,6 +203,7 @@ export function usePanelQuery({
|
||||
queries,
|
||||
offset,
|
||||
pageSize,
|
||||
variables,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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]);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -50,6 +51,10 @@ 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;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Querybuildertypesv5PromQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
@@ -202,11 +203,13 @@ 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 are absent (`variables: {}`) until V2 grows its own variable plumbing.
|
||||
* intermediary). `variables` carries the runtime selection (empty when the dashboard has none).
|
||||
*/
|
||||
export function buildQueryRangeRequest({
|
||||
queries,
|
||||
@@ -215,6 +218,7 @@ export function buildQueryRangeRequest({
|
||||
endMs,
|
||||
fillGaps = false,
|
||||
pagination,
|
||||
variables = {},
|
||||
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
let envelopes = toQueryEnvelopes(queries);
|
||||
if (panelType === PANEL_TYPES.BAR) {
|
||||
@@ -234,7 +238,7 @@ export function buildQueryRangeRequest({
|
||||
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
|
||||
fillGaps,
|
||||
},
|
||||
variables: {},
|
||||
variables,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
@@ -12,9 +13,19 @@ 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,
|
||||
@@ -22,6 +33,11 @@ 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<
|
||||
@@ -31,6 +47,7 @@ export const createVariableSelectionSlice: StateCreator<
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
resolvedVariables: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
@@ -46,6 +63,12 @@ export const createVariableSelectionSlice: StateCreator<
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
setResolvedVariables: (dashboardId, variables): void => {
|
||||
const { resolvedVariables } = get();
|
||||
set({
|
||||
resolvedVariables: { ...resolvedVariables, [dashboardId]: variables },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -60,3 +83,13 @@ 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;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
@@ -32,9 +33,13 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}>();
|
||||
const { search } = useLocation();
|
||||
const { search, state } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
|
||||
|
||||
const { data, isLoading, isError, error } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
@@ -44,17 +49,20 @@ function PanelEditorPage(): JSX.Element {
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(
|
||||
() =>
|
||||
newKind
|
||||
? createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
)
|
||||
: existingPanel,
|
||||
[newKind, existingPanel],
|
||||
);
|
||||
const panel = useMemo(() => {
|
||||
if (newKind) {
|
||||
return createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
);
|
||||
}
|
||||
if (!existingPanel) {
|
||||
return undefined;
|
||||
}
|
||||
// Open on the modal's drilldown edits when handed off; else the saved panel.
|
||||
return handoffSpec ? { ...existingPanel, spec: handoffSpec } : existingPanel;
|
||||
}, [newKind, existingPanel, handoffSpec]);
|
||||
|
||||
// Target section for a newly-created panel (set by the "Add panel" trigger).
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
@@ -145,86 +145,5 @@ 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
|
||||
}
|
||||
|
||||
@@ -39,15 +39,6 @@ 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
|
||||
|
||||
@@ -102,14 +93,8 @@ 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)
|
||||
|
||||
@@ -189,22 +189,10 @@ func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *autht
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Patch(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) PatchObjects(_ context.Context, _ valuer.UUID, _ string, _ authtypes.Relation, _, _ []*coretypes.Object) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(_ context.Context, _ valuer.UUID, _ valuer.UUID) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
@@ -74,46 +74,6 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
|
||||
}
|
||||
|
||||
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
|
||||
return
|
||||
}
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relationStr, ok := mux.Vars(r)["relation"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
|
||||
return
|
||||
}
|
||||
|
||||
relation, err := coretypes.NewVerb(relationStr)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
objects, err := handler.authz.GetObjects(ctx, valuer.MustNewUUID(claims.OrgID), roleID, authtypes.Relation{Verb: relation})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, coretypes.NewObjectGroupsFromObjects(objects))
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -131,99 +91,6 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, roles)
|
||||
}
|
||||
|
||||
func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(authtypes.PatchableRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = role.PatchMetadata(req.Description)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.Patch(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relation, err := coretypes.NewVerb(mux.Vars(r)["relation"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := role.ErrIfManaged(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(coretypes.PatchableObjects)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
additions, deletions, err := coretypes.NewPatchableObjects(req.Additions, req.Deletions, relation)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), role.Name, authtypes.Relation{Verb: relation}, additions, deletions)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
|
||||
@@ -49,7 +49,7 @@ type Module interface {
|
||||
// DeleteUnsafe deletes a dashboard bypassing the guards. Intended for internal system callers.
|
||||
DeleteUnsafe(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
|
||||
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error)
|
||||
|
||||
statsreporter.StatsCollector
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -168,13 +169,13 @@ func (module *module) DeleteUnsafe(ctx context.Context, orgID valuer.UUID, id va
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
dashboards, err := module.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]map[string]string)
|
||||
result := make(map[string][]dashboardtypes.DashboardPanelRef)
|
||||
|
||||
for _, dashboard := range dashboards {
|
||||
dashData := dashboard.Data
|
||||
@@ -198,21 +199,27 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
|
||||
continue
|
||||
}
|
||||
|
||||
// Track which metrics were found in this widget
|
||||
// Track which metrics were found in this widget, along with the
|
||||
// group-by and filter labels referenced for each metric. CH/PromQL
|
||||
// paths are presence-only and leave the label sets empty.
|
||||
foundMetrics := make(map[string]bool)
|
||||
groupByByMetric := make(map[string][]string)
|
||||
filterByByMetric := make(map[string][]string)
|
||||
|
||||
// Check all three query types
|
||||
module.checkBuilderQueriesForMetricNames(query, metricNames, foundMetrics)
|
||||
module.checkBuilderQueriesForMetricNames(query, metricNames, foundMetrics, groupByByMetric, filterByByMetric)
|
||||
module.checkClickHouseQueriesForMetricNames(ctx, query, metricNames, foundMetrics)
|
||||
module.checkPromQLQueriesForMetricNames(ctx, query, metricNames, foundMetrics)
|
||||
|
||||
// Add widget to results for all found metrics
|
||||
for metricName := range foundMetrics {
|
||||
result[metricName] = append(result[metricName], map[string]string{
|
||||
"dashboard_id": dashboard.ID,
|
||||
"widget_name": widgetTitle,
|
||||
"widget_id": widgetID,
|
||||
"dashboard_name": dashTitle,
|
||||
result[metricName] = append(result[metricName], dashboardtypes.DashboardPanelRef{
|
||||
DashboardID: dashboard.ID,
|
||||
DashboardName: dashTitle,
|
||||
PanelID: widgetID,
|
||||
PanelName: widgetTitle,
|
||||
GroupBy: groupByByMetric[metricName],
|
||||
FilterBy: filterByByMetric[metricName],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -260,7 +267,10 @@ func (module *module) DeletePublic(_ context.Context, _ valuer.UUID, _ valuer.UU
|
||||
}
|
||||
|
||||
// checkBuilderQueriesForMetricNames checks builder.queryData[] for aggregations[].metricName.
|
||||
func (module *module) checkBuilderQueriesForMetricNames(query map[string]interface{}, metricNames []string, foundMetrics map[string]bool) {
|
||||
// For each queryData entry whose dataSource is "metrics" and that references a
|
||||
// target metric, it accumulates (deduped) the group-by and filter labels used
|
||||
// by that entry into groupByByMetric/filterByByMetric, keyed by metric name.
|
||||
func (module *module) checkBuilderQueriesForMetricNames(query map[string]interface{}, metricNames []string, foundMetrics map[string]bool, groupByByMetric, filterByByMetric map[string][]string) {
|
||||
builder, ok := query["builder"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
@@ -288,6 +298,7 @@ func (module *module) checkBuilderQueriesForMetricNames(query map[string]interfa
|
||||
continue
|
||||
}
|
||||
|
||||
entryMetrics := make([]string, 0, len(aggregations))
|
||||
for _, agg := range aggregations {
|
||||
aggMap, ok := agg.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -301,9 +312,98 @@ func (module *module) checkBuilderQueriesForMetricNames(query map[string]interfa
|
||||
|
||||
if slices.Contains(metricNames, metricName) {
|
||||
foundMetrics[metricName] = true
|
||||
entryMetrics = append(entryMetrics, metricName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(entryMetrics) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
groupBy := extractBuilderGroupByLabels(data)
|
||||
filterBy := extractBuilderFilterLabels(data)
|
||||
for _, metricName := range entryMetrics {
|
||||
groupByByMetric[metricName] = appendDedup(groupByByMetric[metricName], groupBy...)
|
||||
filterByByMetric[metricName] = appendDedup(filterByByMetric[metricName], filterBy...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractBuilderGroupByLabels(data map[string]interface{}) []string {
|
||||
gb, ok := data["groupBy"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(gb))
|
||||
for _, g := range gb {
|
||||
gm, ok := g.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name, ok := gm["name"].(string); ok && name != "" {
|
||||
out = append(out, name)
|
||||
continue
|
||||
}
|
||||
// v3: groupBy[].key may be a plain string ...
|
||||
if key, ok := gm["key"].(string); ok && key != "" {
|
||||
out = append(out, key)
|
||||
continue
|
||||
}
|
||||
// ... or a nested object {key: "<name>"}.
|
||||
if km, ok := gm["key"].(map[string]interface{}); ok {
|
||||
if key, ok := km["key"].(string); ok && key != "" {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractBuilderFilterLabels(data map[string]interface{}) []string {
|
||||
out := []string{}
|
||||
|
||||
// v5: filter.expression
|
||||
if f, ok := data["filter"].(map[string]interface{}); ok {
|
||||
if expr, ok := f["expression"].(string); ok && expr != "" {
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(expr) {
|
||||
if sel != nil && sel.Name != "" {
|
||||
out = append(out, sel.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v3: filters.items[].key.key
|
||||
if f, ok := data["filters"].(map[string]interface{}); ok {
|
||||
if items, ok := f["items"].([]interface{}); ok {
|
||||
for _, it := range items {
|
||||
im, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
km, ok := im["key"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, ok := km["key"].(string); ok && key != "" {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func appendDedup(dst []string, values ...string) []string {
|
||||
for _, v := range values {
|
||||
if v == "" || slices.Contains(dst, v) {
|
||||
continue
|
||||
}
|
||||
dst = append(dst, v)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// checkClickHouseQueriesForMetricNames checks clickhouse_sql[] array for metric names in query strings.
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
@@ -54,7 +56,13 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
|
||||
|
||||
fieldValueSelector := telemetrytypes.NewFieldValueSelectorFromPostableFieldValueParams(params)
|
||||
|
||||
allValues, allComplete, err := handler.telemetryMetadataStore.GetAllValues(ctx, fieldValueSelector)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
allValues, allComplete, err := handler.telemetryMetadataStore.GetAllValues(ctx, valuer.MustNewUUID(claims.OrgID), fieldValueSelector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -132,12 +132,12 @@ func (m *module) getTopClusterGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getClustersTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
|
||||
func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range clusterAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -140,12 +140,12 @@ func (m *module) getTopDaemonSetGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getDaemonSetsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableDaemonSets) (map[string]map[string]string, error) {
|
||||
func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range daemonSetAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -140,12 +140,12 @@ func (m *module) getTopDeploymentGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getDeploymentsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableDeployments) (map[string]map[string]string, error) {
|
||||
func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range deploymentAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
@@ -367,6 +370,33 @@ func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, sample
|
||||
return fpSB
|
||||
}
|
||||
|
||||
// buildReducedSamplesTblFingerprintSubQuery is like buildSamplesTblFingerprintSubQuery
|
||||
// but for the reduced tables.
|
||||
func (m *module) buildReducedSamplesTblFingerprintSubQuery(metricNames []string, flooredStart, flooredEnd uint64) *sqlbuilder.SelectBuilder {
|
||||
lastSB := sqlbuilder.NewSelectBuilder()
|
||||
lastSB.Select("reduced_fingerprint")
|
||||
lastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
lastSB.Where(
|
||||
lastSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
lastSB.GE("unix_milli", flooredStart),
|
||||
lastSB.L("unix_milli", flooredEnd),
|
||||
)
|
||||
|
||||
sumSB := sqlbuilder.NewSelectBuilder()
|
||||
sumSB.Select("reduced_fingerprint")
|
||||
sumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
sumSB.Where(
|
||||
sumSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sumSB.GE("unix_milli", flooredStart),
|
||||
sumSB.L("unix_milli", flooredEnd),
|
||||
)
|
||||
|
||||
fpSB := sqlbuilder.NewSelectBuilder()
|
||||
fpSB.Select("DISTINCT reduced_fingerprint AS fingerprint")
|
||||
fpSB.From(fpSB.BuilderAs(sqlbuilder.UnionAll(lastSB, sumSB), "reduced_samples"))
|
||||
return fpSB
|
||||
}
|
||||
|
||||
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
@@ -533,6 +563,7 @@ func (m *module) getAttributesExistence(ctx context.Context, metricNames, attrNa
|
||||
// mapping to a flat map of attr_name -> attr_value (includes both groupBy and additional cols).
|
||||
func (m *module) getMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
metricNames []string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
additionalCols []string,
|
||||
@@ -546,6 +577,8 @@ func (m *module) getMetadata(
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "groupBy must not be empty")
|
||||
}
|
||||
|
||||
reductionEnabled := m.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
|
||||
// Step-floor the window and pick the right tables — matches the bounds the
|
||||
// QB v5 metric querier uses, so metadataMap covers the same universe the
|
||||
// ranking sees (see alignedMetricWindow doc).
|
||||
@@ -583,22 +616,64 @@ func (m *module) getMetadata(
|
||||
}
|
||||
|
||||
innerSB.Select(innerSelectCols...)
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", tsAdjustedStartMs),
|
||||
innerSB.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", innerSB.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply optional filter expression
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if reductionEnabled {
|
||||
var filterClause *sqlbuilder.WhereClause
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
var err error
|
||||
filterClause, err = m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
reducedFpSB := m.buildReducedSamplesTblFingerprintSubQuery(metricNames, samplesStartMs, flooredEndMs)
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels", "unix_milli")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
rawSrc.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", rawSrc.Var(fpSB)),
|
||||
)
|
||||
if filterClause != nil {
|
||||
innerSB.AddWhereClause(filterClause)
|
||||
rawSrc.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
|
||||
}
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels", "unix_milli")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
reducedSrc.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", reducedSrc.Var(reducedFpSB)),
|
||||
)
|
||||
if filterClause != nil {
|
||||
reducedSrc.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
|
||||
}
|
||||
|
||||
// Inner query reads over the union of raw + reduced series.
|
||||
innerSB.From(innerSB.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
|
||||
} else {
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", tsAdjustedStartMs),
|
||||
innerSB.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", innerSB.Var(fpSB)),
|
||||
)
|
||||
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
innerSB.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -20,12 +22,15 @@ import (
|
||||
// and a simple uniqExactIf for total count. Inactive = total - active (computed in Go).
|
||||
func (m *module) getPerGroupHostStatusCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metricNames []string,
|
||||
pageGroups []map[string]string,
|
||||
sinceUnixMilli int64,
|
||||
) (map[string]groupHostStatusCounts, error) {
|
||||
|
||||
reductionEnabled := m.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
|
||||
// Build the full filter expression from req (user filter + status filter) and page groups.
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
@@ -53,27 +58,65 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
fmt.Sprintf("uniqExactIf(%s, %s != '') AS total_host_count", hostNameExpr, hostNameExpr),
|
||||
)
|
||||
|
||||
// Build a fingerprint subquery to restrict to fingerprints with actual sample
|
||||
// data in the floored time range.
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, localSamplesTable, samplesStartMs, flooredEndMs)
|
||||
|
||||
sb.Select(selectCols...)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", tsAdjustedStartMs),
|
||||
sb.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply the combined filter expression (user filter + status filter + page groups IN).
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if reductionEnabled {
|
||||
var filterClause *sqlbuilder.WhereClause
|
||||
if filterExpr != "" {
|
||||
var err error
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
rawSrc.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", rawSrc.Var(m.buildSamplesTblFingerprintSubQuery(metricNames, localSamplesTable, samplesStartMs, flooredEndMs))),
|
||||
)
|
||||
if filterClause != nil {
|
||||
sb.AddWhereClause(filterClause)
|
||||
rawSrc.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
|
||||
}
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
reducedSrc.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", reducedSrc.Var(m.buildReducedSamplesTblFingerprintSubQuery(metricNames, samplesStartMs, flooredEndMs))),
|
||||
)
|
||||
if filterClause != nil {
|
||||
reducedSrc.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
|
||||
}
|
||||
|
||||
sb.From(sb.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
|
||||
} else {
|
||||
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, localSamplesTable, samplesStartMs, flooredEndMs)
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", tsAdjustedStartMs),
|
||||
sb.LE("unix_milli", flooredEndMs),
|
||||
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
|
||||
)
|
||||
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
sb.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +331,7 @@ func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.Postable
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *module) getHostsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableHosts) (map[string]map[string]string, error) {
|
||||
func (m *module) getHostsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range hostAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
@@ -299,7 +342,7 @@ func (m *module) getHostsTableMetadata(ctx context.Context, req *inframonitoring
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
metadataMap, err := m.getMetadata(ctx, hostsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
metadataMap, err := m.getMetadata(ctx, orgID, hostsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -148,12 +148,12 @@ func (m *module) getTopJobGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getJobsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
|
||||
func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range jobAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
@@ -26,6 +27,7 @@ type module struct {
|
||||
condBuilder qbtypes.ConditionBuilder
|
||||
logger *slog.Logger
|
||||
config inframonitoring.Config
|
||||
fl flagger.Flagger
|
||||
}
|
||||
|
||||
// NewModule constructs the inframonitoring module with the provided dependencies.
|
||||
@@ -33,6 +35,7 @@ func NewModule(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
querier querier.Querier,
|
||||
fl flagger.Flagger,
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
@@ -46,6 +49,7 @@ func NewModule(
|
||||
condBuilder: condBuilder,
|
||||
logger: providerSettings.Logger,
|
||||
config: cfg,
|
||||
fl: fl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +190,7 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getHostsTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getHostsTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -222,7 +226,7 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
hostCounts := make(map[string]groupHostStatusCounts)
|
||||
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
|
||||
if !isHostNameInGroupBy {
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -272,7 +276,7 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getPodsTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getPodsTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -350,7 +354,7 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getNodesTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getNodesTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -433,7 +437,7 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getNamespacesTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getNamespacesTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -510,7 +514,7 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getClustersTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getClustersTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -600,7 +604,7 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getVolumesTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getVolumesTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -678,7 +682,7 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getDeploymentsTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getDeploymentsTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -761,7 +765,7 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getStatefulSetsTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getStatefulSetsTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -846,7 +850,7 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getJobsTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getJobsTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -931,7 +935,7 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getDaemonSetsTableMetadata(ctx, req)
|
||||
metadataMap, err := m.getDaemonSetsTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -115,12 +115,12 @@ func (m *module) getTopNamespaceGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getNamespacesTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableNamespaces) (map[string]map[string]string, error) {
|
||||
func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range namespaceAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -149,14 +149,14 @@ func (m *module) getTopNodeGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getNodesTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
|
||||
func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range nodeAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
|
||||
|
||||
@@ -170,14 +170,14 @@ func (m *module) getTopPodGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getPodsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
|
||||
func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range podAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupPodPhaseCounts computes per-group pod counts bucketed by each
|
||||
|
||||
@@ -140,12 +140,12 @@ func (m *module) getTopStatefulSetGroups(
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getStatefulSetsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableStatefulSets) (map[string]map[string]string, error) {
|
||||
func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range statefulSetAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user