mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-14 18:40:26 +01:00
Compare commits
81 Commits
anomaly-v2
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e390eef74b | ||
|
|
a6490661d1 | ||
|
|
e8883c4f65 | ||
|
|
aed3d096f0 | ||
|
|
5ffe4ec1f8 | ||
|
|
3e095b710b | ||
|
|
c11061b0ae | ||
|
|
c413d17594 | ||
|
|
9f8b1be83b | ||
|
|
f0d54cedd4 | ||
|
|
be56b929b4 | ||
|
|
20019d835c | ||
|
|
c49396944b | ||
|
|
39d6880cf7 | ||
|
|
3f20c04c97 | ||
|
|
70e556bd38 | ||
|
|
0a59a8eb04 | ||
|
|
6fee60833a | ||
|
|
b81bb42be6 | ||
|
|
d8717ee466 | ||
|
|
d0d81b778f | ||
|
|
b6a0431049 | ||
|
|
54560b6f63 | ||
|
|
41a68cd17e | ||
|
|
af96aef5e1 | ||
|
|
171f95ac25 | ||
|
|
93387b38d2 | ||
|
|
d4384eba1b | ||
|
|
6151467b3e | ||
|
|
daa999d1d6 | ||
|
|
b400846193 | ||
|
|
f5220d078f | ||
|
|
ab750ffffd | ||
|
|
4a2b508636 | ||
|
|
33795999cf | ||
|
|
9aac37eb94 | ||
|
|
12642e27da | ||
|
|
82f8bf88fc | ||
|
|
3befc9e8c6 | ||
|
|
ee5b85f7a4 | ||
|
|
a05164f225 | ||
|
|
35e4013826 | ||
|
|
5884c6aa90 | ||
|
|
533a430714 | ||
|
|
ba6af34714 | ||
|
|
851c7b0ad7 | ||
|
|
ef5a67495c | ||
|
|
9f540ca84b | ||
|
|
40a6b22aed | ||
|
|
6f16416f27 | ||
|
|
f8aa1c1c34 | ||
|
|
65835394c0 | ||
|
|
f132b7e53a | ||
|
|
d4ae156dc4 | ||
|
|
d6bdf9c2b2 | ||
|
|
7ea654f1aa | ||
|
|
3fd7d013a1 | ||
|
|
fb921dd381 | ||
|
|
58020d9e00 | ||
|
|
7a5933e822 | ||
|
|
2533683de6 | ||
|
|
2670d53170 | ||
|
|
8943a9454b | ||
|
|
9a7ed5b711 | ||
|
|
2d75e3d32d | ||
|
|
1d6eabf927 | ||
|
|
082d7b1b77 | ||
|
|
5019dee2d7 | ||
|
|
216de973fb | ||
|
|
18c0eec5e2 | ||
|
|
2ccdeb3631 | ||
|
|
ad12e50bbc | ||
|
|
e247bf3864 | ||
|
|
f4651ea134 | ||
|
|
d449a2dbf2 | ||
|
|
d4b9f91062 | ||
|
|
530710b7bc | ||
|
|
4fb5eec08d | ||
|
|
f889d36f0f | ||
|
|
db12d44523 | ||
|
|
86fc0e81ba |
@@ -2682,7 +2682,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3655,7 +3654,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3692,7 +3690,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
|
||||
@@ -3395,7 +3395,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -3923,7 +3922,6 @@ export interface DashboardtypesComparisonThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -4212,7 +4210,6 @@ export interface DashboardtypesTableThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type migrateCommon struct {
|
||||
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
|
||||
// transform is stateless and shared with the v1→v2 dashboard conversion.
|
||||
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
|
||||
}
|
||||
|
||||
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {
|
||||
|
||||
353
pkg/transition/migrate_shape_safe.go
Normal file
353
pkg/transition/migrate_shape_safe.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// nolint
|
||||
package transition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Shape-safe (idempotent) migration
|
||||
// ══════════════════════════════════════════════
|
||||
//
|
||||
// A copy of the Migrate → updateWidget → updateQueryData chain with the
|
||||
// "uniformly v4 input" assumption removed, so it is safe on a dashboard whose
|
||||
// `version` tag lies (a "v5"-labelled dashboard with un-upgraded, possibly mixed,
|
||||
// bodies — the v1→v2 converter's case). Versus the original: no version gate, and
|
||||
// each step acts only on the pre-v5 shape (leaving a v5 field alone), so it is
|
||||
// idempotent. The original Migrate is left unchanged (battle-tested, no test net).
|
||||
// The *ShapeSafe methods below each note the original they copy; the reused steps
|
||||
// (createFilterExpression, fixGroupBy, buildAggregationExpression, orderByExpr) are
|
||||
// already v5-safe.
|
||||
|
||||
// MigrateQueryDataShapeSafe is the per-query entry point (the core of
|
||||
// updateQueryDataShapeSafe) for callers that process queries one at a time (the
|
||||
// v1→v2 converter). widgetType is the v1 panelTypes (metric reduceTo on tables);
|
||||
// "" is safe.
|
||||
func (m *dashboardMigrateV5) MigrateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
return m.updateQueryDataShapeSafe(ctx, queryData, widgetType)
|
||||
}
|
||||
|
||||
// updateQueryDataShapeSafe copies updateQueryData, with each destructive step
|
||||
// guarded to act only on the pre-v5 shape (see the file header).
|
||||
func (mc *migrateCommon) updateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
updated := false
|
||||
|
||||
aggregateOp, _ := queryData["aggregateOperator"].(string)
|
||||
hasAggregation := aggregateOp != "" && aggregateOp != "noop"
|
||||
|
||||
if mc.createAggregationsShapeSafe(ctx, queryData, widgetType) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// createFilterExpression only touches v4 `filters`; skip if a v5 `filter` exists.
|
||||
if _, hasFilter := queryData["filter"]; !hasFilter {
|
||||
if mc.createFilterExpression(ctx, queryData) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if mc.fixGroupBy(queryData) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if mc.createHavingExpressionShapeSafe(queryData) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if hasAggregation {
|
||||
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
|
||||
newOrderBy := make([]any, 0)
|
||||
for _, order := range orderBy {
|
||||
if orderMap, ok := order.(map[string]any); ok {
|
||||
columnName, _ := orderMap["columnName"].(string)
|
||||
// skip timestamp, id (logs, traces), samples(metrics) ordering for aggregation queries
|
||||
if columnName != "timestamp" && columnName != "samples" && columnName != "id" {
|
||||
if columnName == "#SIGNOZ_VALUE" {
|
||||
if expr, has := mc.orderByExpr(queryData); has {
|
||||
orderMap["columnName"] = expr
|
||||
}
|
||||
} else {
|
||||
// if the order by key is not part of the group by keys, remove it
|
||||
present := false
|
||||
|
||||
groupBy, ok := queryData["groupBy"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for idx := range groupBy {
|
||||
item, ok := groupBy[idx].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := item["key"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key == columnName {
|
||||
present = true
|
||||
}
|
||||
}
|
||||
|
||||
if !present {
|
||||
mc.logger.WarnContext(ctx, "found a order by without group by, skipping", slog.String("order_col_name", columnName))
|
||||
continue
|
||||
}
|
||||
}
|
||||
newOrderBy = append(newOrderBy, orderMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
queryData["orderBy"] = newOrderBy
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
|
||||
newOrderBy := make([]any, 0)
|
||||
for _, order := range orderBy {
|
||||
if orderMap, ok := order.(map[string]any); ok {
|
||||
columnName, _ := orderMap["columnName"].(string)
|
||||
// skip id and timestamp for (traces)
|
||||
if (columnName == "id" || columnName == "timestamp") && dataSource == "traces" {
|
||||
mc.logger.InfoContext(ctx, "skipping `id` order by for traces")
|
||||
continue
|
||||
}
|
||||
|
||||
// skip id for (logs)
|
||||
if (columnName == "id" || columnName == "timestamp") && dataSource == "logs" {
|
||||
mc.logger.InfoContext(ctx, "skipping `id`/`timestamp` order by for logs")
|
||||
continue
|
||||
}
|
||||
|
||||
newOrderBy = append(newOrderBy, orderMap)
|
||||
}
|
||||
}
|
||||
queryData["orderBy"] = newOrderBy
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only the `&& functionsArePreV5(functions)` guard differs from updateQueryData.
|
||||
if functions, ok := queryData["functions"].([]any); ok && functionsArePreV5(functions) {
|
||||
v5Functions := make([]any, len(functions))
|
||||
for i, fn := range functions {
|
||||
if fnMap, ok := fn.(map[string]any); ok {
|
||||
v5Function := map[string]any{
|
||||
"name": fnMap["name"],
|
||||
}
|
||||
|
||||
// Convert args from v4 format to v5 FunctionArg format
|
||||
if args, ok := fnMap["args"].([]any); ok {
|
||||
v5Args := make([]any, len(args))
|
||||
for j, arg := range args {
|
||||
// In v4, args were just values. In v5, they are FunctionArg objects
|
||||
v5Args[j] = map[string]any{
|
||||
"name": "", // v4 didn't have named args
|
||||
"value": arg,
|
||||
}
|
||||
}
|
||||
v5Function["args"] = v5Args
|
||||
}
|
||||
|
||||
// Handle namedArgs if present (some functions might have used this)
|
||||
if namedArgs, ok := fnMap["namedArgs"].(map[string]any); ok {
|
||||
// Convert named args to the new format
|
||||
existingArgs, _ := v5Function["args"].([]any)
|
||||
if existingArgs == nil {
|
||||
existingArgs = []any{}
|
||||
}
|
||||
|
||||
for name, value := range namedArgs {
|
||||
existingArgs = append(existingArgs, map[string]any{
|
||||
"name": name,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
v5Function["args"] = existingArgs
|
||||
}
|
||||
|
||||
v5Functions[i] = v5Function
|
||||
}
|
||||
}
|
||||
queryData["functions"] = v5Functions
|
||||
updated = true
|
||||
}
|
||||
|
||||
delete(queryData, "aggregateOperator")
|
||||
delete(queryData, "aggregateAttribute")
|
||||
delete(queryData, "temporality")
|
||||
delete(queryData, "timeAggregation")
|
||||
delete(queryData, "spaceAggregation")
|
||||
delete(queryData, "reduceTo")
|
||||
delete(queryData, "filters")
|
||||
delete(queryData, "ShiftBy")
|
||||
delete(queryData, "IsAnomaly")
|
||||
delete(queryData, "QueriesUsedInFormula")
|
||||
delete(queryData, "seriesAggregation")
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// createHavingExpressionShapeSafe copies createHavingExpression but leaves an
|
||||
// already-v5 having:{expression} alone instead of wiping it.
|
||||
func (mc *migrateCommon) createHavingExpressionShapeSafe(queryData map[string]any) bool {
|
||||
if _, ok := queryData["having"].(map[string]any); ok {
|
||||
return false // already v5-shaped
|
||||
}
|
||||
having, ok := queryData["having"].([]any)
|
||||
if !ok || len(having) == 0 {
|
||||
queryData["having"] = map[string]any{"expression": ""}
|
||||
return true
|
||||
}
|
||||
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
for idx := range having {
|
||||
if havingItem, ok := having[idx].(map[string]any); ok {
|
||||
havingCol, has := mc.orderByExpr(queryData)
|
||||
if has {
|
||||
havingItem["columnName"] = havingCol
|
||||
havingItem["key"] = map[string]any{"key": havingCol}
|
||||
}
|
||||
having[idx] = havingItem
|
||||
}
|
||||
}
|
||||
queryData["having"] = map[string]any{"expression": mc.buildExpression(context.Background(), having, "AND", dataSource)}
|
||||
return true
|
||||
}
|
||||
|
||||
// createAggregationsShapeSafe copies createAggregations but skips a query that
|
||||
// already has a v5 aggregations[], and picks the metric time/space aggregation
|
||||
// from the body's shape (has timeAggregation/spaceAggregation?) rather than the
|
||||
// version tag.
|
||||
func (mc *migrateCommon) createAggregationsShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
|
||||
return false // already v5-shaped
|
||||
}
|
||||
|
||||
aggregateOp, hasOp := queryData["aggregateOperator"].(string)
|
||||
aggregateAttr, hasAttr := queryData["aggregateAttribute"].(map[string]any)
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
if aggregateOp == "noop" && dataSource != "metrics" {
|
||||
return false
|
||||
}
|
||||
if !hasOp || !hasAttr {
|
||||
return false
|
||||
}
|
||||
|
||||
var aggregation map[string]any
|
||||
|
||||
switch dataSource {
|
||||
case "metrics":
|
||||
_, hasTime := queryData["timeAggregation"]
|
||||
_, hasSpace := queryData["spaceAggregation"]
|
||||
if hasTime || hasSpace { // acts as a check for v4 shape: the body carries its own time/space aggregation.
|
||||
if _, ok := queryData["spaceAggregation"]; !ok {
|
||||
queryData["spaceAggregation"] = aggregateOp
|
||||
}
|
||||
aggregation = map[string]any{
|
||||
"metricName": aggregateAttr["key"],
|
||||
"temporality": queryData["temporality"],
|
||||
"timeAggregation": queryData["timeAggregation"],
|
||||
"spaceAggregation": queryData["spaceAggregation"],
|
||||
}
|
||||
if reduceTo, ok := queryData["reduceTo"].(string); ok {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
}
|
||||
} else {
|
||||
// v3 shape: derive time/space from the compound operator.
|
||||
var timeAgg, spaceAgg, reduceTo string
|
||||
switch aggregateOp {
|
||||
case "sum_rate", "rate_sum":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
|
||||
case "avg_rate", "rate_avg":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "avg", "avg"
|
||||
case "min_rate", "rate_min":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "min", "min"
|
||||
case "max_rate", "rate_max":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "max", "max"
|
||||
case "hist_quantile_50":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p50", "avg"
|
||||
case "hist_quantile_75":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p75", "avg"
|
||||
case "hist_quantile_90":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p90", "avg"
|
||||
case "hist_quantile_95":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p95", "avg"
|
||||
case "hist_quantile_99":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p99", "avg"
|
||||
case "rate":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
|
||||
case "p99", "p90", "p75", "p50", "p25", "p20", "p10", "p05":
|
||||
mc.logger.InfoContext(ctx, "found invalid config")
|
||||
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
|
||||
case "min":
|
||||
timeAgg, spaceAgg, reduceTo = "min", "min", "min"
|
||||
case "max":
|
||||
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
|
||||
case "avg":
|
||||
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
|
||||
case "sum":
|
||||
timeAgg, spaceAgg, reduceTo = "sum", "sum", "sum"
|
||||
case "count":
|
||||
timeAgg, spaceAgg, reduceTo = "count", "sum", "sum"
|
||||
case "count_distinct":
|
||||
timeAgg, spaceAgg, reduceTo = "count_distinct", "sum", "sum"
|
||||
case "noop":
|
||||
mc.logger.WarnContext(ctx, "noop found in the aggregation data")
|
||||
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
|
||||
}
|
||||
aggregation = map[string]any{
|
||||
"metricName": aggregateAttr["key"],
|
||||
"temporality": queryData["temporality"],
|
||||
"timeAggregation": timeAgg,
|
||||
"spaceAggregation": spaceAgg,
|
||||
}
|
||||
if widgetType == "table" {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
} else if reduceTo, ok := queryData["reduceTo"].(string); ok {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
}
|
||||
}
|
||||
case "logs", "traces":
|
||||
aggregation = map[string]any{"expression": mc.buildAggregationExpression(aggregateOp, aggregateAttr)}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
queryData["aggregations"] = []any{aggregation}
|
||||
return true
|
||||
}
|
||||
|
||||
// orderByIsPreV5 reports whether an orderBy slice is still in the v4 shape (an
|
||||
// entry carries "columnName"); a v5 orderBy uses {key:{name}, direction}.
|
||||
func orderByIsPreV5(orderBy []any) bool {
|
||||
for _, o := range orderBy {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if _, has := m["columnName"]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// functionsArePreV5 reports whether a functions slice is still in the v4 shape
|
||||
// (args are raw values); a v5 function's args are {name,value} objects.
|
||||
func functionsArePreV5(functions []any) bool {
|
||||
for _, f := range functions {
|
||||
if m, ok := f.(map[string]any); ok {
|
||||
args, ok := m["args"].([]any)
|
||||
if !ok || len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
_, argIsObject := args[0].(map[string]any)
|
||||
return !argIsObject
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -22,6 +21,7 @@ var (
|
||||
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
|
||||
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
|
||||
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
|
||||
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
|
||||
)
|
||||
|
||||
type StorableDashboard struct {
|
||||
@@ -413,27 +413,26 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
|
||||
widgetData := data.Widgets[widgetIndex]
|
||||
switch widgetData.Query.QueryType {
|
||||
case "builder":
|
||||
migrate := transition.NewMigrateCommon(logger)
|
||||
for _, query := range widgetData.Query.Builder.QueryData {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryFormulas {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
}
|
||||
case "clickhouse_sql":
|
||||
for _, query := range widgetData.Query.ClickhouseSQL {
|
||||
|
||||
@@ -106,7 +106,7 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
}
|
||||
panelKind := panel.Spec.Plugin.Kind
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
@@ -269,8 +269,8 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if grid.Display != nil {
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
|
||||
}
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
|
||||
@@ -1059,6 +1059,34 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestThresholdZeroValueAcceptedMissingRejected documents the *float64 Value:
|
||||
// a threshold at 0 (or 0.0) is valid, because the pointer lets validate:"required"
|
||||
// tell a present zero (non-nil) from an absent value (nil) — while a genuinely
|
||||
// missing value is still rejected.
|
||||
func TestThresholdZeroValueAcceptedMissingRejected(t *testing.T) {
|
||||
numberPanel := func(thresholdSpec string) string {
|
||||
return `{
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"plugin": {"kind": "signoz/NumberPanel", "spec": {"thresholds": [` + thresholdSpec + `]}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}}},
|
||||
"layouts": []
|
||||
}`
|
||||
}
|
||||
|
||||
_, errZero := unmarshalDashboard([]byte(numberPanel(`{"value": 0, "operator": "above", "format": "text", "color": "Red"}`)))
|
||||
require.NoError(t, errZero, `a threshold "value": 0 is valid`)
|
||||
|
||||
// "value": 0.0 is the same float64 zero as "value": 0 — JSON has one number
|
||||
// type — and is accepted identically.
|
||||
_, errZeroFloat := unmarshalDashboard([]byte(numberPanel(`{"value": 0.0, "operator": "above", "format": "text", "color": "Red"}`)))
|
||||
require.NoError(t, errZeroFloat, `"value": 0.0 is the same valid zero`)
|
||||
|
||||
_, errMissing := unmarshalDashboard([]byte(numberPanel(`{"operator": "above", "format": "text", "color": "Red"}`)))
|
||||
require.Error(t, errMissing, "a genuinely missing value is still rejected")
|
||||
require.Contains(t, errMissing.Error(), "Value")
|
||||
}
|
||||
|
||||
func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
@@ -1641,55 +1669,61 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
}
|
||||
|
||||
// Every display name — dashboard, panel, variable — and the grid layout title is
|
||||
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
|
||||
// the message reads "<json path>: <field> name must be at most ...", pairing the
|
||||
// locatable path (like the other spec errors) with a human field label.
|
||||
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
|
||||
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
|
||||
// is one over the relevant limit in each case, and the message reads "<json path>:
|
||||
// <field> name must be at most ...", pairing the locatable path (like the other spec
|
||||
// errors) with a human field label.
|
||||
func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
|
||||
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
dashboardJSON string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
scenario string
|
||||
limit int
|
||||
dashboardJSONFmt string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
}{
|
||||
{
|
||||
scenario: "dashboard display name",
|
||||
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
scenario: "dashboard display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"display": {"name": "%s"}, "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "panel display name",
|
||||
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
scenario: "panel display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "list variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "list variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "text variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "text variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "layout title",
|
||||
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
scenario: "layout title",
|
||||
limit: MaxLayoutTitleLen,
|
||||
dashboardJSONFmt: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
|
||||
tooLong := strings.Repeat("x", testCase.limit+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
|
||||
_, err := unmarshalDashboard([]byte(fmt.Sprintf(testCase.dashboardJSONFmt, tooLong)))
|
||||
require.Error(t, err)
|
||||
// Message is "<path>: <label> name must be at most N characters, got M".
|
||||
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg
|
||||
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
|
||||
// and variable display names, plus the grid layout title.
|
||||
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
|
||||
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
|
||||
const MaxDisplayNameLen = 128
|
||||
|
||||
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
|
||||
// because v1 section (row) titles ran longer.
|
||||
const MaxLayoutTitleLen = 256
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
@@ -252,14 +252,20 @@ type Legend struct {
|
||||
}
|
||||
|
||||
type ThresholdWithLabel struct {
|
||||
Value float64 `json:"value" validate:"required" required:"true"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
// Value is a pointer so a threshold at 0 is valid: validate:"required" treats
|
||||
// the float64 zero as "missing", but a non-nil *float64 to 0 passes (and nil
|
||||
// still fails, so a genuinely absent value is still rejected). nullable:"false"
|
||||
// keeps it a plain required number in the schema — it is never null in valid
|
||||
// data (validation rejects nil), so the pointer must not leak as `number|null`.
|
||||
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
Value float64 `json:"value" validate:"required" required:"true"`
|
||||
// Value is a pointer so a threshold at 0 is valid (see ThresholdWithLabel.Value).
|
||||
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
|
||||
Operator ComparisonOperator `json:"operator"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
|
||||
95
pkg/types/dashboardtypes/perses_v1_to_v2.go
Normal file
95
pkg/types/dashboardtypes/perses_v1_to_v2.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
|
||||
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
|
||||
// DashboardSpec.
|
||||
//
|
||||
// Assumes the v1 widget query data has already been migrated to v5 shape
|
||||
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
|
||||
// invalid v2 envelopes — run the v4→v5 migration first.
|
||||
//
|
||||
// The conversion is split across sibling files by concern:
|
||||
// - perses_v1_to_v2_tags.go tags
|
||||
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
|
||||
// - perses_v1_to_v2_queries.go widget queries
|
||||
// - perses_v1_to_v2_layouts.go grid layouts and sections
|
||||
// - perses_v1_to_v2_variables.go variables
|
||||
// - perses_v1_to_v2_decoder.go v1Decoder: typed field reads + malformed-field detection
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Entry point
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (storable StorableDashboard) IsV2() bool {
|
||||
metadata, _ := storable.Data["metadata"].(map[string]any)
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
version, _ := metadata["schemaVersion"].(string)
|
||||
return version == SchemaVersion
|
||||
}
|
||||
|
||||
func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err error) {
|
||||
// Legacy v1 data can be arbitrarily malformed. The accessors degrade
|
||||
// gracefully, but recover from any unforeseen panic so one bad dashboard
|
||||
// surfaces as an error (to be logged and skipped) rather than crashing the run.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
result, err = nil, errors.Newf(errors.TypeInternal, ErrCodeDashboardMigrationFailed, "panic converting dashboard %s: %v", storable.ID, r)
|
||||
}
|
||||
}()
|
||||
|
||||
if storable.IsV2() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardMigrationFailed, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
|
||||
}
|
||||
|
||||
d := &v1Decoder{}
|
||||
title := d.readString(storable.Data, "title")
|
||||
description := d.readString(storable.Data, "description")
|
||||
image := d.readString(storable.Data, "image")
|
||||
|
||||
sanitizeWidgetIDs(storable.Data)
|
||||
panels := d.convertV1Panels(retainPlacedWidgets(storable.Data))
|
||||
spec := DashboardSpec{
|
||||
Display: Display{Name: clipName(title, MaxDisplayNameLen), Description: description},
|
||||
Variables: d.convertV1Variables(storable.Data["variables"]),
|
||||
Panels: panels,
|
||||
Layouts: d.convertV1Layouts(storable.Data, panels),
|
||||
}
|
||||
|
||||
// marshal and unmarshal cycle to confirm full validation
|
||||
raw, marshalErr := json.Marshal(spec)
|
||||
if marshalErr != nil {
|
||||
return nil, errors.WrapInternalf(marshalErr, errors.CodeInternal, "marshal converted dashboard %s", storable.ID)
|
||||
}
|
||||
if err := json.Unmarshal(raw, new(DashboardSpec)); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardMigrationFailed, "converted dashboard %s is invalid", storable.ID)
|
||||
}
|
||||
tags := d.convertV1TagsForOrg(storable.OrgID, storable.Data["tags"])
|
||||
|
||||
if err := d.errIfHasMalformedFields(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Source: storable.Source,
|
||||
DashboardV2MetadataBase: DashboardV2MetadataBase{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: image,
|
||||
},
|
||||
Name: generateDashboardName(title),
|
||||
Tags: tags,
|
||||
Spec: spec,
|
||||
}, nil
|
||||
}
|
||||
214
pkg/types/dashboardtypes/perses_v1_to_v2_decoder.go
Normal file
214
pkg/types/dashboardtypes/perses_v1_to_v2_decoder.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// v1 decoder
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1Decoder reads fields out of the untyped v1 dashboard blob. Every read*
|
||||
// method follows the same contract: a field that is absent or null yields the
|
||||
// zero value; a field present with the wrong type yields zero AND records a
|
||||
// malformed-field error. Conversion proceeds (so one bad field doesn't abort
|
||||
// the rest) and ConvertV1ToV2 returns d.malformedFieldsErr() at the end so the
|
||||
// dashboard is logged and skipped.
|
||||
//
|
||||
// Polymorphic v1 fields (spanGaps bool|number, selectedValue string|array, …)
|
||||
// are read with a type switch on the already-extracted value, never through
|
||||
// these accessors, so they stay lenient by construction.
|
||||
type v1Decoder struct {
|
||||
bad []string
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// note records a decoding problem (malformed field, unknown value, swallowed
|
||||
// sub-parse error), deduping identical messages. ConvertV1ToV2 surfaces these
|
||||
// via errIfHasMalformedFields.
|
||||
func (d *v1Decoder) note(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if _, dup := d.seen[msg]; dup {
|
||||
return
|
||||
}
|
||||
if d.seen == nil {
|
||||
d.seen = make(map[string]struct{})
|
||||
}
|
||||
d.seen[msg] = struct{}{}
|
||||
d.bad = append(d.bad, msg)
|
||||
}
|
||||
|
||||
// noteMalformedField records a v1 field present with the wrong Go type.
|
||||
func (d *v1Decoder) noteMalformedField(field string, raw any) {
|
||||
d.note("%q has unexpected type %T", field, raw)
|
||||
}
|
||||
|
||||
// detailErr renders an error for a diagnostic note, unfolding the structured
|
||||
// detail our JSON binding attaches via WithAdditional. A plain %v on these
|
||||
// errors prints only the innermost message ("request body contains invalid
|
||||
// field value") and drops the field/type context that says which field was
|
||||
// wrong — the part that actually tells you what to fix.
|
||||
func detailErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
j := errors.AsJSON(err)
|
||||
if len(j.Errors) == 0 {
|
||||
return err.Error()
|
||||
}
|
||||
details := make([]string, 0, len(j.Errors))
|
||||
for _, e := range j.Errors {
|
||||
details = append(details, e.Message)
|
||||
}
|
||||
return j.Message + ": " + strings.Join(details, "; ")
|
||||
}
|
||||
|
||||
func (d *v1Decoder) errIfHasMalformedFields() error {
|
||||
if len(d.bad) == 0 {
|
||||
return nil
|
||||
}
|
||||
// One field per line: these lists run long (a bad widget query is reported
|
||||
// once per widget), and a single "; "-joined line is an unscannable wall.
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "malformed v1 dashboard fields:\n %s", strings.Join(d.bad, "\n "))
|
||||
}
|
||||
|
||||
func readField[T any](d *v1Decoder, m map[string]any, key string) T {
|
||||
var zero T
|
||||
v, present := m[key]
|
||||
if !present || v == nil {
|
||||
return zero
|
||||
}
|
||||
t, ok := v.(T)
|
||||
if !ok {
|
||||
d.noteMalformedField(key, v)
|
||||
return zero
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (d *v1Decoder) readString(m map[string]any, key string) string {
|
||||
return readField[string](d, m, key)
|
||||
}
|
||||
func (d *v1Decoder) readFloat(m map[string]any, key string) float64 {
|
||||
v, present := m[key]
|
||||
if !present || v == nil {
|
||||
return 0
|
||||
}
|
||||
f, ok := coerceFloat(v)
|
||||
if !ok {
|
||||
d.noteMalformedField(key, v)
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// coerceFloat accepts a JSON number or a numeric string (v1 sometimes stores
|
||||
// numbers like softMin as quoted strings). A blank string is "unset", not a
|
||||
// number, so it fails to coerce.
|
||||
func coerceFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
func (d *v1Decoder) readBool(m map[string]any, key string) bool { return readField[bool](d, m, key) }
|
||||
func (d *v1Decoder) readArray(m map[string]any, key string) []any { return readField[[]any](d, m, key) }
|
||||
func (d *v1Decoder) readObject(m map[string]any, key string) map[string]any {
|
||||
return readField[map[string]any](d, m, key)
|
||||
}
|
||||
|
||||
// readInt narrows a numeric field to int (JSON numbers decode as float64).
|
||||
func (d *v1Decoder) readInt(m map[string]any, key string) int { return int(d.readFloat(m, key)) }
|
||||
|
||||
func (d *v1Decoder) readFloatPtr(m map[string]any, key string) *float64 {
|
||||
v, present := m[key]
|
||||
if !present || v == nil {
|
||||
return nil
|
||||
}
|
||||
// A blank string means "unset" (v1's empty softMin/softMax), not malformed.
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
f, ok := coerceFloat(v)
|
||||
if !ok {
|
||||
d.noteMalformedField(key, v)
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
|
||||
// clipName truncates s to at most limit runes so a v1 name over a v2 length bound
|
||||
// (MaxDisplayNameLen / MaxLayoutTitleLen) is shortened rather than failing migration.
|
||||
func clipName(s string, limit int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= limit {
|
||||
return s
|
||||
}
|
||||
return string(r[:limit])
|
||||
}
|
||||
|
||||
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
|
||||
// An empty list is a stand-in for an empty map here; tolerate it silently
|
||||
// rather than flagging the wrong-type as malformed.
|
||||
if s, ok := m[key].([]any); ok && len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw := d.readObject(m, key)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
d.noteMalformedField(key+"."+k, v)
|
||||
continue
|
||||
}
|
||||
out[k] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) readObjects(m map[string]any, key string) []map[string]any {
|
||||
raw := d.readArray(m, key)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, item := range raw {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
d.noteMalformedField(fmt.Sprintf("%s[%d]", key, i), item)
|
||||
continue
|
||||
}
|
||||
out = append(out, obj)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeMapInto converts an untyped map[string]any into a typed T by
|
||||
// round-tripping through JSON, letting encoding/json (struct tags, custom
|
||||
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
|
||||
func decodeMapInto[T any](src map[string]any) (T, error) {
|
||||
var dst T
|
||||
bytes, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return dst, err
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &dst); err != nil {
|
||||
return dst, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
349
pkg/types/dashboardtypes/perses_v1_to_v2_layouts.go
Normal file
349
pkg/types/dashboardtypes/perses_v1_to_v2_layouts.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
)
|
||||
|
||||
// panelRefPrefix is the JSON-ref prefix a grid item uses to point at a panel:
|
||||
// "#/spec/panels/<id>".
|
||||
const panelRefPrefix = "#/spec/panels/"
|
||||
|
||||
// sanitizePanelID rewrites a widget id to something valid in a panel $ref. Perses
|
||||
// accepts only [a-zA-Z0-9_-] per ref segment (common.jsonRefMatching), so every
|
||||
// other rune (em dash, spaces, dots, unicode, …) is mapped to a hyphen.
|
||||
func sanitizePanelID(id string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, id)
|
||||
}
|
||||
|
||||
// sanitizeWidgetIDs rewrites every widget id in the raw v1 data — widgets[].id,
|
||||
// layout[].i, panelMap keys and their widgets[].i — through sanitizePanelID, so a
|
||||
// panel's map key and the layout $ref pointing at it stay identical (an illegal char
|
||||
// in one but not the other would dangle the ref). Runs before panels/layouts build.
|
||||
func sanitizeWidgetIDs(data StorableDashboardData) {
|
||||
sanitizeField := func(raw any, field string) {
|
||||
items, _ := raw.([]any)
|
||||
for _, it := range items {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
if s, ok := m[field].(string); ok {
|
||||
m[field] = sanitizePanelID(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sanitizeField(data["widgets"], "id")
|
||||
sanitizeField(data["layout"], "i")
|
||||
if panelMap, ok := data["panelMap"].(map[string]any); ok {
|
||||
for key, v := range panelMap {
|
||||
if s := sanitizePanelID(key); s != key {
|
||||
panelMap[s] = v
|
||||
delete(panelMap, key)
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
sanitizeField(m["widgets"], "i")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Layouts (data.layout + data.panelMap)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
|
||||
// Membership is positional (as the frontend renders): each row widget owns the
|
||||
// panels below it until the next row; panels above the first row form an unnamed
|
||||
// grid with no section header. Collapsed rows are the exception — their children
|
||||
// live in panelMap[rowID].widgets, not `layout`.
|
||||
func (d *v1Decoder) convertV1Layouts(data StorableDashboardData, panels map[string]*Panel) []Layout {
|
||||
layout := d.readObjects(data, "layout")
|
||||
if len(layout) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// react-grid-layout can persist the same widget id more than once. Keep the first
|
||||
// occurrence in stored order (mirroring getUpdatedLayout — the losing entry's
|
||||
// geometry is discarded, not merged) and drop the rest. Dedupe before sortByPosition
|
||||
// so "first" means first-in-stored-order, not topmost. Entries with no id are left
|
||||
// for the main loop to drop.
|
||||
seenWidgetIds := make(map[string]bool, len(layout))
|
||||
dedupedLayouts := layout[:0]
|
||||
for _, item := range layout {
|
||||
if id := d.readString(item, "i"); id != "" {
|
||||
if seenWidgetIds[id] {
|
||||
continue
|
||||
}
|
||||
seenWidgetIds[id] = true
|
||||
}
|
||||
dedupedLayouts = append(dedupedLayouts, item)
|
||||
}
|
||||
layout = dedupedLayouts
|
||||
|
||||
rows := d.extractRowsAndCollapsedWidgets(data)
|
||||
|
||||
// ids placed directly in `layout`. A collapsed child also listed here is rendered from
|
||||
// layout (the open section), so it's dropped from its collapsed section below.
|
||||
placedInLayout := make(map[string]bool, len(layout))
|
||||
for _, item := range layout {
|
||||
if id := d.readString(item, "i"); id != "" {
|
||||
placedInLayout[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
d.sortByPosition(layout)
|
||||
|
||||
type section struct {
|
||||
row *rowInfo // nil for the unnamed grid of ungrouped panels
|
||||
items []map[string]any
|
||||
}
|
||||
topSectionWithoutHeader := §ion{}
|
||||
sectionsWithHeader := make([]*section, 0, len(rows))
|
||||
currentRowHeader := topSectionWithoutHeader
|
||||
for _, item := range layout {
|
||||
id := d.readString(item, "i")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if row, ok := rows[id]; ok {
|
||||
newRowHeader := §ion{row: row, items: d.extractValidLayoutItemsForCollapsedSection(row.collapsedWidgets, panels, placedInLayout)}
|
||||
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
|
||||
// A collapsed row owns only its stashed children; later panels → ungrouped.
|
||||
if row.collapsed {
|
||||
currentRowHeader = topSectionWithoutHeader
|
||||
} else {
|
||||
currentRowHeader = newRowHeader
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Keep a layout entry only if its widget became a panel; otherwise (skipped
|
||||
// widget, deleted id, or the "__dropping-elem__" drag placeholder) it would
|
||||
// reference a panel that does not exist. Rows are handled above.
|
||||
if _, ok := panels[id]; !ok {
|
||||
continue
|
||||
}
|
||||
currentRowHeader.items = append(currentRowHeader.items, item)
|
||||
}
|
||||
|
||||
out := make([]Layout, 0, len(sectionsWithHeader)+1)
|
||||
if len(topSectionWithoutHeader.items) > 0 {
|
||||
out = append(out, d.buildV2GridLayout(nil, topSectionWithoutHeader.items))
|
||||
}
|
||||
for _, sec := range sectionsWithHeader {
|
||||
out = append(out, d.buildV2GridLayout(sec.row, sec.items))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// retainPlacedWidgets drops widgets the v1 layout never places, returning the
|
||||
// filtered widgets. v1 doesn't render an unplaced widget, so converting it — and
|
||||
// noting any problems it has — is pure noise; filter before conversion so only
|
||||
// rendered widgets reach convertV1Panels. A non-array widgets value is returned
|
||||
// untouched for convertV1Panels to flag; non-map entries are kept so it still
|
||||
// flags them as malformed.
|
||||
func retainPlacedWidgets(data StorableDashboardData) any {
|
||||
widgets, ok := data["widgets"].([]any)
|
||||
if !ok {
|
||||
return data["widgets"]
|
||||
}
|
||||
placed := placedWidgetIDs(data)
|
||||
kept := make([]any, 0, len(widgets))
|
||||
for _, w := range widgets {
|
||||
wm, ok := w.(map[string]any)
|
||||
if !ok {
|
||||
kept = append(kept, w) // malformed entry — leave it for convertV1Panels to note
|
||||
continue
|
||||
}
|
||||
if id, _ := wm["id"].(string); placed[id] {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// placedWidgetIDs returns the set of widget ids the v1 layout actually renders:
|
||||
// every id in `layout`, plus the collapsed-row children stashed in panelMap.
|
||||
// Read leniently (no malformed notes) — convertV1Layouts re-reads these and
|
||||
// reports any genuine problems.
|
||||
func placedWidgetIDs(data StorableDashboardData) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
if layout, ok := data["layout"].([]any); ok {
|
||||
for _, e := range layout {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
if i, ok := m["i"].(string); ok && i != "" {
|
||||
ids[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if panelMap, ok := data["panelMap"].(map[string]any); ok {
|
||||
for _, v := range panelMap {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
widgets, ok := m["widgets"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, w := range widgets {
|
||||
if wm, ok := w.(map[string]any); ok {
|
||||
if i, ok := wm["i"].(string); ok && i != "" {
|
||||
ids[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// extractValidLayoutItemsForCollapsedSection keeps only the collapsed-row children
|
||||
// backed by a real panel and not already placed in `layout`, dropping ghosts and any
|
||||
// child the open layout renders instead. These come from panelMap and skip the main
|
||||
// loop's per-item panel check, so a grid never references a missing or twice-placed panel.
|
||||
func (d *v1Decoder) extractValidLayoutItemsForCollapsedSection(items []map[string]any, panels map[string]*Panel, placedInLayout map[string]bool) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
id := d.readString(item, "i")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := panels[id]; !ok {
|
||||
continue
|
||||
}
|
||||
if placedInLayout[id] || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type rowInfo struct {
|
||||
title string
|
||||
collapsed bool
|
||||
collapsedWidgets []map[string]any
|
||||
}
|
||||
|
||||
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed
|
||||
// rows also carry their children stashed under panelMap[id].widgets.
|
||||
func (d *v1Decoder) extractRowsAndCollapsedWidgets(data StorableDashboardData) map[string]*rowInfo {
|
||||
panelMap := d.readObject(data, "panelMap")
|
||||
rows := make(map[string]*rowInfo)
|
||||
for _, w := range d.readObjects(data, "widgets") {
|
||||
// Read id directly (not via readString): a non-string id is skipped silently by
|
||||
// convertV1Panels, so flagging it malformed here would fail the migration for a
|
||||
// widget that's already been dropped.
|
||||
id, _ := w["id"].(string)
|
||||
if d.readString(w, "panelTypes") != "row" || id == "" {
|
||||
continue
|
||||
}
|
||||
row := &rowInfo{title: d.readString(w, "title")}
|
||||
// Some templates store panelMap[id] as a bare []widgetID instead of the
|
||||
// canonical {widgets, collapsed}. The frontend treats such a non-object
|
||||
// entry as "not collapsed" (see GridCardLayout), so read it leniently: a
|
||||
// non-map yields nil, which reads as not collapsed.
|
||||
pm, _ := panelMap[id].(map[string]any)
|
||||
if d.readBool(pm, "collapsed") {
|
||||
row.collapsed = true
|
||||
row.collapsedWidgets = d.readObjects(pm, "widgets")
|
||||
}
|
||||
rows[id] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no
|
||||
// display); otherwise the grid takes the row's title and collapse state. Items are
|
||||
// sorted by (y, x) then vertically compacted (see compactGridItemsVertically).
|
||||
func (d *v1Decoder) buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
|
||||
d.sortByPosition(items)
|
||||
|
||||
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
|
||||
if row != nil {
|
||||
spec.Display = &dashboard.GridLayoutDisplay{
|
||||
Title: clipName(row.title, MaxLayoutTitleLen),
|
||||
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
spec.Items = append(spec.Items, dashboard.GridItem{
|
||||
X: d.readInt(item, "x"),
|
||||
Y: d.readInt(item, "y"),
|
||||
Width: d.readInt(item, "w"),
|
||||
Height: d.readInt(item, "h"),
|
||||
Content: &common.JSONRef{Ref: panelRefPrefix + d.readString(item, "i")},
|
||||
})
|
||||
}
|
||||
compactGridItemsVertically(spec.Items)
|
||||
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
|
||||
}
|
||||
|
||||
// compactGridItemsVertically mirrors react-grid-layout's correctBounds+compact
|
||||
// (compactType "vertical", allowOverlap false): clamp each item into the grid (x,y>=0;
|
||||
// x+width<=cols by shifting left), then move sorted-first items up to fill space and
|
||||
// down past collisions. Fixes overlaps, gaps, and out-of-bounds coords so the migrated
|
||||
// grid matches the v1 UI and passes v2 validation.
|
||||
func compactGridItemsVertically(items []dashboard.GridItem) {
|
||||
collides := func(a, b dashboard.GridItem) bool {
|
||||
return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
|
||||
}
|
||||
firstCollision := func(l dashboard.GridItem, placed []dashboard.GridItem) (dashboard.GridItem, bool) {
|
||||
for _, p := range placed {
|
||||
if collides(l, p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return dashboard.GridItem{}, false
|
||||
}
|
||||
for i := range items {
|
||||
l := items[i]
|
||||
if l.X+l.Width > gridColumnCount { // overflows right → shift left to fit
|
||||
l.X = gridColumnCount - l.Width
|
||||
}
|
||||
if l.X < 0 {
|
||||
l.X = 0
|
||||
}
|
||||
if l.Y < 0 {
|
||||
l.Y = 0
|
||||
}
|
||||
for l.Y > 0 { // move up to fill space above
|
||||
up := l
|
||||
up.Y--
|
||||
if _, hit := firstCollision(up, items[:i]); hit {
|
||||
break
|
||||
}
|
||||
l.Y--
|
||||
}
|
||||
for { // then down past any collision with an already-placed item
|
||||
c, hit := firstCollision(l, items[:i])
|
||||
if !hit {
|
||||
break
|
||||
}
|
||||
l.Y = c.Y + c.Height
|
||||
}
|
||||
items[i] = l
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) sortByPosition(items []map[string]any) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if yi, yj := d.readInt(items[i], "y"), d.readInt(items[j], "y"); yi != yj {
|
||||
return yi < yj
|
||||
}
|
||||
return d.readInt(items[i], "x") < d.readInt(items[j], "x")
|
||||
})
|
||||
}
|
||||
482
pkg/types/dashboardtypes/perses_v1_to_v2_panels.go
Normal file
482
pkg/types/dashboardtypes/perses_v1_to_v2_panels.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Widgets → Panels
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
|
||||
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
|
||||
// and consumed by convertV1Layouts as section headers.
|
||||
func (d *v1Decoder) convertV1Panels(raw any) map[string]*Panel {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
widgetsRaw, ok := raw.([]any)
|
||||
if !ok {
|
||||
d.noteMalformedField("widgets", raw)
|
||||
return nil
|
||||
}
|
||||
panels := make(map[string]*Panel, len(widgetsRaw))
|
||||
for i, widgetRaw := range widgetsRaw {
|
||||
widget, ok := widgetRaw.(map[string]any)
|
||||
if !ok {
|
||||
d.noteMalformedField(fmt.Sprintf("widgets[%d]", i), widgetRaw)
|
||||
continue
|
||||
}
|
||||
// A non-string (or missing) id can't be referenced by any layout entry, and
|
||||
// v1 doesn't render such widgets either — skip silently, don't flag it as
|
||||
// malformed. Read directly (not via readString) to avoid a malformed note.
|
||||
id, ok := widget["id"].(string)
|
||||
if !ok || id == "" {
|
||||
continue
|
||||
}
|
||||
var panel *Panel
|
||||
panelType := d.readString(widget, "panelTypes")
|
||||
switch panelType {
|
||||
case "graph":
|
||||
panel = d.convertGraphWidget(widget)
|
||||
case "time_series", "TIME_SERIES":
|
||||
// Malformed panelTypes: the canonical v1 value is "graph". Some dashboards
|
||||
// stored the v2/enum-style name instead; accept it as a time-series graph.
|
||||
panel = d.convertGraphWidget(widget)
|
||||
case "bar":
|
||||
panel = d.convertBarWidget(widget)
|
||||
case "value":
|
||||
panel = d.convertValueWidget(widget)
|
||||
case "pie":
|
||||
panel = d.convertPieWidget(widget)
|
||||
case "table":
|
||||
panel = d.convertTableWidget(widget)
|
||||
case "histogram":
|
||||
panel = d.convertHistogramWidget(widget)
|
||||
case "list":
|
||||
panel = d.convertListWidget(widget)
|
||||
case "row":
|
||||
// "row" (section header) is handled by the layout pass;
|
||||
continue
|
||||
default:
|
||||
d.note("widgets[%d] has unknown panel type %q", i, panelType)
|
||||
}
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
if len(panel.Spec.Queries) == 0 {
|
||||
d.note("widgets[%d] %q produced no queries; skipping", i, id)
|
||||
continue
|
||||
}
|
||||
// A lone metric query with no aggregation or no metric name can't render (v1)
|
||||
// and fails v2 validation; drop the widget silently, as v1 effectively does.
|
||||
if isUnrenderableMetricQuery(panel) {
|
||||
continue
|
||||
}
|
||||
panels[id] = panel
|
||||
}
|
||||
return panels
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertGraphWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTimeSeries,
|
||||
Spec: &TimeSeriesPanelSpec{
|
||||
Visualization: TimeSeriesVisualization{
|
||||
BasicVisualization: d.basicVisualization(w),
|
||||
FillSpans: d.readBool(w, "fillSpans"),
|
||||
},
|
||||
Formatting: d.panelFormatting(w),
|
||||
ChartAppearance: TimeSeriesChartAppearance{
|
||||
LineInterpolation: mapV1Enum(d.readString(w, "lineInterpolation"), LineInterpolationSpline,
|
||||
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
|
||||
ShowPoints: d.readBool(w, "showPoints"),
|
||||
LineStyle: mapV1Enum(d.readString(w, "lineStyle"), LineStyleSolid, LineStyleSolid, LineStyleDashed),
|
||||
FillMode: mapV1Enum(d.readString(w, "fillMode"), FillModeNone, FillModeSolid, FillModeGradient, FillModeNone),
|
||||
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
|
||||
},
|
||||
Axes: d.axesFromWidget(w),
|
||||
Legend: d.legendFromWidget(w),
|
||||
Thresholds: d.mapV1ThresholdsWithLabel(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindTimeSeries),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertBarWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindBarChart,
|
||||
Spec: &BarChartPanelSpec{
|
||||
Visualization: BarChartVisualization{
|
||||
BasicVisualization: d.basicVisualization(w),
|
||||
FillSpans: d.readBool(w, "fillSpans"),
|
||||
StackedBarChart: d.readBool(w, "stackedBarChart"),
|
||||
},
|
||||
Formatting: d.panelFormatting(w),
|
||||
Axes: d.axesFromWidget(w),
|
||||
Legend: d.legendFromWidget(w),
|
||||
Thresholds: d.mapV1ThresholdsWithLabel(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindBarChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertValueWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindNumber,
|
||||
Spec: &NumberPanelSpec{
|
||||
Visualization: d.basicVisualization(w),
|
||||
Formatting: d.panelFormatting(w),
|
||||
Thresholds: d.mapV1ComparisonThresholds(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindNumber),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertPieWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindPieChart,
|
||||
Spec: &PieChartPanelSpec{
|
||||
Visualization: d.basicVisualization(w),
|
||||
Formatting: d.panelFormatting(w),
|
||||
Legend: d.legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindPieChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertTableWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTable,
|
||||
Spec: &TablePanelSpec{
|
||||
Visualization: d.basicVisualization(w),
|
||||
Formatting: TableFormatting{
|
||||
ColumnUnits: d.readStringMap(w, "columnUnits"),
|
||||
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
|
||||
},
|
||||
Thresholds: d.mapV1TableThresholds(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindTable),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertHistogramWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindHistogram,
|
||||
Spec: &HistogramPanelSpec{
|
||||
HistogramBuckets: HistogramBuckets{
|
||||
BucketCount: d.readFloatPtr(w, "bucketCount"),
|
||||
BucketWidth: d.readFloatPtr(w, "bucketWidth"),
|
||||
MergeAllActiveQueries: d.readBool(w, "mergeAllActiveQueries"),
|
||||
},
|
||||
Legend: d.legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindHistogram),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertListWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindList,
|
||||
Spec: &ListPanelSpec{
|
||||
SelectFields: d.mapV1SelectFields(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindList),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel-spec shared helpers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (d *v1Decoder) widgetDisplay(w map[string]any) Display {
|
||||
return Display{Name: clipName(d.readString(w, "title"), MaxDisplayNameLen), Description: d.readString(w, "description")}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) basicVisualization(w map[string]any) BasicVisualization {
|
||||
return BasicVisualization{TimePreference: mapV1TimePreference(d.readString(w, "timePreferance"))}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
|
||||
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
|
||||
return Axes{
|
||||
SoftMin: d.readFloatPtr(w, "softMin"),
|
||||
SoftMax: d.readFloatPtr(w, "softMax"),
|
||||
IsLogScale: d.readBool(w, "isLogScale"),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
|
||||
return Legend{
|
||||
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
|
||||
CustomColors: d.readStringMap(w, "customLegendColors"),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
|
||||
field := "selectedLogFields"
|
||||
raw := d.readArray(w, field)
|
||||
if len(raw) == 0 {
|
||||
field = "selectedTracesFields"
|
||||
raw = d.readArray(w, field)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalizePreV5FieldKeys(raw)
|
||||
fields, err := decodeTelemetryFields(raw)
|
||||
if err != nil {
|
||||
d.note("widget %q has malformed %s: %v", d.readString(w, "id"), field, err)
|
||||
return nil
|
||||
}
|
||||
// Drop nameless entries (blank column rows) — v2 requires a name, and the v1
|
||||
// UI renders nothing for them anyway.
|
||||
out := fields[:0]
|
||||
for _, f := range fields {
|
||||
if f.Name != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeTelemetryFields(raw []any) ([]telemetrytypes.TelemetryFieldKey, error) {
|
||||
bytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fields []telemetrytypes.TelemetryFieldKey
|
||||
if err := json.Unmarshal(bytes, &fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel field mappers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
|
||||
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
|
||||
// lowercase form, so the translation is just downcase.
|
||||
func mapV1TimePreference(s string) TimePreference {
|
||||
if s == "" {
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
|
||||
// mapV1Precision is polymorphic (string|number), so it type-switches the raw
|
||||
// value rather than reading through a typed accessor.
|
||||
func mapV1Precision(raw any) PrecisionOption {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
candidate := PrecisionOption{valuer.NewString(v)}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
n := int(v)
|
||||
if n >= 0 && n <= 4 {
|
||||
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
|
||||
}
|
||||
}
|
||||
return PrecisionOption2
|
||||
}
|
||||
|
||||
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
|
||||
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
|
||||
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
|
||||
func mapV1Enum[T interface{ StringValue() string }](s string, fallback T, allowed ...T) T {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a.StringValue() == s {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
|
||||
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
|
||||
// X-axis threshold in seconds. Polymorphic, so it type-switches the raw value.
|
||||
func mapV1SpanGaps(raw any) SpanGaps {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
case float64:
|
||||
return SpanGaps{FillOnlyBelow: true, FillLessThan: time.Duration(v * float64(time.Second)).String()}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1ThresholdsWithLabel(w map[string]any) []ThresholdWithLabel {
|
||||
rawSlice := d.readObjects(w, "thresholds")
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ThresholdWithLabel, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color := d.readString(t, "thresholdColor")
|
||||
label := d.readString(t, "thresholdLabel")
|
||||
if color == "" || label == "" {
|
||||
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
|
||||
continue
|
||||
}
|
||||
value := d.readFloat(t, "thresholdValue")
|
||||
out = append(out, ThresholdWithLabel{Value: &value, Unit: d.readString(t, "thresholdUnit"), Color: color, Label: label})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1ComparisonThresholds(w map[string]any) []ComparisonThreshold {
|
||||
rawSlice := d.readObjects(w, "thresholds")
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ComparisonThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color := d.readString(t, "thresholdColor")
|
||||
if color == "" {
|
||||
continue
|
||||
}
|
||||
value := d.readFloat(t, "thresholdValue")
|
||||
out = append(out, ComparisonThreshold{
|
||||
Value: &value,
|
||||
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
|
||||
Unit: d.readString(t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(d.readString(t, "thresholdFormat")),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1TableThresholds(w map[string]any) []TableThreshold {
|
||||
rawSlice := d.readObjects(w, "thresholds")
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]TableThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color := d.readString(t, "thresholdColor")
|
||||
columnName := d.readString(t, "thresholdTableOptions")
|
||||
if color == "" || columnName == "" {
|
||||
continue
|
||||
}
|
||||
value := d.readFloat(t, "thresholdValue")
|
||||
out = append(out, TableThreshold{
|
||||
ComparisonThreshold: ComparisonThreshold{
|
||||
Value: &value,
|
||||
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
|
||||
Unit: d.readString(t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(d.readString(t, "thresholdFormat")),
|
||||
},
|
||||
ColumnName: columnName,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1ComparisonOperator(s string) ComparisonOperator {
|
||||
switch s {
|
||||
case ">", "gt":
|
||||
return ComparisonOperatorAbove
|
||||
case ">=", "gte":
|
||||
return ComparisonOperatorAboveOrEqual
|
||||
case "<", "lt":
|
||||
return ComparisonOperatorBelow
|
||||
case "<=", "lte":
|
||||
return ComparisonOperatorBelowOrEqual
|
||||
case "=", "==", "eq":
|
||||
return ComparisonOperatorEqual
|
||||
case "!=", "neq":
|
||||
return ComparisonOperatorNotEqual
|
||||
default:
|
||||
// v1 often leaves the operator empty or carries an unknown value; default to
|
||||
// "above" without flagging.
|
||||
return ComparisonOperatorAbove
|
||||
}
|
||||
}
|
||||
|
||||
func mapV1ThresholdFormat(s string) ThresholdFormat {
|
||||
switch strings.ToLower(s) {
|
||||
case "background":
|
||||
return ThresholdFormatBackground
|
||||
case "text":
|
||||
return ThresholdFormatText
|
||||
}
|
||||
return ThresholdFormatText
|
||||
}
|
||||
425
pkg/types/dashboardtypes/perses_v1_to_v2_queries.go
Normal file
425
pkg/types/dashboardtypes/perses_v1_to_v2_queries.go
Normal file
@@ -0,0 +1,425 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Queries
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The kind
|
||||
// chosen depends on the v1 widget query shape:
|
||||
// - a single query (promql / clickhouse_sql / builder) → its native kind
|
||||
// - multiple queries → signoz/CompositeQuery
|
||||
//
|
||||
// A single query is never wrapped in a CompositeQuery; in particular List
|
||||
// panels accept only a bare signoz/BuilderQuery. Builder queries are routed
|
||||
// through qb.WrapInV5Envelope (in collectV1QueryEnvelopes), which translates v4
|
||||
// builder-field names (orderBy/selectColumns/dataSource) into their v5
|
||||
// equivalents and adds the `signal` field required by BuilderQuerySpec's
|
||||
// per-signal dispatch.
|
||||
func (d *v1Decoder) convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
|
||||
envelopes, signal := d.collectV1QueryEnvelopes(widget, panelKind)
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
// List panels accept only a bare BuilderQuery — never a CompositeQuery. Keep the
|
||||
// first query and drop the rest so a multi-query v1 list widget still migrates.
|
||||
if panelKind == PanelKindList && len(envelopes) > 1 {
|
||||
envelopes = envelopes[:1]
|
||||
}
|
||||
requestType := requestTypeForPanel(panelKind)
|
||||
|
||||
// A single query keeps its native kind — never wrapped in a CompositeQuery.
|
||||
if len(envelopes) == 1 {
|
||||
if q := singleQueryFromEnvelope(envelopes[0], requestType, signal); q != nil {
|
||||
return []Query{*q}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: wrap in CompositeQuery.
|
||||
composite, err := parseCompositeFromEnvelopes(envelopes)
|
||||
if err != nil || composite == nil {
|
||||
d.note("widget %q: could not build query from %d envelope(s): %s", d.readString(widget, "id"), len(envelopes), detailErr(err))
|
||||
return nil
|
||||
}
|
||||
return []Query{{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// isUnrenderableMetricQuery reports whether the panel's only query is a metric
|
||||
// builder query that v2 rejects: no aggregations at all ("at least one aggregation
|
||||
// is required") or an aggregation with no metric name ("metric name is required").
|
||||
// v1 doesn't render either, so the widget is skipped silently.
|
||||
func isUnrenderableMetricQuery(panel *Panel) bool {
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return false
|
||||
}
|
||||
plugin := panel.Spec.Queries[0].Spec.Plugin
|
||||
if plugin.Kind != QueryKindBuilder {
|
||||
return false
|
||||
}
|
||||
bqs, ok := plugin.Spec.(*BuilderQuerySpec)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
mq, ok := bqs.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if len(mq.Aggregations) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, agg := range mq.Aggregations {
|
||||
if agg.MetricName == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dropUnrenderableQueries removes queries whose aggregation can't render (see
|
||||
// queryIsUnrenderable) — but only when the widget has other renderable queries to keep
|
||||
// it alive. If every query is unrenderable, the list is returned unchanged so the
|
||||
// single-query path (isUnrenderableMetricQuery) can drop the whole widget silently.
|
||||
func dropUnrenderableQueries(queries []map[string]any) []map[string]any {
|
||||
renderable := make([]map[string]any, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
if !queryIsUnrenderable(q) {
|
||||
renderable = append(renderable, q)
|
||||
}
|
||||
}
|
||||
if len(renderable) == 0 {
|
||||
return queries
|
||||
}
|
||||
return renderable
|
||||
}
|
||||
|
||||
// queryIsUnrenderable reports whether a builder query can't render because of its
|
||||
// aggregations: a metrics query with none or an empty metric name, or a logs/traces
|
||||
// query with an empty aggregation expression. No aggregations is valid for a raw
|
||||
// logs/traces query, so that isn't flagged.
|
||||
func queryIsUnrenderable(q map[string]any) bool {
|
||||
aggs, _ := q["aggregations"].([]any)
|
||||
switch signalFromDataSource(q["dataSource"]) {
|
||||
case telemetrytypes.SignalMetrics:
|
||||
if len(aggs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range aggs {
|
||||
if agg, ok := a.(map[string]any); ok {
|
||||
if mn, _ := agg["metricName"].(string); mn == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
|
||||
for _, a := range aggs {
|
||||
if agg, ok := a.(map[string]any); ok {
|
||||
if expr, _ := agg["expression"].(string); expr == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
|
||||
// shape) its queries produce. Mirrors the frontend's panelTypeToRequestType
|
||||
// (buildQueryRangeRequest.ts): time series for line/bar/histogram (histogram
|
||||
// bins client-side from raw time series, V1 parity), scalar for
|
||||
// number/pie/table, raw rows for list.
|
||||
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
|
||||
switch panelKind {
|
||||
case PanelKindTimeSeries, PanelKindBarChart, PanelKindHistogram:
|
||||
return qb.RequestTypeTimeSeries
|
||||
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
|
||||
return qb.RequestTypeScalar
|
||||
case PanelKindList:
|
||||
return qb.RequestTypeRaw
|
||||
}
|
||||
return qb.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
|
||||
// flattened list of v5-shaped envelopes. The returned signal is the dominant
|
||||
// builder signal (if any), used for typed builder-query dispatch.
|
||||
func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind PanelPluginKind) ([]map[string]any, telemetrytypes.Signal) {
|
||||
queryMap := d.readObject(widget, "query")
|
||||
if queryMap == nil {
|
||||
d.note("widget %q has no query map", d.readString(widget, "id"))
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
rowLimitPanel := panelKind == PanelKindList || panelKind == PanelKindTable
|
||||
// Raw (list) panels legitimately have no aggregation; every other panel needs one.
|
||||
needsAggregation := requestTypeForPanel(panelKind) != qb.RequestTypeRaw
|
||||
|
||||
queryType := d.readString(queryMap, "queryType")
|
||||
switch queryType {
|
||||
case "promql":
|
||||
promQueries := d.readObjects(queryMap, "promql")
|
||||
var out []map[string]any
|
||||
for _, q := range promQueries {
|
||||
// With multiple promql queries, drop the empty ones; a lone query is
|
||||
// kept even if empty (nothing else would remain).
|
||||
if len(promQueries) > 1 && d.readString(q, "query") == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, promQLEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "clickhouse_sql":
|
||||
chQueries := d.readObjects(queryMap, "clickhouse_sql")
|
||||
var out []map[string]any
|
||||
for _, q := range chQueries {
|
||||
// With multiple clickhouse queries, drop the empty ones; a lone query is
|
||||
// kept even if empty (nothing else would remain).
|
||||
if len(chQueries) > 1 && d.readString(q, "query") == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, clickhouseEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "builder":
|
||||
builder := d.readObject(queryMap, "builder")
|
||||
if builder == nil {
|
||||
d.note("widget %q has no builder data in the query map", d.readString(widget, "id"))
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
var out []map[string]any
|
||||
var signal telemetrytypes.Signal
|
||||
widgetType := d.readString(widget, "panelTypes")
|
||||
queries := d.readObjects(builder, "queryData")
|
||||
assignQueryDataNames(queries)
|
||||
for _, q := range queries {
|
||||
normalizePreV5QueryData(q, widgetType)
|
||||
normalizePreV5SelectColumns(q)
|
||||
normalizePreV5GroupBy(q)
|
||||
normalizePreV5PageSize(q, rowLimitPanel)
|
||||
normalizeQueryLimit(q)
|
||||
if needsAggregation {
|
||||
ensureDefaultAggregation(q)
|
||||
}
|
||||
}
|
||||
queries = dropUnrenderableQueries(queries)
|
||||
for _, q := range queries {
|
||||
name := d.readString(q, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
|
||||
if signal.IsZero() {
|
||||
signal = signalFromDataSource(q["dataSource"])
|
||||
}
|
||||
}
|
||||
formulas := d.readObjects(builder, "queryFormulas")
|
||||
assignMissingFormulaNames(formulas)
|
||||
for _, f := range formulas {
|
||||
normalizePreV5QueryData(f, widgetType)
|
||||
name := d.readString(f, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue())))
|
||||
}
|
||||
for _, op := range d.readObjects(builder, "queryTraceOperator") {
|
||||
normalizePreV5QueryData(op, widgetType)
|
||||
name := d.readString(op, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, op, string(qb.QueryTypeTraceOperator.StringValue())))
|
||||
}
|
||||
return out, signal
|
||||
default:
|
||||
d.note("widget %q has unknown queryType %q", d.readString(widget, "id"), queryType)
|
||||
}
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
|
||||
// maxQueries mirrors the frontend MAX_QUERIES; builder query names run A..Z.
|
||||
const maxQueries = 26
|
||||
|
||||
// assignQueryDataNames names builder data queries the way the frontend does: each
|
||||
// unnamed query takes the first unused A..Z, deduped against existing names. It also
|
||||
// forces expression == queryName, since a data query's expression is always its own
|
||||
// name and WrapInV5Envelope's name != expression heuristic would otherwise
|
||||
// misclassify the query as a formula.
|
||||
func assignQueryDataNames(queries []map[string]any) {
|
||||
taken := make(map[string]bool, len(queries))
|
||||
for _, q := range queries {
|
||||
if name, _ := q["queryName"].(string); name != "" {
|
||||
taken[name] = true
|
||||
}
|
||||
}
|
||||
for _, q := range queries {
|
||||
name, _ := q["queryName"].(string)
|
||||
if name == "" {
|
||||
for i := 0; i < maxQueries; i++ {
|
||||
candidate := string(rune('A' + i))
|
||||
if !taken[candidate] {
|
||||
name = candidate
|
||||
taken[candidate] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
q["queryName"] = name
|
||||
}
|
||||
q["expression"] = name
|
||||
}
|
||||
}
|
||||
|
||||
// maxFormulas mirrors the frontend MAX_FORMULAS; formula names run F1..F20.
|
||||
const maxFormulas = 20
|
||||
|
||||
// assignMissingFormulaNames fills queryName for unnamed formulas, mirroring the
|
||||
// frontend: pick the first F{n} (n in 1..20) not already used by another formula.
|
||||
// Formulas that already have a name keep it.
|
||||
func assignMissingFormulaNames(formulas []map[string]any) {
|
||||
taken := make(map[string]bool, len(formulas))
|
||||
for _, f := range formulas {
|
||||
if name, _ := f["queryName"].(string); name != "" {
|
||||
taken[name] = true
|
||||
}
|
||||
}
|
||||
for _, f := range formulas {
|
||||
if name, _ := f["queryName"].(string); name != "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i <= maxFormulas; i++ {
|
||||
candidate := "F" + strconv.Itoa(i)
|
||||
if !taken[candidate] {
|
||||
f["queryName"] = candidate
|
||||
taken[candidate] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func promQLEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypePromQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clickhouseEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypeClickHouseSQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// singleQueryFromEnvelope returns a typed Query for one envelope, using its
|
||||
// native query kind (promql/clickhouse_sql/builder) rather than wrapping it in
|
||||
// a CompositeQuery. A bare signoz/BuilderQuery is valid for every panel kind
|
||||
// and is the only kind List panels accept.
|
||||
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType, signal telemetrytypes.Signal) *Query {
|
||||
t, _ := envelope["type"].(string)
|
||||
spec, _ := envelope["spec"].(map[string]any)
|
||||
switch t {
|
||||
case qb.QueryTypePromQL.StringValue():
|
||||
prom, err := decodeMapInto[qb.PromQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: prom.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
|
||||
},
|
||||
}
|
||||
case qb.QueryTypeClickHouseSQL.StringValue():
|
||||
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: ch.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
|
||||
},
|
||||
}
|
||||
case qb.QueryTypeBuilder.StringValue():
|
||||
builderSpec := parseBuilderQuerySpec(spec, signal)
|
||||
if builderSpec == nil {
|
||||
return nil
|
||||
}
|
||||
name, _ := spec["name"].(string)
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: &BuilderQuerySpec{Spec: builderSpec}},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
|
||||
bytes, err := json.Marshal(envelopes)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
|
||||
}
|
||||
var parsed []qb.QueryEnvelope
|
||||
if err := json.Unmarshal(bytes, &parsed); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
|
||||
}
|
||||
return &CompositeQuerySpec{Queries: parsed}, nil
|
||||
}
|
||||
|
||||
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
|
||||
spec, ok := rawSpec.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !signal.IsZero() {
|
||||
spec["signal"] = signal.StringValue()
|
||||
}
|
||||
bytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
|
||||
// varies by source: builder queries store lowercase ("traces"), while variable
|
||||
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
|
||||
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
|
||||
// Signal.
|
||||
func signalFromDataSource(raw any) telemetrytypes.Signal {
|
||||
s, _ := raw.(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "traces":
|
||||
return telemetrytypes.SignalTraces
|
||||
case "logs":
|
||||
return telemetrytypes.SignalLogs
|
||||
case "metrics":
|
||||
return telemetrytypes.SignalMetrics
|
||||
}
|
||||
return telemetrytypes.Signal{}
|
||||
}
|
||||
406
pkg/types/dashboardtypes/perses_v1_to_v2_queries_malformed.go
Normal file
406
pkg/types/dashboardtypes/perses_v1_to_v2_queries_malformed.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Malformed-field normalization
|
||||
// ══════════════════════════════════════════════
|
||||
//
|
||||
// Pre-v5 query-body reshapes for dashboards whose bodies aren't actually v5-shaped
|
||||
// (e.g. stamped version:"v5" but never upgraded). The bulk of the upgrade is
|
||||
// delegated to transition.MigrateQueryDataShapeSafe (see normalizePreV5QueryData);
|
||||
// this file keeps only the reshapes it doesn't cover.
|
||||
|
||||
// preV5Migrator runs transition's shape-safe (idempotent) v4→v5 upgrade. Stateless
|
||||
// after construction, so a shared instance with a discard logger / no ambiguity
|
||||
// keys is fine.
|
||||
var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandler), nil, nil)
|
||||
|
||||
// normalizePreV5QueryData upgrades one builder queryData/formula in place: the
|
||||
// shared migrator, then a reshape of any existing aggregations[] it leaves alone.
|
||||
func normalizePreV5QueryData(query map[string]any, widgetType string) {
|
||||
dropLegacyFilter(query)
|
||||
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
|
||||
normalizePreV5LogTraceAggregations(query)
|
||||
normalizeMetricAggregations(query)
|
||||
normalizeOrderByKeys(query)
|
||||
normalizeFunctionArgs(query)
|
||||
dropInvalidFunctions(query)
|
||||
}
|
||||
|
||||
// dropInvalidFunctions removes any function the v5 validator would reject — an unknown
|
||||
// name, or a missing/uncastable required arg (see Function.Validate). v1 tolerated these
|
||||
// but v2 fails the whole query, so we drop just the offending function. Runs after
|
||||
// normalizeFunctionArgs so a merely double-wrapped (but otherwise valid) function isn't
|
||||
// lost.
|
||||
func dropInvalidFunctions(query map[string]any) {
|
||||
fns, ok := query["functions"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kept := make([]any, 0, len(fns))
|
||||
for _, f := range fns {
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var fn qb.Function
|
||||
if err := json.Unmarshal(raw, &fn); err != nil {
|
||||
continue
|
||||
}
|
||||
if fn.Validate() != nil {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, f)
|
||||
}
|
||||
query["functions"] = kept
|
||||
}
|
||||
|
||||
// normalizeFunctionArgs collapses a doubly-wrapped function arg to a scalar. The
|
||||
// v4→v5 migration that runs before ConvertV1ToV2 (transition.updateQueryData) wraps every arg as
|
||||
// {name, value} without checking whether it's already a v5 arg, so a body that was
|
||||
// already v5 comes back as {value:{value:60}} and fails validation ("must be a floating
|
||||
// value"). We can't guard it at the source — transition's Migrate is shared and left
|
||||
// untouched — so unwrap one level of {value:...} nesting here.
|
||||
func normalizeFunctionArgs(query map[string]any) {
|
||||
fns, ok := query["functions"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, f := range fns {
|
||||
fn, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
args, ok := fn["args"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, a := range args {
|
||||
arg, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if inner, ok := arg["value"].(map[string]any); ok {
|
||||
if v, ok := inner["value"]; ok {
|
||||
arg["value"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// malformedOrderByValueKeys are v4 order-by columnNames meaning "order by the aggregation value"
|
||||
// that the v5 aggregation validator rejects (validateOrderByForAggregation). All resolve
|
||||
// to the same aggregation key. Add more as they surface. The frontend passes these
|
||||
// through (the query-service resolves them), but the v2 dashboard validator only accepts
|
||||
// a real aggregation key.
|
||||
var malformedOrderByValueKeys = map[string]bool{
|
||||
"#SIGNOZ_VALUE": true,
|
||||
"A": true,
|
||||
"A.count()": true,
|
||||
"__result": true,
|
||||
"value": true,
|
||||
}
|
||||
|
||||
// normalizeOrderByKeys rewrites any orderBy columnName in orderByValueKeys to the
|
||||
// v5-valid aggregation key. Left untouched if the key can't resolve (no aggregation to
|
||||
// name).
|
||||
func normalizeOrderByKeys(query map[string]any) {
|
||||
orders, ok := query["orderBy"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, ok := aggregationOrderKey(query)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, o := range orders {
|
||||
order, ok := o.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[cn] {
|
||||
order["columnName"] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aggregationOrderKey names the first aggregation the way validateOrderByForAggregation
|
||||
// expects: "space(metricName)" for metrics, the expression for logs/traces.
|
||||
func aggregationOrderKey(query map[string]any) (string, bool) {
|
||||
aggs, ok := query["aggregations"].([]any)
|
||||
if !ok || len(aggs) == 0 {
|
||||
return "", false
|
||||
}
|
||||
agg, ok := aggs[0].(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if signalFromDataSource(query["dataSource"]) == telemetrytypes.SignalMetrics {
|
||||
metricName, _ := agg["metricName"].(string)
|
||||
space, _ := agg["spaceAggregation"].(string)
|
||||
if metricName == "" || space == "" {
|
||||
return "", false
|
||||
}
|
||||
return space + "(" + metricName + ")", true
|
||||
}
|
||||
expr, _ := agg["expression"].(string)
|
||||
if expr == "" {
|
||||
return "", false
|
||||
}
|
||||
return expr, true
|
||||
}
|
||||
|
||||
// dropLegacyFilter removes a v4-shaped filter ({items, op}) stored under the v5
|
||||
// `filter` key. The v5 filter is {expression}; the migrator only rewrites the v4
|
||||
// `filters` key and skips when `filter` is present, so this stale shape would reach
|
||||
// WrapInV5Envelope and fail v5 validation. The v1 UI ignores it — it types
|
||||
// IBuilderQuery.filter as {expression} (frontend queryBuilderData.ts, filter?: Filter)
|
||||
// and only ever reads filter.expression, so items/op go unread. We drop it before the
|
||||
// migrator, which can then rebuild `filter` from `filters` if present.
|
||||
func dropLegacyFilter(query map[string]any) {
|
||||
filter, ok := query["filter"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, hasItems := filter["items"]
|
||||
_, hasOp := filter["op"]
|
||||
if hasItems || hasOp {
|
||||
delete(query, "filter")
|
||||
}
|
||||
}
|
||||
|
||||
// metricAggregationFields are the JSON keys a metric aggregation accepts (see
|
||||
// MetricAggregation). The decoder is strict, so any other key (e.g. a logs/traces
|
||||
// style `expression`) is rejected as an unknown field.
|
||||
var metricAggregationFields = map[string]bool{
|
||||
"metricName": true,
|
||||
"temporality": true,
|
||||
"timeAggregation": true,
|
||||
"spaceAggregation": true,
|
||||
"comparisonSpaceAggregationParam": true,
|
||||
"reduceTo": true,
|
||||
}
|
||||
|
||||
// normalizeMetricAggregations reshapes a metric query's aggregations to the shape v5
|
||||
// expects. v1 bodies sometimes carry a logs/traces-style aggregation ({expression});
|
||||
// the frontend ignores expression for metrics and builds from the metric fields
|
||||
// (createAggregation, prepareQueryRangePayloadV5.ts), so we drop every non-metric
|
||||
// key. A dropped expression leaves metricName empty and the widget is skipped later
|
||||
// (isUnrenderableMetricQuery), matching what v1 renders.
|
||||
//
|
||||
// It also defaults an invalid spaceAggregation to "sum": v1 bodies often leave it
|
||||
// empty or carry a stale value, which fails validation (SpaceAggregation.IsValid). A
|
||||
// valid value (including a histogram percentile) is left alone; the metric type isn't
|
||||
// in the body, so we can't prefer a percentile default for histograms.
|
||||
func normalizeMetricAggregations(query map[string]any) {
|
||||
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
|
||||
return
|
||||
}
|
||||
aggs, ok := query["aggregations"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, a := range aggs {
|
||||
agg, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for k := range agg {
|
||||
if !metricAggregationFields[k] {
|
||||
delete(agg, k)
|
||||
}
|
||||
}
|
||||
sa, _ := agg["spaceAggregation"].(string)
|
||||
if !(metrictypes.SpaceAggregation{String: valuer.NewString(sa)}).IsValid() {
|
||||
agg["spaceAggregation"] = metrictypes.SpaceAggregationSum.StringValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aggExprRe matches one "func(args)" with an optional "as alias". Mirrors the
|
||||
// frontend's parseAggregations regex; matching only well-formed func(args)
|
||||
// discards trailing junk ("sum(x) ) )" → "sum(x)").
|
||||
var aggExprRe = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
|
||||
|
||||
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
|
||||
// via parseAggregations (extract func(args), lift inline "as alias", split
|
||||
// multi-part, drop metric-only fields; empty → count()). Covers the case the
|
||||
// migrator skips: it builds from flat fields but leaves a present-but-malformed
|
||||
// aggregations[] alone. A query with none is left as-is.
|
||||
func normalizePreV5LogTraceAggregations(query map[string]any) {
|
||||
switch signalFromDataSource(query["dataSource"]) {
|
||||
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
|
||||
default:
|
||||
return
|
||||
}
|
||||
aggs, ok := query["aggregations"].([]any)
|
||||
if !ok || len(aggs) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]any, 0, len(aggs))
|
||||
for _, a := range aggs {
|
||||
agg, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
expr, _ := agg["expression"].(string)
|
||||
alias, _ := agg["alias"].(string)
|
||||
parsed := parseAggregations(expr, alias)
|
||||
if len(parsed) == 0 {
|
||||
parsed = []any{map[string]any{"expression": "count()"}}
|
||||
}
|
||||
out = append(out, parsed...)
|
||||
}
|
||||
query["aggregations"] = out
|
||||
}
|
||||
|
||||
// ensureDefaultAggregation defaults an empty logs/traces aggregations[] to count(),
|
||||
// mirroring the frontend. Callers gate this to aggregation panels. Metrics are skipped:
|
||||
// count() can't stand in for a missing metricName.
|
||||
func ensureDefaultAggregation(query map[string]any) {
|
||||
switch signalFromDataSource(query["dataSource"]) {
|
||||
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
|
||||
default:
|
||||
return
|
||||
}
|
||||
if aggs, ok := query["aggregations"].([]any); ok && len(aggs) > 0 {
|
||||
return
|
||||
}
|
||||
query["aggregations"] = []any{map[string]any{"expression": "count()"}}
|
||||
}
|
||||
|
||||
// parseAggregations pulls every func(args) (with inline or passed-through alias,
|
||||
// quotes stripped) out of a v1 expression. Mirrors the frontend's
|
||||
// parseAggregations; empty result if none.
|
||||
func parseAggregations(expression, availableAlias string) []any {
|
||||
matches := aggExprRe.FindAllStringSubmatch(expression, -1)
|
||||
out := make([]any, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
alias := m[2]
|
||||
if alias == "" {
|
||||
alias = availableAlias
|
||||
}
|
||||
agg := map[string]any{"expression": m[1]}
|
||||
if alias != "" {
|
||||
agg["alias"] = strings.Trim(alias, `'"`)
|
||||
}
|
||||
out = append(out, agg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePreV5SelectColumns / normalizePreV5GroupBy let WrapInV5Envelope (which
|
||||
// reads the old {key,dataType,type}) handle selectColumns/groupBy stored the v5 way
|
||||
// ({name,…}) — see backfillPreV5FieldKeys. Inverse of normalizePreV5FieldKeys (the
|
||||
// two consumers want opposite shapes).
|
||||
func normalizePreV5SelectColumns(query map[string]any) {
|
||||
if cols, ok := query["selectColumns"].([]any); ok {
|
||||
query["selectColumns"] = backfillPreV5FieldKeys(cols)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePreV5GroupBy(query map[string]any) {
|
||||
if gb, ok := query["groupBy"].([]any); ok {
|
||||
query["groupBy"] = backfillPreV5FieldKeys(gb)
|
||||
}
|
||||
}
|
||||
|
||||
// backfillPreV5FieldKeys copies v5 field names (name/fieldDataType/fieldContext)
|
||||
// down to their v4 equivalents (key/dataType/type) so WrapInV5Envelope, which reads
|
||||
// the v4 names, sees a field stored the v5 way. Fields with no resolvable key are
|
||||
// dropped.
|
||||
func backfillPreV5FieldKeys(fields []any) []any {
|
||||
out := make([]any, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
field, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := field["key"]; !ok {
|
||||
if name, ok := field["name"]; ok {
|
||||
field["key"] = name
|
||||
}
|
||||
}
|
||||
if _, ok := field["dataType"]; !ok {
|
||||
if fdt, ok := field["fieldDataType"]; ok {
|
||||
field["dataType"] = fdt
|
||||
}
|
||||
}
|
||||
if _, ok := field["type"]; !ok {
|
||||
if fc, ok := field["fieldContext"]; ok {
|
||||
field["type"] = fc
|
||||
}
|
||||
}
|
||||
if key, _ := field["key"].(string); key == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePreV5FieldKeys renames list-panel field keys {key,dataType,type} →
|
||||
// {name,fieldDataType,fieldContext} in place (as WrapInV5Envelope does for
|
||||
// groupBy/orderBy). Entries already carrying "name" are left as-is.
|
||||
func normalizePreV5FieldKeys(fields []any) {
|
||||
for _, f := range fields {
|
||||
field, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, hasName := field["name"]; hasName {
|
||||
continue
|
||||
}
|
||||
if key, ok := field["key"]; ok {
|
||||
field["name"] = key
|
||||
}
|
||||
if dataType, ok := field["dataType"]; ok {
|
||||
field["fieldDataType"] = dataType
|
||||
}
|
||||
if typ, ok := field["type"]; ok {
|
||||
field["fieldContext"] = typ
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePreV5PageSize backfills limit from the legacy pageSize (frontend's
|
||||
// `limit || pageSize`), for row-limited panels (list/table) only. Leaves a query
|
||||
// that already has limit, or a non-row-limited panel, untouched.
|
||||
func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
|
||||
if !rowLimitPanel {
|
||||
return
|
||||
}
|
||||
if limit, ok := query["limit"]; ok && limit != nil {
|
||||
return
|
||||
}
|
||||
if ps, ok := query["pageSize"]; ok {
|
||||
query["limit"] = ps
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
|
||||
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
|
||||
// the query unlimited (the field is optional).
|
||||
func normalizeQueryLimit(query map[string]any) {
|
||||
limit, ok := coerceFloat(query["limit"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if limit > qb.MaxQueryLimit {
|
||||
delete(query, "limit")
|
||||
}
|
||||
}
|
||||
122
pkg/types/dashboardtypes/perses_v1_to_v2_tags.go
Normal file
122
pkg/types/dashboardtypes/perses_v1_to_v2_tags.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Tags
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
|
||||
// string is normalized into a pair (separator split, empty-side fallback,
|
||||
// reserved-key prefix, `/` scrub). Tags that normalize to the same
|
||||
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
|
||||
// winning the display casing.
|
||||
//
|
||||
// Characters still illegal after normalization (spaces, punctuation) are molded
|
||||
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
|
||||
|
||||
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
|
||||
// separator (or one side of the split is empty).
|
||||
const defaultV1TagKey = "tag"
|
||||
|
||||
func (d *v1Decoder) convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
rawTagsList, ok := raw.([]any)
|
||||
if !ok {
|
||||
d.noteMalformedField("tags", raw)
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(rawTagsList))
|
||||
tagsV2 := make([]*tagtypes.Tag, 0, len(rawTagsList))
|
||||
for i, rawTag := range rawTagsList {
|
||||
s, ok := rawTag.(string)
|
||||
if !ok {
|
||||
d.noteMalformedField(fmt.Sprintf("tags[%d]", i), rawTag)
|
||||
continue
|
||||
}
|
||||
key, value, ok := normalizeV1Tag(s)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
|
||||
if _, dup := seen[dedupKey]; dup {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
tagsV2 = append(tagsV2, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
|
||||
}
|
||||
return tagsV2
|
||||
}
|
||||
|
||||
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
|
||||
// splitting and molding both sides, a lone survivor becomes a value under the
|
||||
// default key; ok is false if neither survives.
|
||||
func normalizeV1Tag(s string) (string, string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var rawKey, rawValue string
|
||||
switch {
|
||||
case strings.Contains(s, ":"):
|
||||
rawKey, rawValue, _ = strings.Cut(s, ":")
|
||||
// Only the first ":" separates key from value; collapse the rest.
|
||||
rawValue = strings.ReplaceAll(rawValue, ":", "_")
|
||||
case strings.Contains(s, "/"):
|
||||
rawKey, rawValue, _ = strings.Cut(s, "/")
|
||||
default:
|
||||
rawValue = s
|
||||
}
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
rawValue = strings.TrimSpace(rawValue)
|
||||
|
||||
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
|
||||
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
|
||||
rawKey = "_" + rawKey
|
||||
}
|
||||
|
||||
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
|
||||
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
|
||||
switch {
|
||||
case key == "" && value == "":
|
||||
return "", "", false
|
||||
case key == "":
|
||||
return defaultV1TagKey, value, true
|
||||
case value == "":
|
||||
return defaultV1TagKey, key, true
|
||||
default:
|
||||
return key, value, true
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
|
||||
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
|
||||
var (
|
||||
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
|
||||
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
|
||||
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
|
||||
)
|
||||
|
||||
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
|
||||
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
|
||||
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
|
||||
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
|
||||
if s != "" && notLead != nil && notLead.MatchString(s) {
|
||||
s = "_" + s
|
||||
}
|
||||
if len(s) > max {
|
||||
s = strings.TrimRight(s[:max], "_")
|
||||
}
|
||||
return s
|
||||
}
|
||||
1523
pkg/types/dashboardtypes/perses_v1_to_v2_test.go
Normal file
1523
pkg/types/dashboardtypes/perses_v1_to_v2_test.go
Normal file
File diff suppressed because it is too large
Load Diff
210
pkg/types/dashboardtypes/perses_v1_to_v2_variables.go
Normal file
210
pkg/types/dashboardtypes/perses_v1_to_v2_variables.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Variables
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
|
||||
// ordered []Variable. Variables sort by `order` first, then by id for stable
|
||||
// output. v1 variable types map as follows:
|
||||
//
|
||||
// QUERY → ListVariable + signoz/QueryVariable
|
||||
// CUSTOM → ListVariable + signoz/CustomVariable
|
||||
// DYNAMIC → ListVariable + signoz/DynamicVariable
|
||||
// TEXTBOX → TextVariable
|
||||
func (d *v1Decoder) convertV1Variables(raw any) []Variable {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
rawVariablesMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
// v1 sometimes stores variables as a list. The frontend consumes it via
|
||||
// Object.entries/keys, which for an array yields the stringified index as the
|
||||
// key, so mirror that: [{...}] is treated as {"0":{...}}. An empty list is
|
||||
// simply "no variables".
|
||||
rawSlice, isSlice := raw.([]any)
|
||||
if !isSlice {
|
||||
d.noteMalformedField("variables", raw)
|
||||
return nil
|
||||
}
|
||||
rawVariablesMap = make(map[string]any, len(rawSlice))
|
||||
for i, v := range rawSlice {
|
||||
rawVariablesMap[strconv.Itoa(i)] = v
|
||||
}
|
||||
}
|
||||
type ordered struct {
|
||||
variableID string
|
||||
variableContent map[string]any
|
||||
order float64
|
||||
}
|
||||
entries := make([]ordered, 0, len(rawVariablesMap))
|
||||
for variableID, variableContentRaw := range rawVariablesMap {
|
||||
variableContent, ok := variableContentRaw.(map[string]any)
|
||||
if !ok {
|
||||
// A variable whose content isn't an object (e.g. a stray "list" array) can't
|
||||
// render in the current UI, so it's useless — skip it instead of failing the
|
||||
// migration.
|
||||
continue
|
||||
}
|
||||
entries = append(entries, ordered{variableID: variableID, variableContent: variableContent, order: d.readFloat(variableContent, "order")})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].order != entries[j].order {
|
||||
return entries[i].order < entries[j].order
|
||||
}
|
||||
return entries[i].variableID < entries[j].variableID
|
||||
})
|
||||
|
||||
variablesV2 := make([]Variable, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
v, ok := d.convertV1Variable(e.variableContent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
variablesV2 = append(variablesV2, v)
|
||||
}
|
||||
return variablesV2
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertV1Variable(v map[string]any) (Variable, bool) {
|
||||
name := d.readString(v, "name")
|
||||
if name == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
description := d.readString(v, "description")
|
||||
// v1 stores the type upper-cased (QUERY/CUSTOM/…); tolerate any casing.
|
||||
kind := strings.ToUpper(d.readString(v, "type"))
|
||||
|
||||
switch kind {
|
||||
case "TEXTBOX":
|
||||
spec := &TextVariableSpec{
|
||||
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
|
||||
Value: d.readString(v, "textboxValue"),
|
||||
Name: name,
|
||||
}
|
||||
return Variable{Kind: variable.KindText, Spec: spec}, true
|
||||
|
||||
case "QUERY", "CUSTOM", "DYNAMIC":
|
||||
// Drop (don't fail on) a dynamic variable with no attribute — it can't resolve.
|
||||
if kind == "DYNAMIC" && d.readString(v, "dynamicVariablesAttribute") == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
// Drop a custom variable with no recoverable option list — v2 requires one.
|
||||
if kind == "CUSTOM" && d.readString(v, "customValue") == "" && d.readString(v, "selectedValue") == "" && d.readString(v, "defaultValue") == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
// Drop a query variable with no query — it can't resolve.
|
||||
if kind == "QUERY" && d.readString(v, "queryValue") == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
listSpec := &ListVariableSpec{
|
||||
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
|
||||
AllowAllValue: d.readBool(v, "showALLOption"),
|
||||
AllowMultiple: d.readBool(v, "multiSelect"),
|
||||
CustomAllValue: d.readString(v, "customAllValue"),
|
||||
CapturingRegexp: d.readString(v, "capturingRegexp"),
|
||||
Sort: mapV1Sort(d.readString(v, "sort")),
|
||||
Plugin: d.variablePluginFor(kind, v),
|
||||
Name: name,
|
||||
}
|
||||
if dv := mapV1VariableDefault(v, listSpec.AllowMultiple); dv != nil {
|
||||
listSpec.DefaultValue = dv
|
||||
}
|
||||
return Variable{Kind: variable.KindList, Spec: listSpec}, true
|
||||
|
||||
default:
|
||||
d.note("variable %q has unknown type %q", name, kind)
|
||||
return Variable{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) variablePluginFor(kind string, v map[string]any) VariablePlugin {
|
||||
switch kind {
|
||||
case "QUERY":
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindQuery,
|
||||
Spec: &QueryVariableSpec{QueryValue: d.readString(v, "queryValue")},
|
||||
}
|
||||
case "CUSTOM":
|
||||
// Some v1 dashboards stored the option list in selectedValue/defaultValue
|
||||
// instead of customValue; fall back so the variable survives migration.
|
||||
customValue := d.readString(v, "customValue")
|
||||
if customValue == "" {
|
||||
customValue = d.readString(v, "selectedValue")
|
||||
}
|
||||
if customValue == "" {
|
||||
customValue = d.readString(v, "defaultValue")
|
||||
}
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindCustom,
|
||||
Spec: &CustomVariableSpec{CustomValue: customValue},
|
||||
}
|
||||
case "DYNAMIC":
|
||||
spec := &DynamicVariableSpec{Name: d.readString(v, "dynamicVariablesAttribute")}
|
||||
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
|
||||
spec.Signal = signal
|
||||
}
|
||||
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
|
||||
}
|
||||
return VariablePlugin{}
|
||||
}
|
||||
|
||||
// mapV1VariableDefault reads selectedValue/defaultValue, both polymorphic
|
||||
// (string|array), so it indexes the raw value and lets defaultValueFromAny
|
||||
// type-switch — no typed accessor, intentionally lenient.
|
||||
func mapV1VariableDefault(v map[string]any, allowMultiple bool) *VariableDefaultValue {
|
||||
if raw, ok := v["selectedValue"]; ok {
|
||||
return defaultValueFromAny(raw, allowMultiple)
|
||||
}
|
||||
if raw, ok := v["defaultValue"]; ok {
|
||||
return defaultValueFromAny(raw, allowMultiple)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultValueFromAny(raw any, allowMultiple bool) *VariableDefaultValue {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &VariableDefaultValue{variable.DefaultValue{SingleValue: v}}
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
values = append(values, s)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
// A single-select variable can't carry a list default; collapse a lone value.
|
||||
if !allowMultiple && len(values) == 1 {
|
||||
return &VariableDefaultValue{variable.DefaultValue{SingleValue: values[0]}}
|
||||
}
|
||||
return &VariableDefaultValue{variable.DefaultValue{SliceValues: values}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapV1Sort(s string) ListVariableSpecSort {
|
||||
switch s {
|
||||
case "ASC":
|
||||
return SortAlphabeticalAsc
|
||||
case "DESC":
|
||||
return SortAlphabeticalDesc
|
||||
}
|
||||
return ListVariableSpecSort{} // zero (omitzero) — SortNone is the implicit default
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
// WrapInV5Envelope translates a single v4 builder query/formula map into a
|
||||
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
|
||||
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
|
||||
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
|
||||
// from the data source. queryType selects the envelope type, except a formula
|
||||
// (detected when name != queryMap["expression"]) is always emitted as
|
||||
// "builder_formula".
|
||||
//
|
||||
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
|
||||
// produce v5 envelopes, so this lives here with the v5 query types rather than
|
||||
// in an infra-level package.
|
||||
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user