Migrations: Compare backend and frontend outputs to ensure feature parity (#106851)

* wip: trying to understand how to get the ds info from migrator

* add datasource info provider

* Use DS service to fetch DS data

* add more tests cases to match with migrator cases

* Add snapshots

* Non-existing DS

* Add different DS for snapshots

* fix import

* Fix tests: guard against double initialization

* don't use full datasource package in test

* min version should be 35

* fix test

* fix conversion test

* Dashboards: Support schemaVersion v35 migration in backend

* Dashboards: Support schemaVersion v34 migration in backend

* Dashboards: Support schemaVersion v33 migration in backend

* Apply suggestions from code review

Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>

* Apply feedback

* Remove unused parameters

* Refactor to follow Go patterns

* Update logic

* Only write final migration result as output

* Compare backend and frontend results

* Improve snapshots to cover all possible use cases

* Linter

* wip make it consistent v33

* apply feedback

* Return default when the ref cannot be found

* Update apps/dashboard/pkg/migration/schemaversion/v33.go

Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>

* apply feedback

* Use same mocks backend/frontend

* restore migrations

* update snapshots

* Adapt migration tests to use min versions

* Ensure v40-v41 works

* Ensure v39-v40 works

* Simplify the naming of the files

* adjust jest to new input convention

* Ensure every migration v36-v41 works

* Improve v38 naming

* Ensure v36 migrates correctly

* Skip v36 refs migrations on rows

* Treat rows as frontend and ensure same results for v36

* Ensure v34 runs with the same logic than the frontend

* Leave empty stadistics as valid option

* ensure v33 is working as the frontend

* Update tests

* Undo frontend changes for legend handling

* Remove filtering by version in the frontend

* linter

* Clean up v33 input JSON

---------

Co-authored-by: Todd Treece <360020+toddtreece@users.noreply.github.com>
Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
This commit is contained in:
Ivan Ortega Alba
2025-07-03 12:23:51 +02:00
committed by GitHub
co-authored by Stephanie Hingtgen Todd Treece Haris Rozajac
parent b7153d4d20
commit 93c14c52da
109 changed files with 6762 additions and 21950 deletions
@@ -40,9 +40,10 @@ func GetInstanceSettings(nameOrRef interface{}, datasources []DataSourceInfo) *D
return GetDefaultDSInstanceSettings(datasources)
}
// Check if it's a reference object without UID - should return default
// Check if it's a reference object
if ref, ok := nameOrRef.(map[string]interface{}); ok {
if _, hasUID := ref["uid"]; !hasUID {
// Reference object without UID should return default
return GetDefaultDSInstanceSettings(datasources)
}
// It's a reference object with UID, search for matching UID
@@ -56,7 +57,8 @@ func GetInstanceSettings(nameOrRef interface{}, datasources []DataSourceInfo) *D
}
}
}
return GetDefaultDSInstanceSettings(datasources)
// Unknown UID-only reference should return nil (preserve it)
return nil
}
// Check if it's a string
@@ -76,7 +78,7 @@ func GetInstanceSettings(nameOrRef interface{}, datasources []DataSourceInfo) *D
}
}
}
return GetDefaultDSInstanceSettings(datasources)
return nil
}
// MigrateDatasourceNameToRef converts a datasource name/uid string to a reference object
@@ -100,7 +102,13 @@ func MigrateDatasourceNameToRef(nameOrRef interface{}, options map[string]bool,
return GetDataSourceRef(ds)
}
if dsName, ok := nameOrRef.(string); ok && dsName != "" {
// Handle string cases (including empty strings)
if dsName, ok := nameOrRef.(string); ok {
if dsName == "" {
// Empty string should return empty object (frontend behavior)
return map[string]interface{}{}
}
// Unknown datasource name should be preserved as UID-only reference
return map[string]interface{}{
"uid": dsName,
}
@@ -81,35 +81,35 @@ func TestGetDefaultDSInstanceSettings(t *testing.T) {
{
name: "no default datasource",
datasources: []schemaversion.DataSourceInfo{
{UID: "ds1", Type: "prometheus", Name: "DS1", Default: false},
{UID: "ds2", Type: "elasticsearch", Name: "DS2", Default: false},
{UID: "existing-ref-uid", Type: "prometheus", Name: "Existing Ref Name", Default: false},
{UID: "existing-target-uid", Type: "elasticsearch", Name: "Existing Target Name", Default: false},
},
expected: nil,
},
{
name: "single default datasource",
datasources: []schemaversion.DataSourceInfo{
{UID: "ds1", Type: "prometheus", Name: "DS1", Default: false},
{UID: "default-ds", Type: "prometheus", Name: "Default", Default: true, APIVersion: "v1"},
{UID: "ds2", Type: "elasticsearch", Name: "DS2", Default: false},
{UID: "existing-ref-uid", Type: "prometheus", Name: "Existing Ref Name", Default: false},
{UID: "default-ds-uid", Type: "prometheus", Name: "Default Test Datasource Name", Default: true, APIVersion: "v1"},
{UID: "existing-target-uid", Type: "elasticsearch", Name: "Existing Target Name", Default: false},
},
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
UID: "default-ds-uid",
Type: "prometheus",
Name: "Default",
Name: "Default Test Datasource Name",
APIVersion: "v1",
},
},
{
name: "multiple default datasources returns first",
datasources: []schemaversion.DataSourceInfo{
{UID: "ds1", Type: "prometheus", Name: "Default1", Default: true, APIVersion: "v1"},
{UID: "ds2", Type: "elasticsearch", Name: "Default2", Default: true, APIVersion: "v2"},
{UID: "first-default", Type: "prometheus", Name: "First Default", Default: true, APIVersion: "v1"},
{UID: "second-default", Type: "elasticsearch", Name: "Second Default", Default: true, APIVersion: "v2"},
},
expected: &schemaversion.DataSourceInfo{
UID: "ds1",
UID: "first-default",
Type: "prometheus",
Name: "Default1",
Name: "First Default",
APIVersion: "v1",
},
},
@@ -125,9 +125,9 @@ func TestGetDefaultDSInstanceSettings(t *testing.T) {
func TestGetInstanceSettings(t *testing.T) {
datasources := []schemaversion.DataSourceInfo{
{UID: "default-ds", Type: "prometheus", Name: "Default", Default: true, APIVersion: "v1"},
{UID: "other-ds", Type: "elasticsearch", Name: "Elasticsearch", Default: false, APIVersion: "v2"},
{UID: "test-uid", Type: "influxdb", Name: "InfluxDB", Default: false},
{UID: "default-ds-uid", Type: "prometheus", Name: "Default Test Datasource Name", Default: true, APIVersion: "v1"},
{UID: "existing-target-uid", Type: "elasticsearch", Name: "Existing Target Name", Default: false, APIVersion: "v2"},
{UID: "existing-ref-uid", Type: "prometheus", Name: "Existing Ref Name", Default: false, APIVersion: "v1"},
}
tests := []struct {
@@ -139,9 +139,9 @@ func TestGetInstanceSettings(t *testing.T) {
name: "nil should return default",
nameOrRef: nil,
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
UID: "default-ds-uid",
Type: "prometheus",
Name: "Default",
Name: "Default Test Datasource Name",
APIVersion: "v1",
},
},
@@ -149,51 +149,51 @@ func TestGetInstanceSettings(t *testing.T) {
name: "default string should return default",
nameOrRef: "default",
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
UID: "default-ds-uid",
Type: "prometheus",
Name: "Default",
Name: "Default Test Datasource Name",
APIVersion: "v1",
},
},
{
name: "lookup by UID",
nameOrRef: "other-ds",
nameOrRef: "existing-target-uid",
expected: &schemaversion.DataSourceInfo{
UID: "other-ds",
UID: "existing-target-uid",
Type: "elasticsearch",
Name: "Elasticsearch",
Name: "Existing Target Name",
APIVersion: "v2",
},
},
{
name: "lookup by name",
nameOrRef: "Elasticsearch",
nameOrRef: "Existing Target Name",
expected: &schemaversion.DataSourceInfo{
UID: "other-ds",
UID: "existing-target-uid",
Type: "elasticsearch",
Name: "Elasticsearch",
Name: "Existing Target Name",
APIVersion: "v2",
},
},
{
name: "lookup by UID without apiVersion",
nameOrRef: "test-uid",
nameOrRef: "existing-ref-uid",
expected: &schemaversion.DataSourceInfo{
UID: "test-uid",
Type: "influxdb",
Name: "InfluxDB",
APIVersion: "",
UID: "existing-ref-uid",
Type: "prometheus",
Name: "Existing Ref Name",
APIVersion: "v1",
},
},
{
name: "lookup by reference object with UID",
nameOrRef: map[string]interface{}{
"uid": "other-ds",
"uid": "existing-target-uid",
},
expected: &schemaversion.DataSourceInfo{
UID: "other-ds",
UID: "existing-target-uid",
Type: "elasticsearch",
Name: "Elasticsearch",
Name: "Existing Target Name",
APIVersion: "v2",
},
},
@@ -203,39 +203,29 @@ func TestGetInstanceSettings(t *testing.T) {
"type": "prometheus",
},
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
UID: "default-ds-uid",
Type: "prometheus",
Name: "Default",
Name: "Default Test Datasource Name",
APIVersion: "v1",
},
},
{
name: "unknown datasource should return default",
name: "unknown datasource should return nil",
nameOrRef: "unknown-ds",
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
Type: "prometheus",
Name: "Default",
APIVersion: "v1",
},
expected: nil,
},
{
name: "empty string should return default",
name: "empty string should return nil",
nameOrRef: "",
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
Type: "prometheus",
Name: "Default",
APIVersion: "v1",
},
expected: nil,
},
{
name: "unsupported input type should return default",
nameOrRef: 123,
expected: &schemaversion.DataSourceInfo{
UID: "default-ds",
UID: "default-ds-uid",
Type: "prometheus",
Name: "Default",
Name: "Default Test Datasource Name",
APIVersion: "v1",
},
},
@@ -251,9 +241,9 @@ func TestGetInstanceSettings(t *testing.T) {
func TestMigrateDatasourceNameToRef(t *testing.T) {
datasources := []schemaversion.DataSourceInfo{
{UID: "default-ds", Type: "prometheus", Name: "Default", Default: true, APIVersion: "v1"},
{UID: "other-ds", Type: "elasticsearch", Name: "Elasticsearch", Default: false, APIVersion: "v2"},
{UID: "test-uid", Type: "influxdb", Name: "InfluxDB", Default: false},
{UID: "default-ds-uid", Type: "prometheus", Name: "Default Test Datasource Name", Default: true, APIVersion: "v1"},
{UID: "existing-target-uid", Type: "elasticsearch", Name: "Existing Target Name", Default: false, APIVersion: "v2"},
{UID: "existing-ref-uid", Type: "prometheus", Name: "Existing Ref Name", Default: false, APIVersion: "v1"},
}
t.Run("returnDefaultAsNull: true", func(t *testing.T) {
@@ -287,39 +277,33 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
},
{
name: "lookup by UID",
nameOrRef: "other-ds",
nameOrRef: "existing-target-uid",
expected: map[string]interface{}{
"uid": "other-ds",
"uid": "existing-target-uid",
"type": "elasticsearch",
"apiVersion": "v2",
},
},
{
name: "lookup by name",
nameOrRef: "Elasticsearch",
nameOrRef: "Existing Target Name",
expected: map[string]interface{}{
"uid": "other-ds",
"uid": "existing-target-uid",
"type": "elasticsearch",
"apiVersion": "v2",
},
},
{
name: "unknown datasource should return default reference",
name: "unknown datasource should preserve as UID",
nameOrRef: "unknown-ds",
expected: map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
"uid": "unknown-ds",
},
},
{
name: "empty string should return default reference",
name: "empty string should return empty object",
nameOrRef: "",
expected: map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
},
expected: map[string]interface{}{},
},
}
@@ -343,7 +327,7 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
name: "nil should return default reference",
nameOrRef: nil,
expected: map[string]interface{}{
"uid": "default-ds",
"uid": "default-ds-uid",
"type": "prometheus",
"apiVersion": "v1",
},
@@ -352,7 +336,7 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
name: "default should return default reference",
nameOrRef: "default",
expected: map[string]interface{}{
"uid": "default-ds",
"uid": "default-ds-uid",
"type": "prometheus",
"apiVersion": "v1",
},
@@ -370,30 +354,24 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
},
{
name: "lookup by UID",
nameOrRef: "other-ds",
nameOrRef: "existing-target-uid",
expected: map[string]interface{}{
"uid": "other-ds",
"uid": "existing-target-uid",
"type": "elasticsearch",
"apiVersion": "v2",
},
},
{
name: "unknown datasource should return default reference",
name: "unknown datasource should preserve as UID",
nameOrRef: "unknown-ds",
expected: map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
"uid": "unknown-ds",
},
},
{
name: "empty string should return default reference",
name: "empty string should return empty object",
nameOrRef: "",
expected: map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
},
expected: map[string]interface{}{},
},
}
@@ -414,7 +392,7 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
}
result := schemaversion.MigrateDatasourceNameToRef(nameOrRef, options, datasources)
expected := map[string]interface{}{
"uid": "default-ds",
"uid": "default-ds-uid",
"type": "prometheus",
"apiVersion": "v1",
}
@@ -424,7 +402,7 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
t.Run("integer input should return default reference", func(t *testing.T) {
result := schemaversion.MigrateDatasourceNameToRef(123, options, datasources)
expected := map[string]interface{}{
"uid": "default-ds",
"uid": "default-ds-uid",
"type": "prometheus",
"apiVersion": "v1",
}
@@ -126,10 +126,10 @@ func TestV33(t *testing.T) {
"schemaVersion": 32,
"panels": []interface{}{
map[string]interface{}{
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
"targets": []interface{}{
map[string]interface{}{
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
},
},
},
@@ -141,14 +141,14 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
},
@@ -163,10 +163,10 @@ func TestV33(t *testing.T) {
"schemaVersion": 32,
"panels": []interface{}{
map[string]interface{}{
"datasource": "other-ds",
"datasource": "existing-target-uid",
"targets": []interface{}{
map[string]interface{}{
"datasource": "other-ds",
"datasource": "existing-target-uid",
},
},
},
@@ -178,14 +178,14 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
},
@@ -195,7 +195,7 @@ func TestV33(t *testing.T) {
},
},
{
name: "panel with unknown datasource should return default reference",
name: "panel with unknown datasource should preserve as UID",
input: map[string]interface{}{
"schemaVersion": 32,
"panels": []interface{}{
@@ -214,16 +214,12 @@ func TestV33(t *testing.T) {
"panels": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
"uid": "unknown-datasource",
},
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
"uid": "unknown-datasource",
},
},
},
@@ -237,13 +233,13 @@ func TestV33(t *testing.T) {
"schemaVersion": 32,
"panels": []interface{}{
map[string]interface{}{
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
"targets": []interface{}{
map[string]interface{}{
"datasource": "default",
},
map[string]interface{}{
"datasource": "other-ds",
"datasource": "existing-target-uid",
},
map[string]interface{}{
"datasource": "unknown-ds",
@@ -258,7 +254,7 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
"targets": []interface{}{
@@ -268,15 +264,13 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
},
map[string]interface{}{
"datasource": map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
"uid": "unknown-ds",
},
},
},
@@ -290,7 +284,7 @@ func TestV33(t *testing.T) {
"schemaVersion": 32,
"panels": []interface{}{
map[string]interface{}{
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
},
},
},
@@ -300,7 +294,7 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
},
@@ -315,13 +309,13 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"type": "row",
"collapsed": true,
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
"panels": []interface{}{
map[string]interface{}{
"datasource": "default",
"targets": []interface{}{
map[string]interface{}{
"datasource": "other-ds",
"datasource": "existing-target-uid",
},
},
},
@@ -329,7 +323,7 @@ func TestV33(t *testing.T) {
"datasource": "unknown-ds",
"targets": []interface{}{
map[string]interface{}{
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
},
},
},
@@ -345,7 +339,7 @@ func TestV33(t *testing.T) {
"collapsed": true,
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
"panels": []interface{}{
@@ -355,7 +349,7 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
},
@@ -363,15 +357,13 @@ func TestV33(t *testing.T) {
},
map[string]interface{}{
"datasource": map[string]interface{}{
"uid": "default-ds",
"type": "prometheus",
"apiVersion": "v1",
"uid": "unknown-ds",
},
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
},
@@ -389,7 +381,7 @@ func TestV33(t *testing.T) {
"schemaVersion": 32,
"panels": []interface{}{
map[string]interface{}{
"datasource": "Elasticsearch",
"datasource": "Existing Target Name",
"targets": []interface{}{
map[string]interface{}{
"datasource": "default",
@@ -407,7 +399,7 @@ func TestV33(t *testing.T) {
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "other-ds",
"uid": "existing-target-uid",
"apiVersion": "v2",
},
"targets": []interface{}{
+141 -121
View File
@@ -71,6 +71,7 @@ func V34(dashboard map[string]interface{}) error {
if !hasNested {
continue
}
for _, nestedPanel := range nestedPanels {
np, ok := nestedPanel.(map[string]interface{})
if !ok {
@@ -108,63 +109,60 @@ func migrateCloudWatchQueriesInPanel(panel map[string]interface{}) {
continue
}
// Check if this query has multiple statistics
statistics, hasStatistics := t["statistics"].([]interface{})
if !hasStatistics || len(statistics) <= 1 {
// Convert single statistic or no statistics to proper format
if hasStatistics && len(statistics) == 1 {
if stat, ok := statistics[0].(string); ok {
t["statistic"] = stat
}
}
delete(t, "statistics")
// Add CloudWatch fields if missing
if _, exists := t["metricEditorMode"]; !exists {
t["metricEditorMode"] = 0
}
if _, exists := t["metricQueryType"]; !exists {
t["metricQueryType"] = 0
}
// Get valid statistics (including null and empty strings)
validStats, isEmpty := getValidStatistics(t["statistics"])
// Handle empty array case (preserve it)
if isEmpty {
// Keep empty array as-is
newTargets = append(newTargets, t)
continue
}
// Split query with multiple statistics into separate queries
// First, collect all valid statistics
var validStatistics []string
for _, stat := range statistics {
statString, ok := stat.(string)
if !ok {
continue
}
validStatistics = append(validStatistics, statString)
}
// Remove statistics field for processing
delete(t, "statistics")
// If no valid statistics found, remove statistics field and keep original query
if len(validStatistics) == 0 {
delete(t, "statistics")
// Handle based on number of valid statistics
switch len(validStats) {
case 0:
// No valid statistics - keep query as-is
newTargets = append(newTargets, t)
continue
}
// Create separate queries for each valid statistic
for i, statString := range validStatistics {
// Create a copy of the original query
newQuery := make(map[string]interface{})
for k, v := range t {
if k != "statistics" {
newQuery[k] = v
case 1:
// Single statistic - set statistic field if not null
if validStats[0] != nil {
if statString, ok := validStats[0].(string); ok {
t["statistic"] = statString
}
}
newTargets = append(newTargets, t)
default:
// Multiple statistics - create separate queries
for i, stat := range validStats {
newQuery := copyMap(t)
if stat != nil {
if statString, ok := stat.(string); ok {
newQuery["statistic"] = statString
}
}
// Set the single statistic
newQuery["statistic"] = statString
if i == 0 {
// First query replaces the original
newTargets = append(newTargets, newQuery)
} else {
// Additional queries get new refIds and are added at the end
newQuery["refId"] = generateNextRefId(append(targets, additionalTargets...), len(additionalTargets))
additionalTargets = append(additionalTargets, newQuery)
if i == 0 {
newTargets = append(newTargets, newQuery)
} else {
newQuery["refId"] = generateNextRefId(append(targets, additionalTargets...), len(additionalTargets))
additionalTargets = append(additionalTargets, newQuery)
}
}
}
}
// Append additional queries at the end
panel["targets"] = append(newTargets, additionalTargets...)
}
@@ -192,95 +190,117 @@ func migrateCloudWatchAnnotationQueries(dashboard map[string]interface{}) {
continue
}
// Check if this annotation has multiple statistics
statistics, hasStatistics := a["statistics"].([]interface{})
if !hasStatistics || len(statistics) <= 1 {
// Convert single statistic to proper format
if hasStatistics && len(statistics) == 1 {
if stat, ok := statistics[0].(string); ok {
// Create new annotation with single statistic
newAnnotation := make(map[string]interface{})
for k, v := range a {
if k != "statistics" {
newAnnotation[k] = v
}
// Get original name for suffix generation
originalName, _ := a["name"].(string)
// Get valid statistics (including null and empty strings)
validStats, isEmpty := getValidStatistics(a["statistics"])
// Handle empty array case (preserve it)
if isEmpty {
// Keep empty array as-is
annotationsList[i] = a
continue
}
// Handle based on number of valid statistics
switch len(validStats) {
case 0:
// No valid statistics - remove statistics field
delete(a, "statistics")
annotationsList[i] = a
case 1:
// Single statistic - set statistic field if not null
delete(a, "statistics")
if validStats[0] != nil {
if statString, ok := validStats[0].(string); ok {
a["statistic"] = statString
}
}
annotationsList[i] = a
default:
// Multiple statistics - create separate annotations
delete(a, "statistics")
for j, stat := range validStats {
newAnnotation := copyMap(a)
if stat != nil {
if statString, ok := stat.(string); ok {
newAnnotation["statistic"] = statString
}
newAnnotation["statistic"] = stat
}
// Add suffix to name
if originalName != "" {
suffix := getSuffixForStat(stat)
newAnnotation["name"] = originalName + " - " + suffix
}
if j == 0 {
annotationsList[i] = newAnnotation
} else {
additionalAnnotations = append(additionalAnnotations, newAnnotation)
}
} else {
// Always remove statistics field, even if empty or no statistics
newAnnotation := make(map[string]interface{})
for k, v := range a {
if k != "statistics" {
newAnnotation[k] = v
}
}
annotationsList[i] = newAnnotation
}
continue
}
// Split annotation with multiple statistics into separate annotations
// First, collect all valid statistics
var validStatistics []string
for _, stat := range statistics {
statString, ok := stat.(string)
if !ok {
continue
}
validStatistics = append(validStatistics, statString)
}
// If no valid statistics found, remove statistics field and keep original annotation
if len(validStatistics) == 0 {
// Create new annotation without statistics field
newAnnotation := make(map[string]interface{})
for k, v := range a {
if k != "statistics" {
newAnnotation[k] = v
}
}
annotationsList[i] = newAnnotation
continue
}
// Create new annotations for each valid statistic, replace original with first one
originalName, hasName := a["name"].(string)
for j, statString := range validStatistics {
// Create new annotation for this statistic
newAnnotation := make(map[string]interface{})
for k, v := range a {
if k != "statistics" {
newAnnotation[k] = v
}
}
// Set the single statistic
newAnnotation["statistic"] = statString
// Set the name with statistic suffix if multiple valid statistics
if len(validStatistics) > 1 && hasName {
newAnnotation["name"] = originalName + " - " + statString
}
if j == 0 {
// Replace the original annotation with the first new one
annotationsList[i] = newAnnotation
} else {
// Add additional annotations to be appended later
additionalAnnotations = append(additionalAnnotations, newAnnotation)
}
}
}
// Add additional annotations to the end of the list
if len(additionalAnnotations) > 0 {
annotations["list"] = append(annotationsList, additionalAnnotations...)
}
}
// getValidStatistics extracts valid statistics from the statistics field
func getValidStatistics(statisticsField interface{}) ([]interface{}, bool) {
statistics, ok := statisticsField.([]interface{})
if !ok {
return nil, false
}
// Special case: empty arrays should be preserved
if len(statistics) == 0 {
return nil, true // Return nil with true flag to indicate "empty array"
}
var valid []interface{}
for _, stat := range statistics {
// Include null and strings (including empty strings)
if stat == nil || isString(stat) {
valid = append(valid, stat)
}
}
return valid, false
}
// getSuffixForStat returns the appropriate suffix for annotation names
func getSuffixForStat(stat interface{}) string {
if stat == nil {
return "null"
}
if statString, ok := stat.(string); ok {
if statString == "" {
return ""
}
return statString
}
return ""
}
// copyMap creates a shallow copy of a map
func copyMap(original map[string]interface{}) map[string]interface{} {
copy := make(map[string]interface{})
for k, v := range original {
copy[k] = v
}
return copy
}
// isString checks if value is a string
func isString(value interface{}) bool {
_, ok := value.(string)
return ok
}
// isCloudWatchQuery checks if a query target is a CloudWatch query.
func isCloudWatchQuery(target map[string]interface{}) bool {
// Check for required CloudWatch query fields
@@ -36,28 +36,34 @@ func TestV34(t *testing.T) {
"type": "timeseries",
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "C",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Minimum",
"refId": "C",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Minimum",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -90,12 +96,14 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -197,13 +205,20 @@ func TestV34(t *testing.T) {
"annotations": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "CloudWatch Annotation",
"name": "CloudWatch Annotation - Sum",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"prefixMatching": false,
"statistic": "Sum",
},
map[string]interface{}{
"name": "CloudWatch Annotation - null",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"prefixMatching": false,
},
},
},
},
@@ -236,6 +251,13 @@ func TestV34(t *testing.T) {
"prefixMatching": false,
"statistic": "Sum",
},
map[string]interface{}{
"name": "CloudWatch Annotation - null",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"prefixMatching": false,
},
map[string]interface{}{
"name": "CloudWatch Annotation - Average",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
@@ -310,12 +332,14 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "B",
@@ -323,12 +347,14 @@ func TestV34(t *testing.T) {
"datasource": "prometheus",
},
map[string]interface{}{
"refId": "C",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"refId": "C",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -360,11 +386,13 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -427,11 +455,14 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistics": []interface{}{},
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -464,12 +495,23 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -502,11 +544,13 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -541,24 +585,28 @@ func TestV34(t *testing.T) {
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"period": "300",
"alias": "CPU Usage",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"period": "300",
"alias": "CPU Usage",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"period": "300",
"alias": "CPU Usage",
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"period": "300",
"alias": "CPU Usage",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -650,36 +698,44 @@ func TestV34(t *testing.T) {
"id": 4,
"targets": []interface{}{
map[string]interface{}{
"refId": "C",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "default",
"metricName": "CPUUtilization",
"statistic": "Average",
"refId": "C",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "default",
"metricName": "CPUUtilization",
"statistic": "Average",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-2",
"metricName": "CPUUtilization",
"statistic": "Sum",
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-2",
"metricName": "CPUUtilization",
"statistic": "Sum",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "default",
"metricName": "CPUUtilization",
"statistic": "Minimum",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "default",
"metricName": "CPUUtilization",
"statistic": "Minimum",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "D",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "default",
"metricName": "CPUUtilization",
"statistic": "p12.21",
"refId": "D",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "default",
"metricName": "CPUUtilization",
"statistic": "p12.21",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -687,20 +743,24 @@ func TestV34(t *testing.T) {
"id": 5,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-456"},
"namespace": "AWS/EC2",
"region": "us-west-1",
"metricName": "NetworkIn",
"statistic": "Sum",
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-456"},
"namespace": "AWS/EC2",
"region": "us-west-1",
"metricName": "NetworkIn",
"statistic": "Sum",
"metricEditorMode": 0,
"metricQueryType": 0,
},
map[string]interface{}{
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-456"},
"namespace": "AWS/EC2",
"region": "us-west-1",
"metricName": "NetworkIn",
"statistic": "Min",
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-456"},
"namespace": "AWS/EC2",
"region": "us-west-1",
"metricName": "NetworkIn",
"statistic": "Min",
"metricEditorMode": 0,
"metricQueryType": 0,
},
},
},
@@ -709,6 +769,64 @@ func TestV34(t *testing.T) {
},
},
},
{
name: "preserves existing metricEditorMode and metricQueryType values",
input: map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistics": []interface{}{"Average", "Maximum"},
"metricEditorMode": 1,
"metricQueryType": 1,
"period": "300",
"alias": "CPU Usage",
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": int(34),
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Average",
"period": "300",
"alias": "CPU Usage",
"metricEditorMode": 1,
"metricQueryType": 1,
},
map[string]interface{}{
"refId": "B",
"dimensions": map[string]interface{}{"InstanceId": "i-123"},
"namespace": "AWS/EC2",
"region": "us-east-1",
"metricName": "CPUUtilization",
"statistic": "Maximum",
"period": "300",
"alias": "CPU Usage",
"metricEditorMode": 1,
"metricQueryType": 1,
},
},
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V34)
}
+153 -52
View File
@@ -1,8 +1,77 @@
package schemaversion
// V36 migrates dashboard datasource references from string names to UIDs.
// This migration converts datasource references in annotations, template variables, and panels
// from the old format (string name or UID) to the new format (object with uid, type, apiVersion).
// V36 migrates dashboard datasource references from legacy string format to structured UID-based objects.
//
// This migration addresses a critical evolution in Grafana's datasource architecture where datasource
// identification shifted from potentially ambiguous display names to reliable UIDs. The original format
// used string references that could break when datasources were renamed, moved between organizations,
// or when multiple datasources shared similar names. This created reliability and portability issues
// for dashboard sharing and automation workflows.
//
// The migration works by:
// 1. Processing annotations, template variables, and panels (including nested panels in rows)
// 2. Converting string datasource references to structured objects containing uid, type, and apiVersion
// 3. Handling null/missing datasource references by setting appropriate defaults
// 4. Maintaining consistency between panel and target datasource configurations
// 5. Preserving special datasource types like Mixed datasources and expression queries
//
// This transformation provides several critical benefits:
// - Eliminates datasource reference breakage when datasources are renamed
// - Enables reliable dashboard export/import across different Grafana instances
// - Supports advanced datasource features that require type and version information
// - Prepares the schema for future datasource management enhancements
// - Maintains backward compatibility while establishing a robust foundation
//
// The migration handles complex scenarios including:
// - Panels with missing datasource configuration (set to default)
// - Mixed datasource panels with heterogeneous targets
// - Expression queries that reference other queries
// - Template variables that depend on datasource queries
// - Annotation queries from various datasource types
//
// Example transformations:
//
// Before migration (string reference):
//
// datasource: "prometheus-prod"
// // or
// datasource: null
//
// After migration (structured object):
//
// datasource: {
// uid: "prometheus-uid-123",
// type: "prometheus",
// apiVersion: "v1"
// }
//
// Before migration (panel with targets):
//
// panel: {
// datasource: "CloudWatch",
// targets: [{
// datasource: null,
// refId: "A"
// }]
// }
//
// After migration (consistent references):
//
// panel: {
// datasource: {
// uid: "cloudwatch-uid-456",
// type: "cloudwatch",
// apiVersion: "v1"
// },
// targets: [{
// datasource: {
// uid: "cloudwatch-uid-456",
// type: "cloudwatch",
// apiVersion: "v1"
// },
// refId: "A"
// }]
// }
func V36(dsInfo DataSourceInfoProvider) SchemaVersionMigrationFunc {
datasources := dsInfo.GetDataSourceInfo()
return func(dashboard map[string]interface{}) error {
@@ -34,11 +103,8 @@ func migrateAnnotations(dashboard map[string]interface{}, datasources []DataSour
continue
}
ds, exists := queryMap["datasource"]
if !exists {
continue
}
// Always migrate datasource, even if it doesn't exist (will be set to default)
ds := queryMap["datasource"]
queryMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
}
}
@@ -55,6 +121,7 @@ func migrateTemplateVariables(dashboard map[string]interface{}, datasources []Da
return
}
defaultDS := GetDefaultDSInstanceSettings(datasources)
for _, variable := range list {
varMap, ok := variable.(map[string]interface{})
if !ok {
@@ -67,11 +134,12 @@ func migrateTemplateVariables(dashboard map[string]interface{}, datasources []Da
}
ds, exists := varMap["datasource"]
if !exists {
continue
// Handle null datasource variables by setting to default
if !exists || ds == nil {
varMap["datasource"] = GetDataSourceRef(defaultDS)
} else {
varMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
}
varMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
}
}
@@ -88,30 +156,68 @@ func migratePanels(dashboard map[string]interface{}, datasources []DataSourceInf
continue
}
migratePanelDatasources(panelMap, datasources)
// Handle nested panels in collapsed rows
nestedPanels, hasNested := panelMap["panels"].([]interface{})
if !hasNested {
continue
}
for _, nestedPanel := range nestedPanels {
np, ok := nestedPanel.(map[string]interface{})
if !ok {
continue
}
migratePanelDatasources(np, datasources)
}
}
}
// migratePanelDatasources updates datasource references in a single panel and its targets
func migratePanelDatasources(panelMap map[string]interface{}, datasources []DataSourceInfo) {
targets, hasTargets := panelMap["targets"].([]interface{})
if !hasTargets || len(targets) == 0 {
return
}
// NOTE: Even though row panels don't technically need datasource or targets fields,
// we process them anyway to exactly match frontend behavior and avoid inconsistencies
// between frontend and backend migrations. The frontend DashboardMigrator processes
// all panels uniformly without special row panel handling.
defaultDS := GetDefaultDSInstanceSettings(datasources)
panelDataSourceWasDefault := false
// Handle targets - treat empty arrays same as missing targets (matches frontend behavior)
targets, hasTargets := panelMap["targets"].([]interface{})
if !hasTargets || len(targets) == 0 {
targets = []interface{}{
map[string]interface{}{
"refId": "A",
},
}
panelMap["targets"] = targets
hasTargets = true
}
// Handle panel datasource
if ds, exists := panelMap["datasource"]; exists {
if ds == nil {
defaultDS := GetDefaultDSInstanceSettings(datasources)
ds, exists := panelMap["datasource"]
if !exists || ds == nil {
// Set to default if panel has targets (matches frontend logic)
panelMap["datasource"] = GetDataSourceRef(defaultDS)
panelDataSourceWasDefault = true
} else {
// Migrate existing non-null datasource (should be null after V33)
migrated := MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": true}, datasources)
if migrated == nil {
// If migration returned nil, set to default
panelMap["datasource"] = GetDataSourceRef(defaultDS)
panelDataSourceWasDefault = true
} else {
panelMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": true}, datasources)
panelMap["datasource"] = migrated
}
}
// Handle target datasources
if !hasTargets {
return
}
for _, target := range targets {
targetMap, ok := target.(map[string]interface{})
if !ok {
@@ -120,48 +226,43 @@ func migratePanelDatasources(panelMap map[string]interface{}, datasources []Data
ds, exists := targetMap["datasource"]
// Check if target datasource is null or has no uid
isNullOrNoUID := !exists || ds == nil
if !isNullOrNoUID {
dsMap, ok := ds.(map[string]interface{})
if ok {
uid, hasUID := dsMap["uid"]
if !hasUID || uid == nil {
isNullOrNoUID = true
}
// Check if target datasource is null, missing, or has no uid
needsDefault := false
if !exists || ds == nil {
needsDefault = true
} else if dsMap, ok := ds.(map[string]interface{}); ok {
uid, hasUID := dsMap["uid"]
if !hasUID || uid == nil {
needsDefault = true
}
}
if isNullOrNoUID {
// If panel doesn't have mixed datasource, use panel's datasource
if needsDefault {
// Use panel's datasource if it's not mixed
panelDS, ok := panelMap["datasource"].(map[string]interface{})
if !ok {
continue
}
uid, hasUID := panelDS["uid"].(string)
if hasUID && uid != "-- Mixed --" {
targetMap["datasource"] = panelDS
if ok {
uid, hasUID := panelDS["uid"].(string)
if hasUID && uid != "-- Mixed --" {
targetMap["datasource"] = panelDS
} else {
// If panel is mixed, migrate target datasource independently
targetMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
}
}
} else {
// Migrate existing target datasource
targetDS := MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
targetMap["datasource"] = targetDS
targetMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
}
// Update panel datasource if it was default and target is not an expression
if !panelDataSourceWasDefault {
continue
}
targetDS, ok := targetMap["datasource"].(map[string]interface{})
if !ok {
continue
}
uid, ok := targetDS["uid"].(string)
if ok && uid != "__expr__" {
panelMap["datasource"] = targetDS
if panelDataSourceWasDefault {
targetDS, ok := targetMap["datasource"].(map[string]interface{})
if ok {
uid, ok := targetDS["uid"].(string)
if ok && uid != "__expr__" {
panelMap["datasource"] = targetDS
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,76 @@
package schemaversion
// V37 normalizes legend configuration in panels to use a consistent format:
// - Converts boolean legend values to object format
// - Standardizes hidden legends to use showLegend: false with displayMode: list
// - Ensures visible legends have showLegend: true
// V37 normalizes legend configuration to use `showLegend` property consistently.
//
// This migration addresses inconsistencies in how legend visibility was handled.
// There were two ways to hide the legend:
// 1. Using displayMode: "hidden"
// 2. Using showLegend: false
//
// The migration normalizes both approaches to use showLegend consistently:
// - If displayMode is "hidden" OR showLegend is false, set displayMode to "list" and showLegend to false
// - For all other existing legend objects, ensure showLegend is true
//
// Note: This migration only processes legend configurations that already exist as objects.
// Boolean legend values are not processed by this migration.
//
// Example transformations:
//
// Before migration (hidden displayMode):
//
// options: {
// legend: {
// displayMode: "hidden",
// placement: "bottom"
// }
// }
//
// After migration:
//
// options: {
// legend: {
// displayMode: "list",
// showLegend: false,
// placement: "bottom"
// }
// }
//
// Before migration (showLegend false):
//
// options: {
// legend: {
// displayMode: "table",
// showLegend: false
// }
// }
//
// After migration:
//
// options: {
// legend: {
// displayMode: "list",
// showLegend: false
// }
// }
//
// Before migration (visible legend):
//
// options: {
// legend: {
// displayMode: "table",
// placement: "bottom"
// }
// }
//
// After migration:
//
// options: {
// legend: {
// displayMode: "table",
// placement: "bottom",
// showLegend: true
// }
// }
func V37(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(37)
@@ -12,51 +79,50 @@ func V37(dashboard map[string]interface{}) error {
return nil
}
// Process all panels, including nested ones
processPanelsV37(panels)
return nil
}
// processPanelsV37 recursively processes panels, including nested panels within rows
func processPanelsV37(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" {
if nestedPanels, ok := p["panels"].([]interface{}); ok {
processPanelsV37(nestedPanels)
}
continue
}
options, ok := p["options"].(map[string]interface{})
if !ok {
continue
}
// Skip if no legend config exists
// Only process legend if it exists and is an object (not boolean)
legendValue := options["legend"]
if legendValue == nil {
continue
}
// Convert boolean legend to object format
if legendBool, ok := legendValue.(bool); ok {
options["legend"] = map[string]interface{}{
"displayMode": "list",
"showLegend": legendBool,
}
continue
}
// Handle object format legend
legend, ok := legendValue.(map[string]interface{})
if !ok {
if !ok || legend == nil {
continue
}
displayMode, hasDisplayMode := legend["displayMode"].(string)
displayMode, _ := legend["displayMode"].(string)
showLegend, hasShowLegend := legend["showLegend"].(bool)
// Normalize hidden legends
if (hasDisplayMode && displayMode == "hidden") || (hasShowLegend && !showLegend) {
// If displayMode is "hidden" OR showLegend is false, normalize to hidden legend
if displayMode == "hidden" || (hasShowLegend && !showLegend) {
legend["displayMode"] = "list"
legend["showLegend"] = false
continue
} else {
// For all other cases, ensure showLegend is true
legend["showLegend"] = true
}
// Ensure visible legends have showLegend true
legend["showLegend"] = true
}
return nil
}
@@ -9,96 +9,186 @@ import (
func TestV37(t *testing.T) {
tests := []migrationTestCase{
{
name: "no legend config",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"options": map[string]interface{}{},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"options": map[string]interface{}{},
},
},
},
},
{
name: "boolean legend true",
name: "legend normalization with nested panels",
input: map[string]interface{}{
"title": "V37 Legend Normalization Test Dashboard",
"schemaVersion": 36,
"panels": []interface{}{
// Boolean legend true (should remain unchanged)
map[string]interface{}{
"type": "timeseries",
"title": "Panel with Boolean Legend True",
"id": 1,
"options": map[string]interface{}{
"legend": true,
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": true,
},
},
},
},
},
},
{
name: "boolean legend false",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
// Boolean legend false (should remain unchanged)
map[string]interface{}{
"type": "timeseries",
"title": "Panel with Boolean Legend False",
"id": 2,
"options": map[string]interface{}{
"legend": false,
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
},
},
},
{
name: "hidden displayMode",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
// Hidden displayMode (should be normalized)
map[string]interface{}{
"type": "graph",
"title": "Panel with Hidden DisplayMode",
"id": 3,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "hidden",
"placement": "bottom",
},
},
},
// ShowLegend false (should be normalized)
map[string]interface{}{
"type": "stat",
"title": "Panel with ShowLegend False",
"id": 4,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
"showLegend": false,
},
},
},
// Valid legend with table displayMode (should get showLegend: true)
map[string]interface{}{
"type": "barchart",
"title": "Panel with Table Legend",
"id": 5,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
"placement": "bottom",
},
},
},
// Valid legend with list displayMode (should get showLegend: true)
map[string]interface{}{
"type": "histogram",
"title": "Panel with List Legend",
"id": 6,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"placement": "right",
},
},
},
// Panel with no options (should remain unchanged)
map[string]interface{}{
"type": "text",
"title": "Panel with No Options",
"id": 7,
},
// Panel with no legend config (should remain unchanged)
map[string]interface{}{
"type": "gauge",
"title": "Panel with No Legend Config",
"id": 8,
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"fields": "/.*temperature.*/",
},
},
},
// Panel with nil legend (should remain unchanged)
map[string]interface{}{
"type": "piechart",
"title": "Panel with Nil Legend",
"id": 9,
"options": map[string]interface{}{
"legend": nil,
},
},
// Row with nested panels
map[string]interface{}{
"type": "row",
"title": "Row with Nested Panels Having Various Legend Configs",
"id": 10,
"collapsed": false,
"panels": []interface{}{
// Nested panel with boolean legend (should remain unchanged)
map[string]interface{}{
"type": "timeseries",
"title": "Nested Panel with Boolean Legend",
"id": 11,
"options": map[string]interface{}{
"legend": true,
},
},
// Nested panel with hidden displayMode (should be normalized)
map[string]interface{}{
"type": "graph",
"title": "Nested Panel with Hidden DisplayMode",
"id": 12,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "hidden",
},
},
},
// Nested panel with showLegend false (should be normalized)
map[string]interface{}{
"type": "stat",
"title": "Nested Panel with ShowLegend False",
"id": 13,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
"showLegend": false,
},
},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V37 Legend Normalization Test Dashboard",
"schemaVersion": 37,
"panels": []interface{}{
// Boolean legend true (unchanged)
map[string]interface{}{
"type": "timeseries",
"title": "Panel with Boolean Legend True",
"id": 1,
"options": map[string]interface{}{
"legend": true,
},
},
// Boolean legend false (unchanged)
map[string]interface{}{
"type": "timeseries",
"title": "Panel with Boolean Legend False",
"id": 2,
"options": map[string]interface{}{
"legend": false,
},
},
// Hidden displayMode (normalized)
map[string]interface{}{
"type": "graph",
"title": "Panel with Hidden DisplayMode",
"id": 3,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
"placement": "bottom",
},
},
},
// ShowLegend false (normalized)
map[string]interface{}{
"type": "stat",
"title": "Panel with ShowLegend False",
"id": 4,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
@@ -106,62 +196,100 @@ func TestV37(t *testing.T) {
},
},
},
},
},
},
{
name: "showLegend false",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"showLegend": false,
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
},
},
},
{
name: "visible legend",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
// Valid legend with table displayMode (showLegend added)
map[string]interface{}{
"type": "barchart",
"title": "Panel with Table Legend",
"id": 5,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
"placement": "bottom",
"showLegend": true,
},
},
},
// Valid legend with list displayMode (showLegend added)
map[string]interface{}{
"type": "histogram",
"title": "Panel with List Legend",
"id": 6,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"placement": "right",
"showLegend": true,
},
},
},
// Panel with no options (unchanged)
map[string]interface{}{
"type": "text",
"title": "Panel with No Options",
"id": 7,
},
// Panel with no legend config (unchanged)
map[string]interface{}{
"type": "gauge",
"title": "Panel with No Legend Config",
"id": 8,
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"fields": "/.*temperature.*/",
},
},
},
// Panel with nil legend (unchanged)
map[string]interface{}{
"type": "piechart",
"title": "Panel with Nil Legend",
"id": 9,
"options": map[string]interface{}{
"legend": nil,
},
},
// Row with nested panels (nested panels processed)
map[string]interface{}{
"type": "row",
"title": "Row with Nested Panels Having Various Legend Configs",
"id": 10,
"collapsed": false,
"panels": []interface{}{
// Nested panel with boolean legend (unchanged)
map[string]interface{}{
"type": "timeseries",
"title": "Nested Panel with Boolean Legend",
"id": 11,
"options": map[string]interface{}{
"legend": true,
},
},
// Nested panel with hidden displayMode (normalized)
map[string]interface{}{
"type": "graph",
"title": "Nested Panel with Hidden DisplayMode",
"id": 12,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
// Nested panel with showLegend false (normalized)
map[string]interface{}{
"type": "stat",
"title": "Nested Panel with ShowLegend False",
"id": 13,
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
},
},
},
},
},
@@ -1,7 +1,75 @@
package schemaversion
// V38 updates the configuration of the table panel to use the new cellOptions format
// and updates the overrides to use the new cellOptions format
// V38 migrates table panel configuration from displayMode to the structured cellOptions format.
//
// This migration addresses limitations in the original table panel cell display configuration where
// the flat displayMode string property could not accommodate the growing complexity of cell rendering
// options. The original design forced all display settings into a single string value, making it
// difficult to add new customization parameters or provide mode-specific configuration options.
//
// The migration works by:
// 1. Locating table panels in the dashboard (including nested panels within rows)
// 2. Examining field configuration defaults and any field overrides for displayMode properties
// 3. Converting string displayMode values to structured cellOptions objects with type and mode
// 4. Updating both field defaults and field override references to use the new property path
// 5. Preserving all existing visual behavior while enabling future cell customization features
//
// This restructuring provides several key benefits:
// - Enables mode-specific configuration options (e.g., gauge thresholds, color schemes)
// - Supports future cell rendering types without breaking existing configurations
// - Provides clearer separation between cell type and rendering mode
// - Maintains backward compatibility while preparing for enhanced table functionality
//
// The migration handles special cases for legacy gauge modes and color background variants,
// ensuring all existing display behaviors are preserved exactly.
//
// Example transformations:
//
// Before migration (field defaults):
//
// fieldConfig: {
// defaults: {
// custom: {
// displayMode: "gradient-gauge"
// }
// }
// }
//
// After migration (field defaults):
//
// fieldConfig: {
// defaults: {
// custom: {
// cellOptions: {
// type: "gauge",
// mode: "gradient"
// }
// }
// }
// }
//
// Before migration (field override):
//
// overrides: [{
// matcher: { id: "byName", options: "CPU" },
// properties: [{
// id: "custom.displayMode",
// value: "color-background-solid"
// }]
// }]
//
// After migration (field override):
//
// overrides: [{
// matcher: { id: "byName", options: "CPU" },
// properties: [{
// id: "custom.cellOptions",
// value: {
// type: "color-background",
// mode: "basic"
// }
// }]
// }]
func V38(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(38)
@@ -10,12 +78,28 @@ func V38(dashboard map[string]interface{}) error {
return nil
}
// Process all panels, including nested ones
processPanelsV38(panels)
return nil
}
// processPanelsV38 recursively processes panels, including nested panels within rows
func processPanelsV38(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" {
if nestedPanels, ok := p["panels"].([]interface{}); ok {
processPanelsV38(nestedPanels)
}
continue
}
// Only process table panels
if p["type"] != "table" {
continue
@@ -48,8 +132,6 @@ func V38(dashboard map[string]interface{}) error {
// Update any overrides referencing the cell display mode
migrateOverrides(fieldConfig)
}
return nil
}
// migrateOverrides updates the overrides configuration to use the new cellOptions format
@@ -9,50 +9,213 @@ import (
func TestV38(t *testing.T) {
tests := []migrationTestCase{
{
name: "no table panels",
input: map[string]interface{}{
"schemaVersion": 37,
"title": "Test Dashboard",
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"title": "Panel 1",
},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"title": "Panel 1",
},
},
},
},
{
name: "table panel with basic gauge displayMode",
name: "table migration with nested panels",
input: map[string]interface{}{
"title": "V38 Table Migration Test Dashboard",
"schemaVersion": 37,
"panels": []interface{}{
// Basic gauge table
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with Basic Gauge",
"id": 1,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "basic",
},
},
"overrides": []interface{}{},
},
},
// Gradient gauge table
map[string]interface{}{
"type": "table",
"title": "Table with Gradient Gauge",
"id": 2,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "gradient-gauge",
},
},
"overrides": []interface{}{},
},
},
// LCD gauge table
map[string]interface{}{
"type": "table",
"title": "Table with LCD Gauge",
"id": 3,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "lcd-gauge",
},
},
"overrides": []interface{}{},
},
},
// Color background table
map[string]interface{}{
"type": "table",
"title": "Table with Color Background",
"id": 4,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "color-background",
},
},
"overrides": []interface{}{},
},
},
// Color background solid table
map[string]interface{}{
"type": "table",
"title": "Table with Color Background Solid",
"id": 5,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "color-background-solid",
},
},
"overrides": []interface{}{},
},
},
// Unknown mode table
map[string]interface{}{
"type": "table",
"title": "Table with Unknown Mode",
"id": 6,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "some-other-mode",
},
},
"overrides": []interface{}{},
},
},
// Table with no display mode
map[string]interface{}{
"type": "table",
"title": "Table with No Display Mode",
"id": 7,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"width": 100,
},
},
"overrides": []interface{}{},
},
},
// Table with overrides
map[string]interface{}{
"type": "table",
"title": "Table with Overrides",
"id": 8,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "basic",
},
},
"overrides": []interface{}{
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Field1",
},
"properties": []interface{}{
map[string]interface{}{
"id": "custom.displayMode",
"value": "gradient-gauge",
},
},
},
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Field2",
},
"properties": []interface{}{
map[string]interface{}{
"id": "custom.displayMode",
"value": "color-background",
},
},
},
},
},
},
// Non-table panel (should remain unchanged)
map[string]interface{}{
"type": "graph",
"title": "Non-table Panel (Should Remain Unchanged)",
"id": 9,
},
// Row with nested table panels
map[string]interface{}{
"type": "row",
"title": "Row with Nested Table Panels",
"id": 10,
"collapsed": false,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"title": "Nested Table with Basic Mode",
"id": 11,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "basic",
},
},
"overrides": []interface{}{},
},
},
map[string]interface{}{
"type": "table",
"title": "Nested Table with Gradient Gauge",
"id": 12,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "gradient-gauge",
},
},
"overrides": []interface{}{
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "NestedField",
},
"properties": []interface{}{
map[string]interface{}{
"id": "custom.displayMode",
"value": "lcd-gauge",
},
},
},
},
},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V38 Table Migration Test Dashboard",
"schemaVersion": 38,
"panels": []interface{}{
// Basic gauge table (migrated)
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with Basic Gauge",
"id": 1,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
@@ -62,33 +225,14 @@ func TestV38(t *testing.T) {
},
},
},
"overrides": []interface{}{},
},
},
},
},
},
{
name: "table panel with gradient-gauge displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
// Gradient gauge table (migrated)
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "gradient-gauge",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with Gradient Gauge",
"id": 2,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
@@ -98,33 +242,14 @@ func TestV38(t *testing.T) {
},
},
},
"overrides": []interface{}{},
},
},
},
},
},
{
name: "table panel with lcd-gauge displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
// LCD gauge table (migrated)
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "lcd-gauge",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with LCD Gauge",
"id": 3,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
@@ -134,33 +259,14 @@ func TestV38(t *testing.T) {
},
},
},
"overrides": []interface{}{},
},
},
},
},
},
{
name: "table panel with color-background displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
// Color background table (migrated)
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "color-background",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with Color Background",
"id": 4,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
@@ -170,33 +276,14 @@ func TestV38(t *testing.T) {
},
},
},
"overrides": []interface{}{},
},
},
},
},
},
{
name: "table panel with color-background-solid displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
// Color background solid table (migrated)
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "color-background-solid",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with Color Background Solid",
"id": 5,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
@@ -206,33 +293,14 @@ func TestV38(t *testing.T) {
},
},
},
"overrides": []interface{}{},
},
},
},
},
},
{
name: "table panel with default displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
// Unknown mode table (migrated)
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "some-other-mode",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"type": "table",
"title": "Table with Unknown Mode",
"id": 6,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
@@ -241,6 +309,132 @@ func TestV38(t *testing.T) {
},
},
},
"overrides": []interface{}{},
},
},
// Table with no display mode (unchanged)
map[string]interface{}{
"type": "table",
"title": "Table with No Display Mode",
"id": 7,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"width": 100,
},
},
"overrides": []interface{}{},
},
},
// Table with overrides (migrated)
map[string]interface{}{
"type": "table",
"title": "Table with Overrides",
"id": 8,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "gauge",
"mode": "basic",
},
},
},
"overrides": []interface{}{
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Field1",
},
"properties": []interface{}{
map[string]interface{}{
"id": "custom.cellOptions",
"value": map[string]interface{}{
"type": "gauge",
"mode": "gradient",
},
},
},
},
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Field2",
},
"properties": []interface{}{
map[string]interface{}{
"id": "custom.cellOptions",
"value": map[string]interface{}{
"type": "color-background",
"mode": "gradient",
},
},
},
},
},
},
},
// Non-table panel (unchanged)
map[string]interface{}{
"type": "graph",
"title": "Non-table Panel (Should Remain Unchanged)",
"id": 9,
},
// Row with nested table panels (nested tables migrated)
map[string]interface{}{
"type": "row",
"title": "Row with Nested Table Panels",
"id": 10,
"collapsed": false,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"title": "Nested Table with Basic Mode",
"id": 11,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "gauge",
"mode": "basic",
},
},
},
"overrides": []interface{}{},
},
},
map[string]interface{}{
"type": "table",
"title": "Nested Table with Gradient Gauge",
"id": 12,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "gauge",
"mode": "gradient",
},
},
},
"overrides": []interface{}{
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "NestedField",
},
"properties": []interface{}{
map[string]interface{}{
"id": "custom.cellOptions",
"value": map[string]interface{}{
"type": "gauge",
"mode": "lcd",
},
},
},
},
},
},
},
},
},
},
@@ -1,7 +1,50 @@
package schemaversion
// V39 updates the configuration of the Timeseries to table transformation
// to support multiple options per query
// V39 migrates timeSeriesTable transformation configuration to support extensible per-query options.
//
// This migration addresses limitations in the original timeSeriesTable transformation design where
// each query could only be configured with a single statistic function. The original refIdToStat
// format was too restrictive for evolving use cases that require multiple configuration parameters
// per query, such as custom formatting, aggregation methods, or display preferences.
//
// The migration works by:
// 1. Locating panels with timeSeriesTable transformations (including nested panels in rows)
// 2. Extracting the existing refIdToStat mapping from transformation options
// 3. Converting each refId-statistic pair to the new nested object structure
// 4. Preserving the statistic function while enabling future option expansion
// 5. Skipping transformations that lack valid refIdToStat configuration
//
// This restructuring enables future enhancements while maintaining backward compatibility:
// - Additional per-query options can be added without breaking existing configurations
// - The stat property preserves current functionality exactly as before
// - New features like custom labels, formats, or calculations can be added seamlessly
// - The structure scales better for complex multi-query transformations
//
// Example transformation:
//
// Before migration:
//
// transformations: [{
// id: "timeSeriesTable",
// options: {
// refIdToStat: {
// "A": "mean",
// "B": "max",
// "C": "last"
// }
// }
// }]
//
// After migration:
//
// transformations: [{
// id: "timeSeriesTable",
// options: {
// "A": { stat: "mean" },
// "B": { stat: "max" },
// "C": { stat: "last" }
// }
// }]
func V39(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(39)
@@ -10,12 +53,28 @@ func V39(dashboard map[string]interface{}) error {
return nil
}
// Process all panels, including nested ones
processPanelsV39(panels)
return nil
}
// processPanelsV39 recursively processes panels, including nested panels within rows
func processPanelsV39(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" {
if nestedPanels, ok := p["panels"].([]interface{}); ok {
processPanelsV39(nestedPanels)
}
continue
}
transformations, ok := p["transformations"].([]interface{})
if !ok {
continue
@@ -55,6 +114,4 @@ func V39(dashboard map[string]interface{}) error {
t["options"] = transformationOptions
}
}
return nil
}
@@ -9,32 +9,32 @@ import (
func TestV39(t *testing.T) {
tests := []migrationTestCase{
{
name: "no transformations",
input: map[string]interface{}{
"schemaVersion": 38,
"title": "Test Dashboard",
"panels": []interface{}{
map[string]interface{}{
"title": "Panel 1",
},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"title": "Panel 1",
},
},
},
},
{
name: "timeSeriesTable transformation with refIdToStat",
name: "comprehensive timeSeriesTable transformation migration with nested panels",
input: map[string]interface{}{
"title": "V39 TimeSeriesTable Transformation Migration Test Dashboard",
"schemaVersion": 38,
"panels": []interface{}{
// Single stat timeSeriesTable
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable Transformation - Single Stat",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
},
},
},
},
},
// Multiple stats timeSeriesTable
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable Transformation - Multiple Stats",
"id": 2,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
@@ -42,6 +42,121 @@ func TestV39(t *testing.T) {
"refIdToStat": map[string]interface{}{
"A": "mean",
"B": "max",
"C": "min",
"D": "sum",
},
},
},
},
},
// Mixed transformations
map[string]interface{}{
"type": "graph",
"title": "Panel with TimeSeriesTable Transformation - Mixed with Other Transforms",
"id": 3,
"transformations": []interface{}{
map[string]interface{}{
"id": "reduce",
"options": map[string]interface{}{
"reducers": []interface{}{"mean"},
},
},
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "last",
"B": "first",
},
},
},
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{
"excludeByName": map[string]interface{}{},
},
},
},
},
// Non-timeSeriesTable transformation
map[string]interface{}{
"type": "stat",
"title": "Panel with Non-TimeSeriesTable Transformation (Should Remain Unchanged)",
"id": 4,
"transformations": []interface{}{
map[string]interface{}{
"id": "reduce",
"options": map[string]interface{}{
"reducers": []interface{}{"mean", "max"},
},
},
},
},
// Empty refIdToStat
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable - Empty RefIdToStat",
"id": 5,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{},
},
},
},
},
// No options (should skip)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable - No Options (Should Skip)",
"id": 6,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
},
},
},
// Invalid options (should skip)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable - Invalid Options (Should Skip)",
"id": 7,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"someOtherOption": "value",
},
},
},
},
// No transformations
map[string]interface{}{
"type": "graph",
"title": "Panel with No Transformations (Should Remain Unchanged)",
"id": 8,
},
// Row with nested panels
map[string]interface{}{
"type": "row",
"title": "Row with Nested Panels Having TimeSeriesTable Transformations",
"id": 9,
"collapsed": false,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"title": "Nested Panel with TimeSeriesTable",
"id": 10,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"NestedA": "median",
"NestedB": "stdDev",
},
},
},
},
},
@@ -50,9 +165,30 @@ func TestV39(t *testing.T) {
},
},
expected: map[string]interface{}{
"title": "V39 TimeSeriesTable Transformation Migration Test Dashboard",
"schemaVersion": 39,
"panels": []interface{}{
// Single stat timeSeriesTable (migrated)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable Transformation - Single Stat",
"id": 1,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"A": map[string]interface{}{
"stat": "mean",
},
},
},
},
},
// Multiple stats timeSeriesTable (migrated)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable Transformation - Multiple Stats",
"id": 2,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
@@ -63,41 +199,126 @@ func TestV39(t *testing.T) {
"B": map[string]interface{}{
"stat": "max",
},
},
},
},
},
},
},
},
{
name: "non-timeSeriesTable transformation is not modified",
input: map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "otherTransform",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
"C": map[string]interface{}{
"stat": "min",
},
"D": map[string]interface{}{
"stat": "sum",
},
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 39,
"panels": []interface{}{
// Mixed transformations (timeSeriesTable migrated, others unchanged)
map[string]interface{}{
"type": "graph",
"title": "Panel with TimeSeriesTable Transformation - Mixed with Other Transforms",
"id": 3,
"transformations": []interface{}{
map[string]interface{}{
"id": "otherTransform",
"id": "reduce",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
"reducers": []interface{}{"mean"},
},
},
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"A": map[string]interface{}{
"stat": "last",
},
"B": map[string]interface{}{
"stat": "first",
},
},
},
map[string]interface{}{
"id": "organize",
"options": map[string]interface{}{
"excludeByName": map[string]interface{}{},
},
},
},
},
// Non-timeSeriesTable transformation (unchanged)
map[string]interface{}{
"type": "stat",
"title": "Panel with Non-TimeSeriesTable Transformation (Should Remain Unchanged)",
"id": 4,
"transformations": []interface{}{
map[string]interface{}{
"id": "reduce",
"options": map[string]interface{}{
"reducers": []interface{}{"mean", "max"},
},
},
},
},
// Empty refIdToStat (migrated to empty options)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable - Empty RefIdToStat",
"id": 5,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{},
},
},
},
// No options (unchanged - should skip)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable - No Options (Should Skip)",
"id": 6,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
},
},
},
// Invalid options (unchanged - should skip)
map[string]interface{}{
"type": "table",
"title": "Panel with TimeSeriesTable - Invalid Options (Should Skip)",
"id": 7,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"someOtherOption": "value",
},
},
},
},
// No transformations (unchanged)
map[string]interface{}{
"type": "graph",
"title": "Panel with No Transformations (Should Remain Unchanged)",
"id": 8,
},
// Row with nested panels (nested panel migrated)
map[string]interface{}{
"type": "row",
"title": "Row with Nested Panels Having TimeSeriesTable Transformations",
"id": 9,
"collapsed": false,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"title": "Nested Panel with TimeSeriesTable",
"id": 10,
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"NestedA": map[string]interface{}{
"stat": "median",
},
"NestedB": map[string]interface{}{
"stat": "stdDev",
},
},
},
},
},
@@ -1,5 +1,38 @@
package schemaversion
// V40 normalizes the dashboard refresh property to ensure consistent string typing.
//
// This migration addresses type inconsistencies in dashboard refresh configuration that could
// cause runtime errors or unexpected behavior. Over time, the refresh property has accumulated
// various data types (boolean, numeric, null, undefined) due to different dashboard creation
// methods, API usage patterns, and legacy imports.
//
// The migration works by:
// 1. Checking if the refresh property exists and is already a string type
// 2. Converting any non-string values (boolean true/false, numbers, null) to an empty string
// 3. Ensuring all dashboards have a consistent string-typed refresh property
//
// This normalization is critical because:
// - The frontend refresh logic expects string values for parsing time intervals
// - Non-string values can cause dashboard loading failures
// - Empty string is the standard representation for "no auto-refresh"
// - Consistent typing enables proper validation and UI behavior
//
// Example transformations:
//
// Before migration:
//
// refresh: true // boolean
// refresh: 30 // number (seconds)
// refresh: null // null value
// refresh: undefined // missing property
//
// After migration:
//
// refresh: "" // normalized to empty string
// refresh: "" // normalized to empty string
// refresh: "" // normalized to empty string
// refresh: "" // property added with empty string
func V40(dash map[string]interface{}) error {
dash["schemaVersion"] = int(40)
if _, ok := dash["refresh"].(string); !ok {
@@ -20,7 +20,7 @@ func TestV40(t *testing.T) {
},
},
{
name: "boolean refresh value is converted to an empty string",
name: "boolean refresh value (true) is converted to an empty string",
input: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
@@ -32,6 +32,19 @@ func TestV40(t *testing.T) {
"refresh": "",
},
},
{
name: "boolean refresh value (false) is converted to an empty string",
input: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"refresh": false,
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 40,
"refresh": "",
},
},
{
name: "string refresh value is not converted",
input: map[string]interface{}{
@@ -45,6 +58,32 @@ func TestV40(t *testing.T) {
"refresh": "1m",
},
},
{
name: "empty string refresh value is preserved",
input: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"refresh": "",
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 40,
"refresh": "",
},
},
{
name: "numeric refresh value is converted to empty string",
input: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"refresh": 60,
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 40,
"refresh": "",
},
},
}
runMigrationTests(t, tests, schemaversion.V40)
@@ -1,5 +1,35 @@
package schemaversion
// V41 removes the deprecated time_options property from dashboard timepicker configuration.
//
// This migration addresses technical debt by cleaning up legacy timepicker settings that have
// been obsolete since Grafana version 5. The time_options property was originally designed to
// allow customization of predefined time range options in the time picker dropdown, but this
// functionality was superseded by more flexible time selection mechanisms.
//
// The migration works by:
// 1. Locating dashboard timepicker configuration objects
// 2. Removing the deprecated time_options property if present
// 3. Preserving all other timepicker settings (refresh_intervals, etc.)
//
// This cleanup prevents potential confusion for developers and ensures the dashboard schema
// remains focused on actively used configuration options. The removal is safe because the
// time_options property has had no functional impact for several major Grafana versions.
//
// Example transformation:
//
// Before migration:
//
// timepicker: {
// refresh_intervals: ["5s", "10s", "30s", "1m"],
// time_options: ["5m", "15m", "1h", "6h", "12h", "24h"]
// }
//
// After migration:
//
// timepicker: {
// refresh_intervals: ["5s", "10s", "30s", "1m"]
// }
func V41(dash map[string]interface{}) error {
dash["schemaVersion"] = int(41)
if timepicker, ok := dash["timepicker"].(map[string]interface{}); ok {
@@ -22,6 +22,22 @@ func TestV41(t *testing.T) {
"timepicker": map[string]interface{}{},
},
},
{
name: "timepicker without time_options is unchanged",
input: map[string]interface{}{
"title": "Test Dashboard",
"timepicker": map[string]interface{}{
"refresh_intervals": []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 41,
"timepicker": map[string]interface{}{
"refresh_intervals": []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"},
},
},
},
{
name: "timepicker is not set",
input: map[string]interface{}{