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:
co-authored by
Stephanie Hingtgen
Todd Treece
Haris Rozajac
parent
b7153d4d20
commit
93c14c52da
@@ -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{}{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
Reference in New Issue
Block a user