Compare commits

...

1 Commits

Author SHA1 Message Date
Naman Verma
86fc0e81ba chore: add migration script from current to perses dashboard 2026-06-14 22:58:08 +05:30
9 changed files with 2100 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package dashboardtypes
import (
"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_helpers.go generic map/slice accessors
// ══════════════════════════════════════════════
// 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() (*DashboardV2, error) {
if storable.IsV2() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
}
image, _ := storable.Data["image"].(string)
title, _ := storable.Data["title"].(string)
description, _ := storable.Data["description"].(string)
spec := DashboardSpec{
Display: Display{Name: title, Description: description},
Variables: convertV1Variables(storable.Data["variables"]),
Panels: convertV1Panels(storable.Data["widgets"]),
Layouts: convertV1Layouts(storable.Data),
}
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: storable.Name,
Tags: convertV1TagsForOrg(storable.OrgID, storable.Data["tags"]),
Spec: spec,
}, nil
}

View File

@@ -0,0 +1,85 @@
package dashboardtypes
import "encoding/json"
// ══════════════════════════════════════════════
// Generic helpers
// ══════════════════════════════════════════════
// ptrValueAt is the pointer-returning sibling of valueAt: it returns *T so the
// caller can tell "absent / wrong type" (nil) apart from a present zero value.
// Used for optional fields like soft axis bounds and histogram bucket sizing.
func ptrValueAt[T any](raw any, key string) *T {
m, ok := raw.(map[string]any)
if !ok {
return nil
}
v, ok := m[key].(T)
if !ok {
return nil
}
return &v
}
func readStringMap(raw any) map[string]string {
m, ok := raw.(map[string]any)
if !ok || len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out
}
func readSliceOfMaps(raw any) []map[string]any {
rawSlice, ok := raw.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(rawSlice))
for _, item := range rawSlice {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// valueAt reads key from raw (when raw is a map[string]any) and returns its
// value as T, or the zero value of T if raw isn't a map, the key is absent, or
// the stored value isn't a T. Used to pull typed fields out of the untyped v1
// dashboard blob.
func valueAt[T any](raw any, key string) T {
var zero T
m, ok := raw.(map[string]any)
if !ok {
return zero
}
v, _ := m[key].(T)
return v
}
// intAt is a thin wrapper over valueAt: JSON decodes numbers as float64, so an
// integer field must be read as float64 and narrowed.
func intAt(raw any, key string) int {
return int(valueAt[float64](raw, key))
}
// 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
}

View File

