Dashboard Migrations: V32 no-op migration + V31 LabelsToFields-Merge Migration (#107947)

* Dashboard Migrations: V31 LabelsToFields-Merge Migration

* Dashboard Migrations: V32 No-op migration
This commit is contained in:
Ivan Ortega Alba
2025-07-15 15:28:48 +00:00
committed by GitHub
parent da0f1d7b18
commit 14cb6aea92
9 changed files with 1191 additions and 1 deletions
@@ -5,7 +5,7 @@ import (
)
const (
MIN_VERSION = 32
MIN_VERSION = 30
LATEST_VERSION = 41
)
@@ -26,6 +26,8 @@ type DataSourceInfoProvider interface {
func GetMigrations(dsInfoProvider DataSourceInfoProvider) map[int]SchemaVersionMigrationFunc {
return map[int]SchemaVersionMigrationFunc{
31: V31,
32: V32,
33: V33(dsInfoProvider),
34: V34,
35: V35,
@@ -0,0 +1,132 @@
package schemaversion
// V31 adds a merge transformer after any labelsToFields transformer in panel transformations.
//
// This migration addresses data processing workflow optimization by automatically inserting
// a merge transformation after the labelsToFields transformation. When the labelsToFields
// transformer converts time series labels to individual fields, it can result in multiple
// data frames that need to be consolidated for proper visualization and analysis.
//
// The migration works by:
// 1. Iterating through all panels in the dashboard, including nested panels in collapsed rows
// 2. Examining the transformations array within each panel
// 3. Identifying transformations with id 'labelsToFields'
// 4. Inserting a merge transformation with empty options immediately after each labelsToFields transformation
// 5. Preserving the original labelsToFields options (mode, keepLabels, valueLabel, etc.) without modification
// 6. Using empty options for merge transformations to enable optimal default consolidation behavior
// 7. Preserving the original order and configuration of all other transformations
//
// The migration handles complex scenarios including:
// - Multiple labelsToFields transformations within a single panel
// - Panels with mixed transformation types
// - Nested panels within collapsed row panels
// - Panels with no transformations (left unchanged)
// - Panels with transformations but no labelsToFields (left unchanged)
//
// Example transformation:
//
// Before migration:
//
// panel: {
// "transformations": [
// { "id": "organize", "options": {} },
// { "id": "labelsToFields", "options": {} },
// { "id": "calculateField", "options": {} },
// { "id": "labelsToFields", "options": { "mode": "rows", "keepLabels": ["job", "instance"] } },
// ]
// }
//
// After migration:
//
// panel: {
// "transformations": [
// { "id": "organize", "options": {} },
// { "id": "labelsToFields", "options": {} },
// { "id": "merge", "options": {} },
// { "id": "calculateField", "options": {} },
// { "id": "labelsToFields", "options": { "mode": "rows", "keepLabels": ["job", "instance"] } },
// { "id": "merge", "options": {} }
// ]
// }
func V31(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(31)
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
// Process all panels, including nested ones
processPanelsV31(panels)
return nil
}
// processPanelsV31 recursively processes panels, including nested panels within rows
func processPanelsV31(panels []interface{}) {
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
// Process nested panels if this is a row panel
if p["type"] == "row" {
nestedPanels, hasNested := p["panels"].([]interface{})
if !hasNested {
continue
}
processPanelsV31(nestedPanels)
continue
}
transformations, ok := p["transformations"].([]interface{})
if !ok {
continue
}
// Check if we have any labelsToFields transformations
hasLabelsToFields := false
for _, transformation := range transformations {
t, ok := transformation.(map[string]interface{})
if !ok {
continue
}
if t["id"] == "labelsToFields" {
hasLabelsToFields = true
break
}
}
if !hasLabelsToFields {
continue
}
// Create new transformations array with merge transformations added
newTransformations := []interface{}{}
for _, transformation := range transformations {
t, ok := transformation.(map[string]interface{})
if !ok {
newTransformations = append(newTransformations, transformation)
continue
}
// Add the current transformation (preserving all original options)
newTransformations = append(newTransformations, transformation)
// If this is a labelsToFields transformation, add a merge transformation after it
// with empty options to enable optimal default consolidation behavior
if t["id"] == "labelsToFields" {
mergeTransformation := map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{},
}
newTransformations = append(newTransformations, mergeTransformation)
}
}
// Update the panel with the new transformations
p["transformations"] = newTransformations
}
}
@@ -0,0 +1,336 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV31(t *testing.T) {
tests := []migrationTestCase{
{
name: "panel with basic labelsToFields transformation gets merge transformation added",
input: map[string]interface{}{
"title": "V31 LabelsToFields Migration Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with basic labelsToFields",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V31 LabelsToFields Migration Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with basic labelsToFields",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{},
},
},
},
},
},
},
{
name: "panel with labelsToFields options preserved during migration",
input: map[string]interface{}{
"title": "V31 LabelsToFields Options Preservation Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with labelsToFields options",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{
"mode": "rows",
"keepLabels": []interface{}{"job", "instance"},
"valueLabel": "value",
},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V31 LabelsToFields Options Preservation Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with labelsToFields options",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{
"mode": "rows",
"keepLabels": []interface{}{"job", "instance"},
"valueLabel": "value",
},
},
map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{},
},
},
},
},
},
},
{
name: "panel with multiple labelsToFields transformations",
input: map[string]interface{}{
"title": "V31 Multiple LabelsToFields Migration Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with multiple labelsToFields",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "calculateField",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{
"mode": "rows",
},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V31 Multiple LabelsToFields Migration Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with multiple labelsToFields",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "calculateField",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{
"mode": "rows",
},
},
map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{},
},
},
},
},
},
},
{
name: "panel with no transformations remains unchanged",
input: map[string]interface{}{
"title": "V31 No Transformations Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with no transformations",
"id": 1,
},
},
},
expected: map[string]interface{}{
"title": "V31 No Transformations Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with no transformations",
"id": 1,
},
},
},
},
{
name: "panel with transformations but no labelsToFields remains unchanged",
input: map[string]interface{}{
"title": "V31 Other Transformations Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with other transformations",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "reduce",
"options": map[string]interface{}{},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V31 Other Transformations Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with other transformations",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "reduce",
"options": map[string]interface{}{},
},
},
},
},
},
},
{
name: "nested panels in row with labelsToFields transformation",
input: map[string]interface{}{
"title": "V31 Nested Panels Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "row",
"title": "Row with nested panels",
"id": 1,
"collapsed": false,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Nested panel with labelsToFields",
"id": 2,
"transformations": []interface{}{
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{},
},
},
},
map[string]interface{}{
"type": "timeseries",
"title": "Nested panel without labelsToFields",
"id": 3,
"transformations": []interface{}{
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{},
},
},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V31 Nested Panels Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "row",
"title": "Row with nested panels",
"id": 1,
"collapsed": false,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Nested panel with labelsToFields",
"id": 2,
"transformations": []interface{}{
map[string]interface{}{
"id": "labelsToFields",
"options": map[string]interface{}{},
},
map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{},
},
},
},
map[string]interface{}{
"type": "timeseries",
"title": "Nested panel without labelsToFields",
"id": 3,
"transformations": []interface{}{
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{},
},
},
},
},
},
},
},
},
{
name: "dashboard with no panels",
input: map[string]interface{}{
"title": "V31 No Panels Test Dashboard",
"schemaVersion": 30,
},
expected: map[string]interface{}{
"title": "V31 No Panels Test Dashboard",
"schemaVersion": 31,
},
},
}
runMigrationTests(t, tests, schemaversion.V31)
}
@@ -0,0 +1,27 @@
package schemaversion
// V32 is a no-op migration that serves as a placeholder for consistency.
//
// The migration performs no modifications to the dashboard structure and simply
// updates the schema version number.
// Example (no changes made):
//
// Before migration:
//
// dashboard: {
// "title": "My Dashboard",
// "schemaVersion": 31,
// "panels": [...]
// }
//
// After migration:
//
// dashboard: {
// "title": "My Dashboard",
// "schemaVersion": 32,
// "panels": [...] // unchanged
// }
func V32(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(32)
return nil
}
@@ -0,0 +1,38 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV32(t *testing.T) {
tests := []migrationTestCase{
{
name: "v32 no-op migration updates schema version only",
input: map[string]interface{}{
"title": "V32 No-Op Migration Test Dashboard",
"schemaVersion": 31,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel remains unchanged",
"id": 1,
},
},
},
expected: map[string]interface{}{
"title": "V32 No-Op Migration Test Dashboard",
"schemaVersion": 32,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel remains unchanged",
"id": 1,
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V32)
}
@@ -0,0 +1,126 @@
{
"title": "V31 LabelsToFields Merge Migration Test Dashboard",
"schemaVersion": 30,
"panels": [
{
"type": "timeseries",
"title": "Panel with basic labelsToFields transformation",
"id": 1,
"transformations": [
{
"id": "labelsToFields",
"options": {}
}
]
},
{
"type": "timeseries",
"title": "Panel with labelsToFields options preserved",
"id": 9,
"transformations": [
{
"id": "labelsToFields",
"options": {
"mode": "rows",
"keepLabels": ["job", "instance", "region"],
"valueLabel": "value"
}
}
]
},
{
"type": "timeseries",
"title": "Panel with multiple labelsToFields transformations",
"id": 2,
"transformations": [
{
"id": "organize",
"options": {}
},
{
"id": "labelsToFields",
"options": {}
},
{
"id": "calculateField",
"options": {}
},
{
"id": "labelsToFields",
"options": {
"mode": "rows"
}
}
]
},
{
"type": "timeseries",
"title": "Panel with no transformations",
"id": 3
},
{
"type": "timeseries",
"title": "Panel with other transformations only",
"id": 4,
"transformations": [
{
"id": "organize",
"options": {}
},
{
"id": "reduce",
"options": {}
}
]
},
{
"type": "row",
"title": "Row with nested panels",
"id": 5,
"collapsed": false,
"panels": [
{
"type": "timeseries",
"title": "Nested panel with labelsToFields",
"id": 6,
"transformations": [
{
"id": "labelsToFields",
"options": {}
}
]
},
{
"type": "timeseries",
"title": "Nested panel without labelsToFields",
"id": 7,
"transformations": [
{
"id": "organize",
"options": {}
}
]
}
]
},
{
"type": "timeseries",
"title": "Panel with labelsToFields and existing merge",
"id": 8,
"transformations": [
{
"id": "labelsToFields",
"options": {}
},
{
"id": "merge",
"options": {}
},
{
"id": "reduce",
"options": {}
}
]
}
]
}
@@ -0,0 +1,80 @@
{
"title": "V32 No-Op Migration Test Dashboard",
"schemaVersion": 31,
"panels": [
{
"type": "timeseries",
"title": "Panel with transformations remains unchanged",
"id": 1,
"transformations": [
{
"id": "labelsToFields",
"options": {
"mode": "rows",
"keepLabels": ["job", "instance"]
}
},
{
"id": "merge",
"options": {}
}
]
},
{
"type": "graph",
"title": "Graph panel remains unchanged",
"id": 2,
"yAxes": [
{
"show": true,
"min": null,
"max": null
}
]
},
{
"type": "row",
"title": "Row with nested panels",
"id": 3,
"collapsed": false,
"panels": [
{
"type": "stat",
"title": "Nested stat panel",
"id": 4,
"fieldConfig": {
"defaults": {
"unit": "bytes"
}
}
}
]
}
],
"templating": {
"list": [
{
"name": "environment",
"type": "query",
"datasource": "prometheus",
"options": []
}
]
},
"annotations": {
"list": [
{
"name": "Deployments",
"datasource": "grafana",
"enable": true
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
}
}
@@ -0,0 +1,290 @@
{
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 1,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with basic labelsToFields transformation",
"transformations": [
{
"id": "labelsToFields",
"options": {}
},
{
"id": "merge",
"options": {}
}
],
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 9,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with labelsToFields options preserved",
"transformations": [
{
"id": "labelsToFields",
"options": {
"keepLabels": [
"job",
"instance",
"region"
],
"mode": "rows",
"valueLabel": "value"
}
},
{
"id": "merge",
"options": {}
}
],
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 2,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with multiple labelsToFields transformations",
"transformations": [
{
"id": "organize",
"options": {}
},
{
"id": "labelsToFields",
"options": {}
},
{
"id": "merge",
"options": {}
},
{
"id": "calculateField",
"options": {}
},
{
"id": "labelsToFields",
"options": {
"mode": "rows"
}
},
{
"id": "merge",
"options": {}
}
],
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 3,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with no transformations",
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 4,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with other transformations only",
"transformations": [
{
"id": "organize",
"options": {}
},
{
"id": "reduce",
"options": {}
}
],
"type": "timeseries"
},
{
"collapsed": false,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 5,
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 6,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Nested panel with labelsToFields",
"transformations": [
{
"id": "labelsToFields",
"options": {}
},
{
"id": "merge",
"options": {}
}
],
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 7,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Nested panel without labelsToFields",
"transformations": [
{
"id": "organize",
"options": {}
}
],
"type": "timeseries"
}
],
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Row with nested panels",
"type": "row"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 8,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with labelsToFields and existing merge",
"transformations": [
{
"id": "labelsToFields",
"options": {}
},
{
"id": "merge",
"options": {}
},
{
"id": "merge",
"options": {}
},
{
"id": "reduce",
"options": {}
}
],
"type": "timeseries"
}
],
"refresh": "",
"schemaVersion": 41,
"title": "V31 LabelsToFields Merge Migration Test Dashboard"
}
@@ -0,0 +1,159 @@
{
"annotations": {
"list": [
{
"datasource": {
"uid": "grafana"
},
"enable": true,
"name": "Deployments"
}
]
},
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 1,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with transformations remains unchanged",
"transformations": [
{
"id": "labelsToFields",
"options": {
"keepLabels": [
"job",
"instance"
],
"mode": "rows"
}
},
{
"id": "merge",
"options": {}
}
],
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 2,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Graph panel remains unchanged",
"type": "graph",
"yAxes": [
{
"max": null,
"min": null,
"show": true
}
]
},
{
"collapsed": false,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 3,
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
}
},
"id": 4,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Nested stat panel",
"type": "stat"
}
],
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Row with nested panels",
"type": "row"
}
],
"refresh": "",
"schemaVersion": 41,
"templating": {
"list": [
{
"datasource": {
"uid": "prometheus"
},
"name": "environment",
"options": [],
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"title": "V32 No-Op Migration Test Dashboard"
}