@@ -0,0 +1,138 @@
package dashboardtypes
import (
"fmt"
"sort"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// ══════════════════════════════════════════════
// Layouts (data.layout + data.panelMap)
// ══════════════════════════════════════════════
// convertV1Layouts groups v1 react-grid-layout entries by section. Each row
// widget (panelTypes == "row") in `widgets` plus its `panelMap` entry becomes
// a separate v2 grid layout with a collapsible display. Widgets that are not
// part of any section land in a default unnamed grid (added only if any such
// widgets exist).
func convertV1Layouts(data StorableDashboardData) []Layout {
layoutsRaw := readSliceOfMaps(data["layout"])
if len(layoutsRaw) == 0 {
return nil
}
panelMap, _ := data["panelMap"].(map[string]any)
rows, widgetIDToRow := indexRows(data["widgets"], panelMap)
type bucket struct {
title string
open bool
isRow bool
layouts []map[string]any
ordering int
}
rootBucket := &bucket{}
rowBuckets := make(map[string]*bucket, len(rows))
for _, row := range rows {
rowBuckets[row.id] = &bucket{
title: row.title,
open: !row.collapsed,
isRow: true,
ordering: row.ordering,
}
}
for _, item := range layoutsRaw {
widgetID, _ := item["i"].(string)
if widgetID == "" {
continue
}
if rowID, ok := widgetIDToRow[widgetID]; ok {
if b, ok := rowBuckets[rowID]; ok {
b.layouts = append(b.layouts, item)
continue
}
}
// row widgets themselves shouldn't end up as items in the root grid;
// they exist only to anchor their section.
if _, isRow := rowBuckets[widgetID]; isRow {
continue
}
rootBucket.layouts = append(rootBucket.layouts, item)
}
out := make([]Layout, 0, len(rows)+1)
if len(rootBucket.layouts) > 0 {
out = append(out, gridLayoutFromBucket("", true, false, rootBucket.layouts))
}
rowKeys := make([]string, 0, len(rowBuckets))
for id := range rowBuckets {
rowKeys = append(rowKeys, id)
}
sort.SliceStable(rowKeys, func(i, j int) bool {
return rowBuckets[rowKeys[i]].ordering < rowBuckets[rowKeys[j]].ordering
})
for _, id := range rowKeys {
b := rowBuckets[id]
out = append(out, gridLayoutFromBucket(b.title, b.open, true, b.layouts))
}
return out
}
type rowInfo struct {
id string
title string
collapsed bool
ordering int
}
func indexRows(widgetsRaw any, panelMap map[string]any) ([]rowInfo, map[string]string) {
widgets := readSliceOfMaps(widgetsRaw)
rows := make([]rowInfo, 0)
widgetToRow := make(map[string]string)
for i, w := range widgets {
if t, _ := w["panelTypes"].(string); t != "row" {
continue
}
id, _ := w["id"].(string)
if id == "" {
continue
}
title, _ := w["title"].(string)
row := rowInfo{id: id, title: title, ordering: i}
if pm, ok := panelMap[id].(map[string]any); ok {
row.collapsed = valueAt[bool](pm, "collapsed")
for _, child := range readSliceOfMaps(pm["widgets"]) {
if childID, _ := child["i"].(string); childID != "" {
widgetToRow[childID] = id
}
}
}
rows = append(rows, row)
}
return rows, widgetToRow
}
func gridLayoutFromBucket(title string, open, isRow bool, items []map[string]any) Layout {
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
if title != "" || isRow {
spec.Display = &dashboard.GridLayoutDisplay{Title: title}
if isRow {
spec.Display.Collapse = &dashboard.GridLayoutCollapse{Open: open}
}
}
for _, item := range items {
widgetID, _ := item["i"].(string)
spec.Items = append(spec.Items, dashboard.GridItem{
X: intAt(item, "x"),
Y: intAt(item, "y"),
Width: intAt(item, "w"),
Height: intAt(item, "h"),
Content: &common.JSONRef{Ref: fmt.Sprintf("#/spec/panels/%s", widgetID)},
})
}
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
}

View File

@@ -0,0 +1,449 @@
package dashboardtypes
import (
"encoding/json"
"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 convertV1Panels(raw any) map[string]*Panel {
rawSlice, ok := raw.([]any)
if !ok {
return nil
}
panels := make(map[string]*Panel, len(rawSlice))
for _, item := range rawSlice {
widget, ok := item.(map[string]any)
if !ok {
continue
}
id, _ := widget["id"].(string)
if id == "" {
continue
}
panelType, _ := widget["panelTypes"].(string)
var panel *Panel
switch panelType {
case "graph":
panel = convertGraphWidget(widget)
case "bar":
panel = convertBarWidget(widget)
case "value":
panel = convertValueWidget(widget)
case "pie":
panel = convertPieWidget(widget)
case "table":
panel = convertTableWidget(widget)
case "histogram":
panel = convertHistogramWidget(widget)
case "list":
panel = convertListWidget(widget)
default:
// "row" (section header) is handled by the layout pass; unknown kinds skipped.
continue
}
if panel == nil {
continue
}
panels[id] = panel
}
return panels
}
func convertGraphWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTimeSeries,
Spec: &TimeSeriesPanelSpec{
Visualization: TimeSeriesVisualization{
BasicVisualization: basicVisualization(w),
FillSpans: valueAt[bool](w, "fillSpans"),
},
Formatting: panelFormatting(w),
ChartAppearance: TimeSeriesChartAppearance{
LineInterpolation: mapV1Enum(w["lineInterpolation"], LineInterpolationSpline,
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
ShowPoints: valueAt[bool](w, "showPoints"),
LineStyle: mapV1Enum(w["lineStyle"], LineStyleSolid, LineStyleSolid, LineStyleDashed),
FillMode: mapV1Enum(w["fillMode"], FillModeSolid, FillModeSolid, FillModeGradient, FillModeNone),
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
},
Axes: axesFromWidget(w),
Legend: legendFromWidget(w),
Thresholds: mapV1ThresholdsWithLabel(w["thresholds"]),
},
},
Queries: convertV1WidgetQuery(w, PanelKindTimeSeries),
},
}
}
func convertBarWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindBarChart,
Spec: &BarChartPanelSpec{
Visualization: BarChartVisualization{
BasicVisualization: basicVisualization(w),
FillSpans: valueAt[bool](w, "fillSpans"),
StackedBarChart: valueAt[bool](w, "stackedBarChart"),
},
Formatting: panelFormatting(w),
Axes: axesFromWidget(w),
Legend: legendFromWidget(w),
Thresholds: mapV1ThresholdsWithLabel(w["thresholds"]),
},
},
Queries: convertV1WidgetQuery(w, PanelKindBarChart),
},
}
}
func convertValueWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindNumber,
Spec: &NumberPanelSpec{
Visualization: basicVisualization(w),
Formatting: panelFormatting(w),
Thresholds: mapV1ComparisonThresholds(w["thresholds"]),
},
},
Queries: convertV1WidgetQuery(w, PanelKindNumber),
},
}
}
func convertPieWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindPieChart,
Spec: &PieChartPanelSpec{
Visualization: basicVisualization(w),
Formatting: panelFormatting(w),
Legend: legendFromWidget(w),
},
},
Queries: convertV1WidgetQuery(w, PanelKindPieChart),
},
}
}
func convertTableWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTable,
Spec: &TablePanelSpec{
Visualization: basicVisualization(w),
Formatting: TableFormatting{
ColumnUnits: readStringMap(w["columnUnits"]),
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
},
Thresholds: mapV1TableThresholds(w["thresholds"]),
},
},
Queries: convertV1WidgetQuery(w, PanelKindTable),
},
}
}
func convertHistogramWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindHistogram,
Spec: &HistogramPanelSpec{
HistogramBuckets: HistogramBuckets{
BucketCount: ptrValueAt[float64](w, "bucketCount"),
BucketWidth: ptrValueAt[float64](w, "bucketWidth"),
MergeAllActiveQueries: valueAt[bool](w, "mergeAllActiveQueries"),
},
Legend: legendFromWidget(w),
},
},
Queries: convertV1WidgetQuery(w, PanelKindHistogram),
},
}
}
func convertListWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindList,
Spec: &ListPanelSpec{
SelectFields: mapV1SelectFields(w),
},
},
Queries: convertV1WidgetQuery(w, PanelKindList),
},
}
}
// ══════════════════════════════════════════════
// Panel-spec shared helpers
// ══════════════════════════════════════════════
func widgetDisplay(w map[string]any) Display {
title, _ := w["title"].(string)
description, _ := w["description"].(string)
return Display{Name: title, Description: description}
}
func basicVisualization(w map[string]any) BasicVisualization {
return BasicVisualization{TimePreference: mapV1TimePreference(w["timePreferance"])}
}
func panelFormatting(w map[string]any) PanelFormatting {
unit, _ := w["yAxisUnit"].(string)
return PanelFormatting{Unit: unit, DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
}
func axesFromWidget(w map[string]any) Axes {
return Axes{
SoftMin: ptrValueAt[float64](w, "softMin"),
SoftMax: ptrValueAt[float64](w, "softMax"),
IsLogScale: valueAt[bool](w, "isLogScale"),
}
}
func legendFromWidget(w map[string]any) Legend {
return Legend{
Position: mapV1Enum(w["legendPosition"], LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
CustomColors: readStringMap(w["customLegendColors"]),
}
}
func mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
if raw, ok := w["selectedLogFields"].([]any); ok && len(raw) > 0 {
return decodeTelemetryFields(raw)
}
if raw, ok := w["selectedTracesFields"].([]any); ok && len(raw) > 0 {
return decodeTelemetryFields(raw)
}
return nil
}
func decodeTelemetryFields(raw []any) []telemetrytypes.TelemetryFieldKey {
bytes, err := json.Marshal(raw)
if err != nil {
return nil
}
var fields []telemetrytypes.TelemetryFieldKey
if err := json.Unmarshal(bytes, &fields); err != nil {
return nil
}
return fields
}
// ══════════════════════════════════════════════
// 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(raw any) TimePreference {
s, ok := raw.(string)
if !ok || s == "" {
return TimePreferenceGlobalTime
}
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
return TimePreferenceGlobalTime
}
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 }](raw any, fallback T, allowed ...T) T {
s, ok := raw.(string)
if !ok || 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.
func mapV1SpanGaps(raw any) SpanGaps {
switch v := raw.(type) {
case bool:
if v {
return SpanGaps{FillOnlyBelow: false}
}
return SpanGaps{FillOnlyBelow: true}
case float64:
dur, err := valuer.ParseTextDuration(time.Duration(v * float64(time.Second)).String())
if err != nil {
return SpanGaps{FillOnlyBelow: false}
}
return SpanGaps{FillOnlyBelow: true, FillLessThan: dur}
}
return SpanGaps{FillOnlyBelow: false}
}
func mapV1ThresholdsWithLabel(raw any) []ThresholdWithLabel {
rawSlice := readSliceOfMaps(raw)
if len(rawSlice) == 0 {
return nil
}
out := make([]ThresholdWithLabel, 0, len(rawSlice))
for _, t := range rawSlice {
color, _ := t["thresholdColor"].(string)
label, _ := t["thresholdLabel"].(string)
if color == "" || label == "" {
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
continue
}
value, _ := t["thresholdValue"].(float64)
unit, _ := t["thresholdUnit"].(string)
out = append(out, ThresholdWithLabel{Value: value, Unit: unit, Color: color, Label: label})
}
if len(out) == 0 {
return nil
}
return out
}
func mapV1ComparisonThresholds(raw any) []ComparisonThreshold {
rawSlice := readSliceOfMaps(raw)
if len(rawSlice) == 0 {
return nil
}
out := make([]ComparisonThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color, _ := t["thresholdColor"].(string)
if color == "" {
continue
}
out = append(out, ComparisonThreshold{
Value: valueAt[float64](t, "thresholdValue"),
Operator: mapV1ComparisonOperator(t["thresholdOperator"]),
Unit: valueAt[string](t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
})
}
if len(out) == 0 {
return nil
}
return out
}
func mapV1TableThresholds(raw any) []TableThreshold {
rawSlice := readSliceOfMaps(raw)
if len(rawSlice) == 0 {
return nil
}
out := make([]TableThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color, _ := t["thresholdColor"].(string)
columnName, _ := t["thresholdTableOptions"].(string)
if color == "" || columnName == "" {
continue
}
out = append(out, TableThreshold{
ComparisonThreshold: ComparisonThreshold{
Value: valueAt[float64](t, "thresholdValue"),
Operator: mapV1ComparisonOperator(t["thresholdOperator"]),
Unit: valueAt[string](t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
},
ColumnName: columnName,
})
}
if len(out) == 0 {
return nil
}
return out
}
func mapV1ComparisonOperator(raw any) ComparisonOperator {
s, _ := raw.(string)
switch s {
case ">":
return ComparisonOperatorAbove
case ">=":
return ComparisonOperatorAboveOrEqual
case "<":
return ComparisonOperatorBelow
case "<=":
return ComparisonOperatorBelowOrEqual
case "=":
return ComparisonOperatorEqual
case "!=":
return ComparisonOperatorNotEqual
}
return ComparisonOperatorAbove
}
func mapV1ThresholdFormat(raw any) ThresholdFormat {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "background":
return ThresholdFormatBackground
case "text":
return ThresholdFormatText
}
return ThresholdFormatText
}

View File

@@ -0,0 +1,249 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
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:
// - single promql → signoz/PromQLQuery
// - single clickhouse_sql → signoz/ClickHouseSQL
// - exactly one builder query → signoz/BuilderQuery (PanelKindList only)
// - everything else → signoz/CompositeQuery wrapping all envelopes
//
// Builder queries are routed through transition.WrapInV5Envelope, 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 convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
envelopes, signal := collectV1QueryEnvelopes(widget)
if len(envelopes) == 0 {
return nil
}
requestType := requestTypeForPanel(panelKind)
// List panels must use signoz/BuilderQuery (the only kind in
// allowedQueryKinds[PanelKindList]).
if panelKind == PanelKindList {
first := envelopes[0]
if t, _ := first["type"].(string); t == string(qb.QueryTypeBuilder.StringValue()) {
spec := parseBuilderQuerySpec(first["spec"], signal)
if spec == nil {
return nil
}
return []Query{{
Kind: requestType,
Spec: QuerySpec{
Name: valueAt[string](first["spec"], "name"),
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: spec},
},
},
}}
}
}
// Single non-builder query → use its native kind directly. Cleaner JSON
// than wrapping in CompositeQuery for the common single-query case.
if len(envelopes) == 1 {
if q := singleQueryFromEnvelope(envelopes[0], requestType); q != nil {
return []Query{*q}
}
}
// Default: wrap in CompositeQuery.
composite, err := parseCompositeFromEnvelopes(envelopes)
if err != nil || composite == nil {
return nil
}
return []Query{{
Kind: requestType,
Spec: QuerySpec{
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
},
}}
}
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
// shape) its queries produce. Mirrors the shape each visualization consumes:
// time series for line/bar, scalar for number/pie/table, distribution for
// histogram, raw rows for list.
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
switch panelKind {
case PanelKindTimeSeries, PanelKindBarChart:
return qb.RequestTypeTimeSeries
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
return qb.RequestTypeScalar
case PanelKindHistogram:
return qb.RequestTypeDistribution
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 collectV1QueryEnvelopes(widget map[string]any) ([]map[string]any, telemetrytypes.Signal) {
queryMap, ok := widget["query"].(map[string]any)
if !ok {
return nil, telemetrytypes.Signal{}
}
queryType, _ := queryMap["queryType"].(string)
switch queryType {
case "promql":
var out []map[string]any
for _, q := range readSliceOfMaps(queryMap["promql"]) {
out = append(out, promQLEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "clickhouse_sql":
var out []map[string]any
for _, q := range readSliceOfMaps(queryMap["clickhouse_sql"]) {
out = append(out, clickhouseEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "builder":
builder, _ := queryMap["builder"].(map[string]any)
if builder == nil {
return nil, telemetrytypes.Signal{}
}
var out []map[string]any
var signal telemetrytypes.Signal
wrap := transition.NewMigrateCommon(nil)
for _, q := range readSliceOfMaps(builder["queryData"]) {
name := valueAt[string](q, "queryName")
out = append(out, wrap.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
if signal.IsZero() {
signal = signalFromDataSource(q["dataSource"])
}
}
for _, f := range readSliceOfMaps(builder["queryFormulas"]) {
name := valueAt[string](f, "queryName")
out = append(out, wrap.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue())))
}
for _, op := range readSliceOfMaps(builder["queryTraceOperator"]) {
name := valueAt[string](op, "queryName")
out = append(out, wrap.WrapInV5Envelope(name, op, string(qb.QueryTypeTraceOperator.StringValue())))
}
return out, signal
}
return nil, telemetrytypes.Signal{}
}
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 an envelope whose type is
// promql/clickhouse_sql. Builder envelopes always fall through to Composite so
// composite-only panel kinds (TimeSeries/BarChart/etc.) get uniform queries.
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType) *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},
},
}
}
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
}
func signalFromDataSource(raw any) telemetrytypes.Signal {
s, _ := raw.(string)
switch s {
case "traces":
return telemetrytypes.SignalTraces
case "logs":
return telemetrytypes.SignalLogs
case "metrics":
return telemetrytypes.SignalMetrics
}
return telemetrytypes.Signal{}
}

View File

@@ -0,0 +1,110 @@
package dashboardtypes
import (
"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 following the rules in pkg/types/migration.md
// (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 (e.g. spaces) are left intact:
// such tags fail tag validation downstream and are logged for the customer to
// fix, per the migration's dry-run plan.
// 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 convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
rawSlice, ok := raw.([]any)
if !ok {
return nil
}
seen := make(map[string]struct{}, len(rawSlice))
out := make([]*tagtypes.Tag, 0, len(rawSlice))
for _, item := range rawSlice {
s, ok := item.(string)
if !ok {
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{}{}
out = append(out, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
}
return out
}
// normalizeV1Tag derives a (key, value) pair from one v1 tag string per the
// ordered rules in pkg/types/migration.md. ok is false when the string has no
// usable content (empty after trimming, or a bare separator).
func normalizeV1Tag(s string) (string, string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", "", false
}
var key, value string
var ok bool
switch {
case strings.Contains(s, ":"):
key, value, ok = splitV1Tag(s, ":")
// Only the first ":" separates key from value; collapse the rest.
value = strings.ReplaceAll(value, ":", "_")
case strings.Contains(s, "/"):
key, value, ok = splitV1Tag(s, "/")
default:
key, value, ok = defaultV1TagKey, s, true
}
if !ok {
return "", "", false
}
// Reserved-key collision: prefix with "_" so the list-query DSL stays
// unambiguous. Matched case-insensitively against the DSL column names.
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(key))]; reserved {
key = "_" + key
}
// Stored tags must never contain "/" (input validation forbids it).
key = strings.ReplaceAll(key, "/", "_")
value = strings.ReplaceAll(value, "/", "_")
return key, value, true
}
// splitV1Tag splits s at the first occurrence of sep, trimming each side. An
// empty side collapses to the default key with the non-empty side as the value;
// if both sides are empty (a bare separator) ok is false.
func splitV1Tag(s, sep string) (string, string, bool) {
left, right, _ := strings.Cut(s, sep)
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
switch {
case left == "" && right == "":
return "", "", false
case left == "":
return defaultV1TagKey, right, true
case right == "":
return defaultV1TagKey, left, true
default:
return left, right, true
}
}

View File

@@ -0,0 +1,766 @@
package dashboardtypes
import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/dashboard"
"github.com/perses/spec/go/dashboard/variable"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertV1TagsForOrg(t *testing.T) {
orgID := valuer.GenerateUUID()
type kv struct{ key, value string }
cases := []struct {
scenario string
rawTags any
expectedTags []kv
}{
{
scenario: "no separator uses the default key",
rawTags: []any{"apm", "latency", "throughput"},
expectedTags: []kv{{"tag", "apm"}, {"tag", "latency"}, {"tag", "throughput"}},
},
{
scenario: "colon splits into key and value",
rawTags: []any{"env:prod", "team : backend"},
expectedTags: []kv{{"env", "prod"}, {"team", "backend"}},
},
{
scenario: "slash splits into key and value when no colon present",
rawTags: []any{"team/backend"},
expectedTags: []kv{{"team", "backend"}},
},
{
scenario: "colon takes precedence over slash and slash is scrubbed",
rawTags: []any{"team/eng:prod", "team/eng:my/path"},
expectedTags: []kv{{"team_eng", "prod"}, {"team_eng", "my_path"}},
},
{
scenario: "empty left side falls back to the default key",
rawTags: []any{":prod"},
expectedTags: []kv{{"tag", "prod"}},
},
{
scenario: "empty right side keeps the left side as the value",
rawTags: []any{"env:"},
expectedTags: []kv{{"tag", "env"}},
},
{
scenario: "extra colons in the value collapse to underscores",
rawTags: []any{"a:b:c"},
expectedTags: []kv{{"a", "b_c"}},
},
{
scenario: "extra slashes in the value are scrubbed",
rawTags: []any{"a/b/c"},
expectedTags: []kv{{"a", "b_c"}},
},
{
scenario: "reserved key gets an underscore prefix",
rawTags: []any{"name:foo", "Source:bar"},
expectedTags: []kv{{"_name", "foo"}, {"_Source", "bar"}},
},
{
scenario: "drops empty, whitespace-only, and bare-separator entries",
rawTags: []any{"", " ", ":", "/", "apm"},
expectedTags: []kv{{"tag", "apm"}},
},
{
scenario: "dedupes case-insensitive duplicates, first casing wins",
rawTags: []any{"Env:Prod", "env:PROD"},
expectedTags: []kv{{"Env", "Prod"}},
},
{
scenario: "returns nil for missing tags field",
rawTags: nil,
expectedTags: nil,
},
{
scenario: "ignores non-string elements",
rawTags: []any{"apm", 42, true, "logs"},
expectedTags: []kv{{"tag", "apm"}, {"tag", "logs"}},
},
}
for _, tc := range cases {
t.Run(tc.scenario, func(t *testing.T) {
tags := convertV1TagsForOrg(orgID, tc.rawTags)
require.Len(t, tags, len(tc.expectedTags))
for i, expected := range tc.expectedTags {
assert.Equal(t, expected.key, tags[i].Key)
assert.Equal(t, expected.value, tags[i].Value)
assert.Equal(t, orgID, tags[i].OrgID)
assert.Equal(t, coretypes.KindDashboard, tags[i].Kind)
}
})
}
}
func TestConvertGraphWidgetToTimeSeriesPanel(t *testing.T) {
widget := map[string]any{
"id": "widget-1",
"panelTypes": "graph",
"title": "Request rate",
"description": "RPS over time",
"timePreferance": "LAST_1_HR",
"fillSpans": true,
"yAxisUnit": "reqps",
"decimalPrecision": float64(3),
"lineInterpolation": "linear",
"lineStyle": "dashed",
"fillMode": "gradient",
"showPoints": true,
"spanGaps": float64(60),
"softMin": float64(0),
"softMax": float64(100),
"isLogScale": true,
"legendPosition": "right",
"customLegendColors": map[string]any{"A": "#ff0000", "B": "#00ff00"},
"thresholds": []any{
map[string]any{
"thresholdValue": float64(90),
"thresholdUnit": "reqps",
"thresholdColor": "#ff0000",
"thresholdLabel": "high",
},
map[string]any{
"thresholdValue": float64(50),
"thresholdColor": "", // missing — must be dropped
"thresholdLabel": "missing-color",
},
},
}
panel := convertGraphWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindPanel, panel.Kind)
assert.Equal(t, "Request rate", panel.Spec.Display.Name)
assert.Equal(t, "RPS over time", panel.Spec.Display.Description)
assert.Equal(t, PanelKindTimeSeries, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.True(t, ok, "panel plugin spec should be *TimeSeriesPanelSpec")
assert.Equal(t, TimePreferenceLast1Hr, spec.Visualization.TimePreference)
assert.True(t, spec.Visualization.FillSpans)
assert.Equal(t, "reqps", spec.Formatting.Unit)
assert.Equal(t, PrecisionOption3, spec.Formatting.DecimalPrecision)
assert.Equal(t, LineInterpolationLinear, spec.ChartAppearance.LineInterpolation)
assert.True(t, spec.ChartAppearance.ShowPoints)
assert.Equal(t, LineStyleDashed, spec.ChartAppearance.LineStyle)
assert.Equal(t, FillModeGradient, spec.ChartAppearance.FillMode)
assert.True(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow)
assert.Equal(t, "1m0s", spec.ChartAppearance.SpanGaps.FillLessThan.StringValue())
require.NotNil(t, spec.Axes.SoftMin)
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
require.NotNil(t, spec.Axes.SoftMax)
assert.Equal(t, float64(100), *spec.Axes.SoftMax)
assert.True(t, spec.Axes.IsLogScale)
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
assert.Equal(t, map[string]string{"A": "#ff0000", "B": "#00ff00"}, spec.Legend.CustomColors)
require.Len(t, spec.Thresholds, 1, "threshold with missing color should be dropped")
assert.Equal(t, ThresholdWithLabel{Value: 90, Unit: "reqps", Color: "#ff0000", Label: "high"}, spec.Thresholds[0])
}
func TestConvertGraphWidgetDefaultsForMissingFields(t *testing.T) {
widget := map[string]any{
"id": "widget-1",
"panelTypes": "graph",
"title": "minimal",
}
panel := convertGraphWidget(widget)
require.NotNil(t, panel)
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.True(t, ok)
assert.Equal(t, TimePreferenceGlobalTime, spec.Visualization.TimePreference)
assert.Equal(t, PrecisionOption2, spec.Formatting.DecimalPrecision)
assert.Equal(t, LineInterpolationSpline, spec.ChartAppearance.LineInterpolation)
assert.Equal(t, LineStyleSolid, spec.ChartAppearance.LineStyle)
assert.Equal(t, FillModeSolid, spec.ChartAppearance.FillMode)
assert.Equal(t, LegendPositionBottom, spec.Legend.Position)
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow)
assert.Nil(t, spec.Axes.SoftMin)
assert.Nil(t, spec.Axes.SoftMax)
assert.Empty(t, spec.Thresholds)
}
func TestConvertV1ToV2HappyPath(t *testing.T) {
orgID := valuer.GenerateUUID()
storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
UserAuditable: types.UserAuditable{CreatedBy: "alice", UpdatedBy: "bob"},
OrgID: orgID,
Source: SourceUser,
Name: "apm-metrics",
Data: StorableDashboardData{
"title": "APM Metrics",
"description": "service overview",
"image": "data:image/png;base64,abc",
"tags": []any{"apm", "team:platform"},
"widgets": []any{
// section header — owned by the layout pass, not a panel
map[string]any{"id": "row-1", "panelTypes": "row", "title": "Overview"},
// graph widget → TimeSeries panel
map[string]any{
"id": "panel-1",
"panelTypes": "graph",
"title": "Latency",
},
// table widget → Table panel
map[string]any{"id": "panel-2", "panelTypes": "table"},
// widget with missing id — dropped
map[string]any{"panelTypes": "graph", "title": "no id"},
// unknown panel kind — silently dropped
map[string]any{"id": "panel-3", "panelTypes": "totally-new"},
},
},
}
dashboard, err := storable.ConvertV1ToV2()
require.NoError(t, err)
require.NotNil(t, dashboard)
assert.Equal(t, storable.ID, dashboard.ID)
assert.Equal(t, storable.OrgID, dashboard.OrgID)
assert.Equal(t, storable.Source, dashboard.Source)
assert.Equal(t, storable.Name, dashboard.Name)
assert.Equal(t, SchemaVersion, dashboard.SchemaVersion)
assert.Equal(t, "data:image/png;base64,abc", dashboard.Image)
assert.Equal(t, "APM Metrics", dashboard.Spec.Display.Name)
assert.Equal(t, "service overview", dashboard.Spec.Display.Description)
require.Len(t, dashboard.Tags, 2)
assert.Equal(t, "tag", dashboard.Tags[0].Key)
assert.Equal(t, "apm", dashboard.Tags[0].Value)
assert.Equal(t, "team", dashboard.Tags[1].Key)
assert.Equal(t, "platform", dashboard.Tags[1].Value)
require.Len(t, dashboard.Spec.Panels, 2, "graph and table map; row, no-id, and unknown kinds are dropped")
require.Contains(t, dashboard.Spec.Panels, "panel-1")
require.Contains(t, dashboard.Spec.Panels, "panel-2")
assert.Equal(t, PanelKindTimeSeries, dashboard.Spec.Panels["panel-1"].Spec.Plugin.Kind)
assert.Equal(t, PanelKindTable, dashboard.Spec.Panels["panel-2"].Spec.Plugin.Kind)
}
func TestConvertV1ToV2RejectsAlreadyV2(t *testing.T) {
storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: valuer.GenerateUUID(),
Source: SourceUser,
Name: "already-v2",
Data: StorableDashboardData{
"metadata": map[string]any{"schemaVersion": SchemaVersion},
"spec": map[string]any{},
},
}
dashboard, err := storable.ConvertV1ToV2()
assert.Nil(t, dashboard)
require.Error(t, err)
assert.Contains(t, err.Error(), "already in")
}
func TestSpanGapsMapping(t *testing.T) {
cases := []struct {
scenario string
rawSpanGaps any
expectedFillOnlyBelow bool
expectedFillLessThan string
}{
{scenario: "true spans every gap", rawSpanGaps: true, expectedFillOnlyBelow: false, expectedFillLessThan: "0s"},
{scenario: "false spans no gaps", rawSpanGaps: false, expectedFillOnlyBelow: true, expectedFillLessThan: "0s"},
{scenario: "number is seconds threshold", rawSpanGaps: float64(30), expectedFillOnlyBelow: true, expectedFillLessThan: "30s"},
{scenario: "missing defaults to span all", rawSpanGaps: nil, expectedFillOnlyBelow: false, expectedFillLessThan: "0s"},
}
for _, tc := range cases {
t.Run(tc.scenario, func(t *testing.T) {
got := mapV1SpanGaps(tc.rawSpanGaps)
assert.Equal(t, tc.expectedFillOnlyBelow, got.FillOnlyBelow)
assert.Equal(t, tc.expectedFillLessThan, got.FillLessThan.StringValue())
})
}
}
// ══════════════════════════════════════════════
// Other panel-kind converters
// ══════════════════════════════════════════════
func TestConvertBarWidgetToBarChartPanel(t *testing.T) {
widget := map[string]any{
"id": "bar-1",
"panelTypes": "bar",
"title": "Requests by status",
"fillSpans": true,
"stackedBarChart": true,
"yAxisUnit": "reqps",
"softMin": float64(0),
"isLogScale": true,
"legendPosition": "right",
}
panel := convertBarWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindBarChart, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*BarChartPanelSpec)
require.True(t, ok)
assert.True(t, spec.Visualization.FillSpans)
assert.True(t, spec.Visualization.StackedBarChart)
assert.Equal(t, "reqps", spec.Formatting.Unit)
require.NotNil(t, spec.Axes.SoftMin)
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
assert.True(t, spec.Axes.IsLogScale)
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
}
func TestConvertValueWidgetToNumberPanel(t *testing.T) {
widget := map[string]any{
"id": "val-1",
"panelTypes": "value",
"title": "Active services",
"yAxisUnit": "count",
"thresholds": []any{
map[string]any{
"thresholdValue": float64(100),
"thresholdOperator": ">=",
"thresholdColor": "#ff0000",
"thresholdFormat": "Background",
"thresholdUnit": "count",
},
map[string]any{
// missing color — must be dropped
"thresholdValue": float64(10),
},
},
}
panel := convertValueWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindNumber, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*NumberPanelSpec)
require.True(t, ok)
require.Len(t, spec.Thresholds, 1)
assert.Equal(t, float64(100), spec.Thresholds[0].Value)
assert.Equal(t, ComparisonOperatorAboveOrEqual, spec.Thresholds[0].Operator)
assert.Equal(t, "#ff0000", spec.Thresholds[0].Color)
assert.Equal(t, ThresholdFormatBackground, spec.Thresholds[0].Format)
}
func TestConvertTableWidgetToTablePanel(t *testing.T) {
widget := map[string]any{
"id": "tbl-1",
"panelTypes": "table",
"title": "Top services",
"columnUnits": map[string]any{
"latency": "ms",
"errors": "count",
},
"thresholds": []any{
map[string]any{
"thresholdValue": float64(500),
"thresholdColor": "#ff0000",
"thresholdTableOptions": "latency",
"thresholdOperator": ">",
},
map[string]any{
// missing columnName — dropped
"thresholdValue": float64(1),
"thresholdColor": "#00ff00",
},
},
}
panel := convertTableWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindTable, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*TablePanelSpec)
require.True(t, ok)
assert.Equal(t, "ms", spec.Formatting.ColumnUnits["latency"])
assert.Equal(t, "count", spec.Formatting.ColumnUnits["errors"])
require.Len(t, spec.Thresholds, 1)
assert.Equal(t, "latency", spec.Thresholds[0].ColumnName)
assert.Equal(t, ComparisonOperatorAbove, spec.Thresholds[0].Operator)
}
func TestConvertPieWidgetToPieChartPanel(t *testing.T) {
widget := map[string]any{
"id": "pie-1",
"panelTypes": "pie",
"title": "Share",
"legendPosition": "right",
}
panel := convertPieWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindPieChart, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*PieChartPanelSpec)
require.True(t, ok)
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
}
func TestConvertHistogramWidget(t *testing.T) {
bucketCount := float64(20)
widget := map[string]any{
"id": "hist-1",
"panelTypes": "histogram",
"title": "Latency distribution",
"bucketCount": bucketCount,
"mergeAllActiveQueries": true,
}
panel := convertHistogramWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindHistogram, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*HistogramPanelSpec)
require.True(t, ok)
require.NotNil(t, spec.HistogramBuckets.BucketCount)
assert.Equal(t, bucketCount, *spec.HistogramBuckets.BucketCount)
assert.Nil(t, spec.HistogramBuckets.BucketWidth)
assert.True(t, spec.HistogramBuckets.MergeAllActiveQueries)
}
func TestConvertListWidget(t *testing.T) {
widget := map[string]any{
"id": "list-1",
"panelTypes": "list",
"title": "Recent logs",
"selectedLogFields": []any{
map[string]any{"name": "body", "fieldDataType": "string", "fieldContext": "log"},
map[string]any{"name": "severity_text", "fieldDataType": "string", "fieldContext": "log"},
},
}
panel := convertListWidget(widget)
require.NotNil(t, panel)
assert.Equal(t, PanelKindList, panel.Spec.Plugin.Kind)
spec, ok := panel.Spec.Plugin.Spec.(*ListPanelSpec)
require.True(t, ok)
require.Len(t, spec.SelectFields, 2)
assert.Equal(t, "body", spec.SelectFields[0].Name)
}
// ══════════════════════════════════════════════
// Query translation
// ══════════════════════════════════════════════
func TestConvertV1WidgetQuerySinglePromQL(t *testing.T) {
widget := map[string]any{
"id": "p-1",
"panelTypes": "graph",
"query": map[string]any{
"queryType": "promql",
"promql": []any{
map[string]any{"name": "A", "query": "up", "legend": "{{job}}"},
},
},
}
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
require.Len(t, queries, 1)
assert.Equal(t, qb.RequestTypeTimeSeries, queries[0].Kind)
assert.Equal(t, QueryKindPromQL, queries[0].Spec.Plugin.Kind)
prom, ok := queries[0].Spec.Plugin.Spec.(*qb.PromQuery)
require.True(t, ok)
assert.Equal(t, "A", prom.Name)
assert.Equal(t, "up", prom.Query)
assert.Equal(t, "{{job}}", prom.Legend)
}
func TestConvertV1WidgetQuerySingleClickHouse(t *testing.T) {
widget := map[string]any{
"id": "c-1",
"panelTypes": "table",
"query": map[string]any{
"queryType": "clickhouse_sql",
"clickhouse_sql": []any{
map[string]any{"name": "Q", "query": "SELECT 1", "legend": "x"},
},
},
}
queries := convertV1WidgetQuery(widget, PanelKindTable)
require.Len(t, queries, 1)
assert.Equal(t, qb.RequestTypeScalar, queries[0].Kind)
assert.Equal(t, QueryKindClickHouseSQL, queries[0].Spec.Plugin.Kind)
ch, ok := queries[0].Spec.Plugin.Spec.(*qb.ClickHouseQuery)
require.True(t, ok)
assert.Equal(t, "Q", ch.Name)
assert.Equal(t, "SELECT 1", ch.Query)
}
func TestConvertV1WidgetQueryMultipleBuilderWrapsInComposite(t *testing.T) {
widget := map[string]any{
"id": "b-1",
"panelTypes": "graph",
"query": map[string]any{
"queryType": "builder",
"builder": map[string]any{
"queryData": []any{
map[string]any{
"queryName": "A",
"expression": "A",
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "signoz_calls_total"}},
},
map[string]any{
"queryName": "B",
"expression": "B",
"dataSource": "logs",
"aggregations": []any{map[string]any{"expression": "count()"}},
},
},
"queryFormulas": []any{
map[string]any{"queryName": "F1", "expression": "A + B"},
},
},
},
}
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
require.Len(t, queries, 1)
assert.Equal(t, qb.RequestTypeTimeSeries, queries[0].Kind)
assert.Equal(t, QueryKindComposite, queries[0].Spec.Plugin.Kind)
composite, ok := queries[0].Spec.Plugin.Spec.(*CompositeQuerySpec)
require.True(t, ok)
require.Len(t, composite.Queries, 3)
assert.Equal(t, qb.QueryTypeBuilder, composite.Queries[0].Type)
assert.Equal(t, qb.QueryTypeBuilder, composite.Queries[1].Type)
assert.Equal(t, qb.QueryTypeFormula, composite.Queries[2].Type)
}
func TestConvertV1WidgetQueryListPanelUsesBuilderDirectly(t *testing.T) {
widget := map[string]any{
"id": "l-1",
"panelTypes": "list",
"query": map[string]any{
"queryType": "builder",
"builder": map[string]any{
"queryData": []any{
map[string]any{
"queryName": "A",
"expression": "A",
"dataSource": "logs",
},
},
},
},
}
queries := convertV1WidgetQuery(widget, PanelKindList)
require.Len(t, queries, 1)
assert.Equal(t, qb.RequestTypeRaw, queries[0].Kind)
assert.Equal(t, QueryKindBuilder, queries[0].Spec.Plugin.Kind)
wrapper, ok := queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec)
require.True(t, ok)
spec, ok := wrapper.Spec.(qb.QueryBuilderQuery[qb.LogAggregation])
require.True(t, ok, "list builder query should dispatch to LogAggregation, got %T", wrapper.Spec)
assert.Equal(t, "A", spec.Name)
}
func TestConvertV1WidgetQueryNoQuery(t *testing.T) {
widget := map[string]any{"id": "x", "panelTypes": "graph"}
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
assert.Nil(t, queries)
}
// ══════════════════════════════════════════════
// Layouts and sections
// ══════════════════════════════════════════════
func TestConvertV1LayoutsRootOnly(t *testing.T) {
data := StorableDashboardData{
"layout": []any{
map[string]any{"i": "p-1", "x": float64(0), "y": float64(0), "w": float64(6), "h": float64(6)},
map[string]any{"i": "p-2", "x": float64(6), "y": float64(0), "w": float64(6), "h": float64(6)},
},
"widgets": []any{
map[string]any{"id": "p-1", "panelTypes": "graph"},
map[string]any{"id": "p-2", "panelTypes": "graph"},
},
}
layouts := convertV1Layouts(data)
require.Len(t, layouts, 1)
assert.Equal(t, dashboard.KindGridLayout, layouts[0].Kind)
spec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
require.True(t, ok)
require.Len(t, spec.Items, 2)
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
assert.Equal(t, 6, spec.Items[1].Width)
assert.Nil(t, spec.Display, "root-only grid should have no display block")
}
func TestConvertV1LayoutsWithCollapsedSection(t *testing.T) {
data := StorableDashboardData{
"widgets": []any{
map[string]any{"id": "row-1", "panelTypes": "row", "title": "Latency"},
map[string]any{"id": "p-1", "panelTypes": "graph"},
map[string]any{"id": "p-2", "panelTypes": "graph"},
},
"layout": []any{
map[string]any{"i": "row-1", "x": float64(0), "y": float64(0), "w": float64(12), "h": float64(1)},
map[string]any{"i": "p-1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
map[string]any{"i": "p-2", "x": float64(0), "y": float64(7), "w": float64(6), "h": float64(6)},
},
"panelMap": map[string]any{
"row-1": map[string]any{
"collapsed": true,
"widgets": []any{
map[string]any{"i": "p-1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
},
},
},
}
layouts := convertV1Layouts(data)
require.Len(t, layouts, 2, "one root grid (p-2) + one section grid (row-1 with p-1)")
rootSpec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
require.True(t, ok)
require.Len(t, rootSpec.Items, 1)
assert.Equal(t, "#/spec/panels/p-2", rootSpec.Items[0].Content.Ref)
assert.Nil(t, rootSpec.Display)
sectionSpec, ok := layouts[1].Spec.(*dashboard.GridLayoutSpec)
require.True(t, ok)
require.NotNil(t, sectionSpec.Display)
assert.Equal(t, "Latency", sectionSpec.Display.Title)
require.NotNil(t, sectionSpec.Display.Collapse)
assert.False(t, sectionSpec.Display.Collapse.Open, "collapsed=true → open=false")
require.Len(t, sectionSpec.Items, 1)
assert.Equal(t, "#/spec/panels/p-1", sectionSpec.Items[0].Content.Ref)
}
func TestConvertV1LayoutsEmpty(t *testing.T) {
assert.Nil(t, convertV1Layouts(StorableDashboardData{}))
}
// ══════════════════════════════════════════════
// Variables
// ══════════════════════════════════════════════
func TestConvertV1VariablesAllTypes(t *testing.T) {
raw := map[string]any{
"u-1": map[string]any{
"name": "service.name",
"description": "the service",
"type": "QUERY",
"queryValue": "SELECT name FROM s",
"multiSelect": true,
"showALLOption": true,
"sort": "ASC",
"order": float64(1),
},
"u-2": map[string]any{
"name": "env",
"type": "CUSTOM",
"customValue": "prod,staging,dev",
"order": float64(2),
"selectedValue": "prod",
},
"u-3": map[string]any{
"name": "deployment.environment",
"type": "DYNAMIC",
"dynamicVariablesAttribute": "deployment.environment",
"dynamicVariablesSource": "traces",
"order": float64(0),
},
"u-4": map[string]any{
"name": "freetext",
"type": "TEXTBOX",
"textboxValue": "hello",
"order": float64(3),
},
}
vars := convertV1Variables(raw)
require.Len(t, vars, 4)
// Ordered by `order` ascending: u-3 (0), u-1 (1), u-2 (2), u-4 (3)
assert.Equal(t, variable.KindList, vars[0].Kind)
dyn, ok := vars[0].Spec.(*ListVariableSpec)
require.True(t, ok)
assert.Equal(t, "deployment.environment", dyn.Name)
assert.Equal(t, VariableKindDynamic, dyn.Plugin.Kind)
q, ok := vars[1].Spec.(*ListVariableSpec)
require.True(t, ok)
assert.Equal(t, "service.name", q.Name)
assert.Equal(t, VariableKindQuery, q.Plugin.Kind)
assert.True(t, q.AllowMultiple)
assert.True(t, q.AllowAllValue)
require.NotNil(t, q.Sort)
assert.Equal(t, variable.SortAlphabeticalAsc, *q.Sort)
c, ok := vars[2].Spec.(*ListVariableSpec)
require.True(t, ok)
assert.Equal(t, "env", c.Name)
assert.Equal(t, VariableKindCustom, c.Plugin.Kind)
require.NotNil(t, c.DefaultValue)
assert.Equal(t, "prod", c.DefaultValue.SingleValue)
assert.Equal(t, variable.KindText, vars[3].Kind)
text, ok := vars[3].Spec.(*dashboard.TextVariableSpec)
require.True(t, ok)
assert.Equal(t, "freetext", text.Name)
assert.Equal(t, "hello", text.Value)
}
func TestConvertV1VariablesSkipsUnnamedAndUnknownTypes(t *testing.T) {
raw := map[string]any{
"u-1": map[string]any{"name": "", "type": "QUERY"},
"u-2": map[string]any{"name": "ok", "type": "WHATEVER"},
"u-3": map[string]any{"name": "good", "type": "CUSTOM", "customValue": "a"},
}
vars := convertV1Variables(raw)
require.Len(t, vars, 1)
spec := vars[0].Spec.(*ListVariableSpec)
assert.Equal(t, "good", spec.Name)
}
func TestConvertV1VariablesDefaultFromSelectedSlice(t *testing.T) {
raw := map[string]any{
"u-1": map[string]any{
"name": "svc",
"type": "QUERY",
"queryValue": "SELECT 1",
"selectedValue": []any{"foo", "", "bar"},
},
}
vars := convertV1Variables(raw)
require.Len(t, vars, 1)
spec := vars[0].Spec.(*ListVariableSpec)
require.NotNil(t, spec.DefaultValue)
assert.Equal(t, []string{"foo", "bar"}, spec.DefaultValue.SliceValues)
}

View File

@@ -0,0 +1,170 @@
package dashboardtypes
import (
"sort"
"github.com/perses/spec/go/dashboard"
"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 convertV1Variables(raw any) []Variable {
rawMap, ok := raw.(map[string]any)
if !ok || len(rawMap) == 0 {
return nil
}
type ordered struct {
key string
val map[string]any
ord float64
}
entries := make([]ordered, 0, len(rawMap))
for key, value := range rawMap {
m, ok := value.(map[string]any)
if !ok {
continue
}
ord, _ := m["order"].(float64)
entries = append(entries, ordered{key: key, val: m, ord: ord})
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].ord != entries[j].ord {
return entries[i].ord < entries[j].ord
}
return entries[i].key < entries[j].key
})
out := make([]Variable, 0, len(entries))
for _, e := range entries {
v, ok := convertV1Variable(e.val)
if !ok {
continue
}
out = append(out, v)
}
return out
}
func convertV1Variable(v map[string]any) (Variable, bool) {
name, _ := v["name"].(string)
if name == "" {
return Variable{}, false
}
description, _ := v["description"].(string)
kind, _ := v["type"].(string)
switch kind {
case "TEXTBOX":
value, _ := v["textboxValue"].(string)
spec := &dashboard.TextVariableSpec{
TextSpec: variable.TextSpec{
Display: &variable.Display{Name: name, Description: description},
Value: value,
},
Name: name,
}
return Variable{Kind: variable.KindText, Spec: spec}, true
case "QUERY", "CUSTOM", "DYNAMIC":
listSpec := &ListVariableSpec{
Display: Display{Name: name, Description: description},
AllowAllValue: valueAt[bool](v, "showALLOption"),
AllowMultiple: valueAt[bool](v, "multiSelect"),
CustomAllValue: valueAt[string](v, "customAllValue"),
CapturingRegexp: valueAt[string](v, "capturingRegexp"),
Sort: mapV1Sort(v["sort"]),
Plugin: variablePluginFor(kind, v),
Name: name,
}
if dv := mapV1VariableDefault(v); dv != nil {
listSpec.DefaultValue = dv
}
return Variable{Kind: variable.KindList, Spec: listSpec}, true
}
return Variable{}, false
}
func variablePluginFor(kind string, v map[string]any) VariablePlugin {
switch kind {
case "QUERY":
return VariablePlugin{
Kind: VariableKindQuery,
Spec: &QueryVariableSpec{QueryValue: valueAt[string](v, "queryValue")},
}
case "CUSTOM":
return VariablePlugin{
Kind: VariableKindCustom,
Spec: &CustomVariableSpec{CustomValue: valueAt[string](v, "customValue")},
}
case "DYNAMIC":
spec := &DynamicVariableSpec{Name: valueAt[string](v, "dynamicVariablesAttribute")}
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
spec.Signal = signal
}
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
}
return VariablePlugin{}
}
func mapV1VariableDefault(v map[string]any) *variable.DefaultValue {
if raw, ok := v["selectedValue"]; ok {
return defaultValueFromAny(raw)
}
if raw, ok := v["defaultValue"]; ok {
return defaultValueFromAny(raw)
}
return nil
}
func defaultValueFromAny(raw any) *variable.DefaultValue {
switch v := raw.(type) {
case string:
if v == "" {
return nil
}
return &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
}
return &variable.DefaultValue{SliceValues: values}
}
return nil
}
func mapV1Sort(raw any) *variable.Sort {
s, _ := raw.(string)
var sort variable.Sort
switch s {
case "ASC":
sort = variable.SortAlphabeticalAsc
case "DESC":
sort = variable.SortAlphabeticalDesc
case "DISABLED", "":
return nil // SortNone is the implicit default
default:
return nil
}
return &sort
}

66
pkg/types/migration.md Normal file
View File

@@ -0,0 +1,66 @@
### Phases
1. **Pre-migration (dev)**: new tables `tag`, `tag_relations`, `pinned_dashboard`,  `dashboard_view`
2. **Validation**: run the migration script against a few prod snapshots locally. Verify counts match, spot-check shapes, time the run to estimate downtime.
3. **Dry-run in cloud prod (cloud only).** Ship a build that runs the migration script in read-only
mode against live prod data. Whenever the v1 get API is called for a dashboard, we dry-run the migration script for it in an async process. If there is a failure, schema mismatches, tag normalization rejections, etc, it is logged. Reach out to affected customers to fix their dashboards before the real migration. Re-run closer to migration day to confirm resolution.
4. **Migration deploy**: script runs, FF flips on. Integration dashboards materialized in the `dashboard` table using an internal system account with `Locked = true`.
5. **Post-migration**: v1 APIs deprecated but still respond.
#### **Rejected idea: dry run in a background job**
In the above plan, we only check the dashboards that the users access. However, that should be enough to cover enough dashboards to be able to find out possible issues. The extra effort of a background job doesn't have enough ROI.
### What gets migrated
Existing v1 dashboards → full v2 data shape (tags extracted from `data.tags` into `tag` and `tag_relations`; the field is removed from the blob). Integration dashboards → materialized rows. Pinned dashboards and saved views start empty.
### Tag normalization (v1 strings → v2 tag rows)
Each v1 dashboard `data.tags` is `[]string`. For every string `s`, derive `(key, value)`.
**Order of rules:**
1. **Trim** leading/trailing whitespace from `s`. If empty after trim → **skip silently** (log dashboard id + index, continue).
2. **If `s` contains `:`** → split at the **first** `:`. Let `k` = left side, `v` = right side.
- If `k` is empty (input was `:val`) → `key = "tag"`, `value = val`.
- If `v` is empty (input was `key:`) → `key = "tag"`, `value = k` (the literal left side becomes the value).
- Otherwise → `key = k`, `value = v`.
- Other `:` are replaced with `_`.
3. **Else if `s` contains `/`** → split at the **first** `/`. Let `k` = left side, `v` = right side.
- Same empty-side handling: empty left → `key="tag", value=v`; empty right → `key="tag", value=k`. Otherwise → `key=k, value=v`.
4. **Else** (no separator) → `key = "tag"`, `value = s`.
5. Reserved-key collision. After steps 24, if the resulting key (case-insensitively) matches a reserved DSL key (name, description, created_at, updated_at, created_by, locked, public), prefix it with _ (e.g. name → _name). Silent — extremely unlikely in practice, but the rename keeps the dashboard alive without ambiguating the query DSL.
6. **`/` scrub.** Output tags must never contain `/` (input validation forbids it). After the above steps, replace any remaining `/` in `key` and `value` with `_`:
- `a/b/c` → step 3 splits at first `/` → `key="a", value="b/c"` → after scrub → `key="a", value="b_c"`
- `team/eng:prod` → step 2 splits at `:` → `key="team/eng", value="prod"` → after scrub → `key="team_eng", value="prod"`
- `team/eng:my/path` → step 2 → `key="team/eng", value="my/path"` → scrub → `key="team_eng", value="my_path"`
Trailing/leading whitespace within `key` and `value` after split is also trimmed; if either side becomes empty after that, apply the empty-side rules above. If both sides are effectively empty (e.g. input was `:` or `/`), skip silently.
**Case-collision dedup:**
Multiple v1 strings can normalize to the same `(LOWER(key), LOWER(value))` across an org (e.g. `Env:Prod` and `env:PROD`). The functional unique index ensures only one row exists. Display casing is taken from the variant on the dashboard with the **earliest `created_at`** (ties broken by `dashboard.id`) — same rule as the previous spec, just applied to `(key, value)` instead of `name`.
**Tag relations:**
After tag rows are upserted, build `tag_relations` from each (dashboard, tag-id-after-dedup) pair. `ON CONFLICT` clause in the query makes this idempotent.
### Script properties
- Per-dashboard transactional. One failure logs the dashboard id and continues.
- Idempotent: `ON CONFLICT DO NOTHING` on tag and tag_relations upserts; dashboards already in v2 shape are skipped.
- Progress logged every N dashboards; final summary includes totals and failure list.
### Rollback
Forward-only — no v2→v1 reverse script. The FF is the kill-switch pre-frontend-cutover. After cutover, rollback = another deploy with the fix.
### What about dashboards that fail to migrate after all this?
In Get API (v2) there will be a check on the dashboard fetched.
- `v2` → normal flow.
- `v1` → return `422 Unprocessable Entity`.
The deprecated v1 APIs will still exist, so if any support ticket comes, we can check via the v1 API and see whats wrong.