Fix dashboard migration discrepancies between backend and frontend implementations (use toEqual) (#110268)
**Highlights**
* **Single-version migrations**: add `targetVersion` to migrator & model, separate outputs, enforce exact version.
* **Datasource fixes**: include `apiVersion` in tests, empty-string → `{}`, preserve `{}` refs, drop unwanted defaults.
* **Panel defaults & nesting**: only top-level panels get defaults; preserve empty `transformations` context-aware; filter repeated panels.
* **Migration parity**
* V16: collapsed rows, grid height parsing (`px`).
* V17: omit `maxPerRow` when `minSpan=1`.
* V19–V20: cleanup defaults (`targetBlank`, style).
* V23–V24: template vars + table panel consistency.
* V28: full singlestat/stat parity, mappings & color.
* V30–V36: threshold logic, empty refs, nested targets.
* **Save-model cleanup**: replicate frontend defaults/filtering, drop null IDs, metadata, unused props.
* **Testing**: unified suites, dev dashboards (v42), full unit coverage for major migrations.
Co-authored-by: Ivan Ortega [ivanortegaalba@gmail.com](mailto:ivanortegaalba@gmail.com)
Co-authored-by: Dominik Prokop [dominik.prokop@grafana.com](mailto:dominik.prokop@grafana.com)
This commit is contained in:
co-authored by
Ivan Ortega [ivanortegaalba@gmail.com](mailto:ivanortegaalba@gmail.com)
Dominik Prokop [dominik.prokop@grafana.com](mailto:dominik.prokop@grafana.com)
parent
98fd3e8fe9
commit
a72e02f88a
@@ -34,51 +34,29 @@ func GetDefaultDSInstanceSettings(datasources []DataSourceInfo) *DataSourceInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceSettings looks up a datasource by name or uid reference
|
||||
func GetInstanceSettings(nameOrRef interface{}, datasources []DataSourceInfo) *DataSourceInfo {
|
||||
if nameOrRef == nil || nameOrRef == "default" {
|
||||
return GetDefaultDSInstanceSettings(datasources)
|
||||
}
|
||||
|
||||
// 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
|
||||
for _, ds := range datasources {
|
||||
if uid, hasUID := ref["uid"]; hasUID && uid == ds.UID {
|
||||
return &DataSourceInfo{
|
||||
UID: ds.UID,
|
||||
Type: ds.Type,
|
||||
Name: ds.Name,
|
||||
APIVersion: ds.APIVersion,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unknown UID-only reference should return nil (preserve it)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a string
|
||||
str, ok := nameOrRef.(string)
|
||||
// isDataSourceRef checks if the object is a valid DataSourceRef (has uid or type)
|
||||
// Matches the frontend isDataSourceRef function in datasource.ts
|
||||
func isDataSourceRef(ref interface{}) bool {
|
||||
dsRef, ok := ref.(map[string]interface{})
|
||||
if !ok {
|
||||
return GetDefaultDSInstanceSettings(datasources)
|
||||
return false
|
||||
}
|
||||
|
||||
// Search for matching name or UID
|
||||
for _, ds := range datasources {
|
||||
if str == ds.Name || str == ds.UID {
|
||||
return &DataSourceInfo{
|
||||
UID: ds.UID,
|
||||
Type: ds.Type,
|
||||
Name: ds.Name,
|
||||
APIVersion: ds.APIVersion,
|
||||
}
|
||||
hasUID := false
|
||||
if uid, exists := dsRef["uid"]; exists {
|
||||
if uidStr, ok := uid.(string); ok && uidStr != "" {
|
||||
hasUID = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
hasType := false
|
||||
if typ, exists := dsRef["type"]; exists {
|
||||
if typStr, ok := typ.(string); ok && typStr != "" {
|
||||
hasType = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasUID || hasType
|
||||
}
|
||||
|
||||
// MigrateDatasourceNameToRef converts a datasource name/uid string to a reference object
|
||||
@@ -91,26 +69,42 @@ func MigrateDatasourceNameToRef(nameOrRef interface{}, options map[string]bool,
|
||||
return nil
|
||||
}
|
||||
|
||||
if dsRef, ok := nameOrRef.(map[string]interface{}); ok {
|
||||
if _, hasUID := dsRef["uid"]; hasUID {
|
||||
return dsRef
|
||||
// Frontend: if (isDataSourceRef(nameOrRef)) { return nameOrRef; }
|
||||
if isDataSourceRef(nameOrRef) {
|
||||
return nameOrRef.(map[string]interface{})
|
||||
}
|
||||
|
||||
// Look up datasource by name/UID
|
||||
if nameOrRef == nil || nameOrRef == "default" {
|
||||
ds := GetDefaultDSInstanceSettings(datasources)
|
||||
if ds != nil {
|
||||
return GetDataSourceRef(ds)
|
||||
}
|
||||
}
|
||||
|
||||
ds := GetInstanceSettings(nameOrRef, datasources)
|
||||
if ds != nil {
|
||||
return GetDataSourceRef(ds)
|
||||
}
|
||||
|
||||
// Handle string cases (including empty strings)
|
||||
if dsName, ok := nameOrRef.(string); ok {
|
||||
if dsName == "" {
|
||||
// Empty string should return empty object (frontend behavior)
|
||||
// Check if it's a string name/UID
|
||||
if str, ok := nameOrRef.(string); ok {
|
||||
// Handle empty string case
|
||||
if str == "" {
|
||||
// Empty string should return {} (frontend behavior)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Search for matching datasource
|
||||
for _, ds := range datasources {
|
||||
if str == ds.Name || str == ds.UID {
|
||||
return GetDataSourceRef(&DataSourceInfo{
|
||||
UID: ds.UID,
|
||||
Type: ds.Type,
|
||||
Name: ds.Name,
|
||||
APIVersion: ds.APIVersion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown datasource name should be preserved as UID-only reference
|
||||
return map[string]interface{}{
|
||||
"uid": dsName,
|
||||
"uid": str,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,122 +123,6 @@ func TestGetDefaultDSInstanceSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSettings(t *testing.T) {
|
||||
datasources := []schemaversion.DataSourceInfo{
|
||||
{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 {
|
||||
name string
|
||||
nameOrRef interface{}
|
||||
expected *schemaversion.DataSourceInfo
|
||||
}{
|
||||
{
|
||||
name: "nil should return default",
|
||||
nameOrRef: nil,
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "default-ds-uid",
|
||||
Type: "prometheus",
|
||||
Name: "Default Test Datasource Name",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default string should return default",
|
||||
nameOrRef: "default",
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "default-ds-uid",
|
||||
Type: "prometheus",
|
||||
Name: "Default Test Datasource Name",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lookup by UID",
|
||||
nameOrRef: "existing-target-uid",
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "existing-target-uid",
|
||||
Type: "elasticsearch",
|
||||
Name: "Existing Target Name",
|
||||
APIVersion: "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lookup by name",
|
||||
nameOrRef: "Existing Target Name",
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "existing-target-uid",
|
||||
Type: "elasticsearch",
|
||||
Name: "Existing Target Name",
|
||||
APIVersion: "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lookup by UID without apiVersion",
|
||||
nameOrRef: "existing-ref-uid",
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "existing-ref-uid",
|
||||
Type: "prometheus",
|
||||
Name: "Existing Ref Name",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lookup by reference object with UID",
|
||||
nameOrRef: map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "existing-target-uid",
|
||||
Type: "elasticsearch",
|
||||
Name: "Existing Target Name",
|
||||
APIVersion: "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lookup by reference object without UID",
|
||||
nameOrRef: map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
},
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "default-ds-uid",
|
||||
Type: "prometheus",
|
||||
Name: "Default Test Datasource Name",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown datasource should return nil",
|
||||
nameOrRef: "unknown-ds",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "empty string should return nil",
|
||||
nameOrRef: "",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "unsupported input type should return default",
|
||||
nameOrRef: 123,
|
||||
expected: &schemaversion.DataSourceInfo{
|
||||
UID: "default-ds-uid",
|
||||
Type: "prometheus",
|
||||
Name: "Default Test Datasource Name",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := schemaversion.GetInstanceSettings(tt.nameOrRef, datasources)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDatasourceNameToRef(t *testing.T) {
|
||||
datasources := []schemaversion.DataSourceInfo{
|
||||
{UID: "default-ds-uid", Type: "prometheus", Name: "Default Test Datasource Name", Default: true, APIVersion: "v1"},
|
||||
@@ -386,26 +270,20 @@ func TestMigrateDatasourceNameToRef(t *testing.T) {
|
||||
t.Run("edge cases", func(t *testing.T) {
|
||||
options := map[string]bool{"returnDefaultAsNull": false}
|
||||
|
||||
t.Run("reference without uid should lookup default", func(t *testing.T) {
|
||||
t.Run("reference without uid should be preserved as-is", func(t *testing.T) {
|
||||
nameOrRef := map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
}
|
||||
result := schemaversion.MigrateDatasourceNameToRef(nameOrRef, options, datasources)
|
||||
expected := map[string]interface{}{
|
||||
"uid": "default-ds-uid",
|
||||
"type": "prometheus",
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("integer input should return default reference", func(t *testing.T) {
|
||||
t.Run("integer input should return nil", func(t *testing.T) {
|
||||
result := schemaversion.MigrateDatasourceNameToRef(123, options, datasources)
|
||||
expected := map[string]interface{}{
|
||||
"uid": "default-ds-uid",
|
||||
"type": "prometheus",
|
||||
"apiVersion": "v1",
|
||||
}
|
||||
expected := map[string]interface{}(nil)
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migration_utils.go contains shared utility functions used across multiple schema version migrations.
|
||||
|
||||
// GetStringValue safely extracts a string value from a map, returning empty string if not found or not a string
|
||||
@@ -55,6 +60,14 @@ func ConvertToFloat(value interface{}) (float64, bool) {
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case string:
|
||||
// Handle string values like "700px" - strip px suffix and parse
|
||||
// This matches frontend behavior: parseInt(height.replace('px', ''), 10)
|
||||
cleanStr := strings.TrimSuffix(v, "px")
|
||||
if parsed, err := strconv.ParseFloat(cleanStr, 64); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
@@ -77,3 +90,12 @@ func ConvertToInt(value interface{}) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// IsArray checks if a value is an array (slice)
|
||||
func IsArray(value interface{}) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := value.([]interface{})
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -97,12 +97,11 @@ func upgradeToGridLayout(dashboard map[string]interface{}) {
|
||||
if showRows {
|
||||
// add special row panel (lines 1041-1058 in TS)
|
||||
rowPanel = map[string]interface{}{
|
||||
"id": nextRowID,
|
||||
"type": "row",
|
||||
"title": GetStringValue(row, "title"),
|
||||
"collapsed": isCollapsed,
|
||||
"repeat": GetStringValue(row, "repeat"),
|
||||
"panels": []interface{}{},
|
||||
"id": nextRowID,
|
||||
"type": "row",
|
||||
"title": GetStringValue(row, "title"),
|
||||
"repeat": GetStringValue(row, "repeat"),
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": yPos,
|
||||
@@ -110,6 +109,12 @@ func upgradeToGridLayout(dashboard map[string]interface{}) {
|
||||
"h": rowGridHeight,
|
||||
},
|
||||
}
|
||||
|
||||
// Set collapsed property only if the original row had a collapse property
|
||||
// This matches the frontend behavior: rowPanel.collapsed = row.collapse
|
||||
if _, hasCollapse := row["collapse"]; hasCollapse {
|
||||
rowPanel["collapsed"] = isCollapsed
|
||||
}
|
||||
nextRowID++
|
||||
yPos++
|
||||
}
|
||||
|
||||
@@ -189,12 +189,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 5, // Next ID after row panel (4)
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 5, // Next ID after row panel (4)
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
@@ -260,12 +259,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 4, // Next ID after max panel ID (3)
|
||||
"type": "row",
|
||||
"title": "Row",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 4, // Next ID after max panel ID (3)
|
||||
"type": "row",
|
||||
"title": "Row",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -283,12 +281,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 5, // Next ID after row panel (4)
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 5, // Next ID after row panel (4)
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 9,
|
||||
@@ -391,12 +388,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 8,
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 8,
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
@@ -433,12 +429,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 9,
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 9,
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 10,
|
||||
@@ -552,12 +547,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 5, // Next ID after row panel (4)
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 5, // Next ID after row panel (4)
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
@@ -1230,12 +1224,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
// Repeated row panel (comes after its panels)
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"type": "row",
|
||||
"title": "Row",
|
||||
"collapsed": false,
|
||||
"repeat": "server",
|
||||
"panels": []interface{}{},
|
||||
"id": 3,
|
||||
"type": "row",
|
||||
"title": "Row",
|
||||
"repeat": "server",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -1255,12 +1248,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
// Second row panel (comes after its panels)
|
||||
map[string]interface{}{
|
||||
"id": 4,
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"collapsed": false,
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"id": 4,
|
||||
"type": "row",
|
||||
"title": "",
|
||||
"repeat": "",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 9,
|
||||
@@ -1316,12 +1308,11 @@ func TestV16(t *testing.T) {
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 3, // Next ID after max panel ID (2)
|
||||
"type": "row",
|
||||
"title": "Row1",
|
||||
"collapsed": false,
|
||||
"repeat": "server",
|
||||
"panels": []interface{}{},
|
||||
"id": 3, // Next ID after max panel ID (2)
|
||||
"type": "row",
|
||||
"title": "Row1",
|
||||
"repeat": "server",
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -1458,6 +1449,89 @@ func TestV16(t *testing.T) {
|
||||
// rows field should be removed
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should parse string heights with px suffix during rows to panels migration",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 15,
|
||||
"rows": []interface{}{
|
||||
map[string]interface{}{
|
||||
"collapse": false,
|
||||
"height": "700px", // String height with px suffix
|
||||
"showTitle": true,
|
||||
"title": "Rollout progress",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "barchart",
|
||||
"span": 4,
|
||||
"title": "Versions running",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"expr": "up",
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"type": "barchart",
|
||||
"span": 4,
|
||||
"title": "Deployment progress",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 16,
|
||||
"panels": []interface{}{
|
||||
// First panel
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "barchart",
|
||||
"title": "Versions running",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"expr": "up",
|
||||
},
|
||||
},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 1, // After row panel
|
||||
"w": 8, // 4 span * 2 = 8 width
|
||||
"h": 19, // 700px parsed correctly: ceil(700/38) = 19
|
||||
},
|
||||
},
|
||||
// Second panel
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"type": "barchart",
|
||||
"title": "Deployment progress",
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 8, // Next to first panel
|
||||
"y": 1,
|
||||
"w": 8,
|
||||
"h": 19,
|
||||
},
|
||||
},
|
||||
// Row panel (created because showTitle is true)
|
||||
map[string]interface{}{
|
||||
"id": 3, // Next available ID
|
||||
"type": "row",
|
||||
"title": "Rollout progress",
|
||||
"collapsed": false, // Backend always sets this
|
||||
"repeat": "", // Backend always sets this
|
||||
"panels": []interface{}{},
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 24,
|
||||
"h": 19, // Same height as calculated from "700px"
|
||||
},
|
||||
},
|
||||
},
|
||||
// rows field should be removed
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, schemaversion.V16)
|
||||
|
||||
@@ -82,7 +82,8 @@ func migrateMinSpanToMaxPerRow(panel map[string]interface{}) {
|
||||
max := gridColumnCount / minSpan
|
||||
factors := getFactors(gridColumnCount)
|
||||
|
||||
// Find the first factor greater than max
|
||||
// Find the first factor greater than max, then use the previous factor
|
||||
// This matches the frontend logic: findIndex(factors, (o) => o > max) - 1
|
||||
factorIndex := -1
|
||||
for i, factor := range factors {
|
||||
if float64(factor) > max {
|
||||
@@ -91,16 +92,20 @@ func migrateMinSpanToMaxPerRow(panel map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use the previous factor as maxPerRow
|
||||
// Use the previous factor as maxPerRow (matching frontend logic exactly)
|
||||
// The frontend code does: factors[findIndex(factors, (o) => o > max) - 1]
|
||||
// When findIndex returns -1, this becomes factors[-2] which is undefined
|
||||
// So we need to match this behavior
|
||||
if factorIndex > 0 {
|
||||
panel["maxPerRow"] = factors[factorIndex-1]
|
||||
} else if factorIndex == 0 {
|
||||
// If the first factor is already greater than max, use 1
|
||||
panel["maxPerRow"] = 1
|
||||
} else {
|
||||
// If no factor is greater than max, use the largest factor
|
||||
panel["maxPerRow"] = factors[len(factors)-1]
|
||||
}
|
||||
// If no factor is greater than max, don't set maxPerRow
|
||||
// This matches frontend behavior when findIndex returns -1
|
||||
// The frontend sets maxPerRow to undefined, which gets filtered out
|
||||
// So we don't set it at all
|
||||
|
||||
// Remove the minSpan property
|
||||
delete(panel, "minSpan")
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestV17(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with minSpan 1 gets converted to maxPerRow 24",
|
||||
name: "panel with minSpan 1 gets minSpan removed without setting maxPerRow",
|
||||
input: map[string]interface{}{
|
||||
"title": "V17 MinSpan Migration Test",
|
||||
"schemaVersion": 16,
|
||||
@@ -162,10 +162,9 @@ func TestV17(t *testing.T) {
|
||||
"schemaVersion": 17,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 6,
|
||||
"type": "graph",
|
||||
"title": "Tiny Panel",
|
||||
"maxPerRow": 24,
|
||||
"id": 6,
|
||||
"type": "graph",
|
||||
"title": "Tiny Panel",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,10 +47,10 @@ import "context"
|
||||
func V18(_ context.Context, dashboard map[string]interface{}) error {
|
||||
dashboard["schemaVersion"] = 18
|
||||
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok {
|
||||
if !IsArray(dashboard["panels"]) {
|
||||
return nil
|
||||
}
|
||||
panels := dashboard["panels"].([]interface{})
|
||||
|
||||
for _, p := range panels {
|
||||
panel, ok := p.(map[string]interface{})
|
||||
@@ -91,12 +91,15 @@ func migrateGaugePanelOptions(panel map[string]interface{}) {
|
||||
|
||||
options["valueOptions"] = valueOptions
|
||||
|
||||
if thresholds, ok := optionsGauge["thresholds"].([]interface{}); ok && len(thresholds) > 0 {
|
||||
reversedThresholds := make([]interface{}, len(thresholds))
|
||||
for i, threshold := range thresholds {
|
||||
reversedThresholds[len(thresholds)-1-i] = threshold
|
||||
if IsArray(optionsGauge["thresholds"]) {
|
||||
thresholds := optionsGauge["thresholds"].([]interface{})
|
||||
if len(thresholds) > 0 {
|
||||
reversedThresholds := make([]interface{}, len(thresholds))
|
||||
for i, threshold := range thresholds {
|
||||
reversedThresholds[len(thresholds)-1-i] = threshold
|
||||
}
|
||||
options["thresholds"] = reversedThresholds
|
||||
}
|
||||
options["thresholds"] = reversedThresholds
|
||||
}
|
||||
|
||||
// Copy any other properties from options-gauge to options
|
||||
|
||||
@@ -84,9 +84,14 @@ func upgradePanelLink(link map[string]interface{}) map[string]interface{} {
|
||||
url := buildPanelLinkURL(link)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"url": url,
|
||||
"title": GetStringValue(link, "title"),
|
||||
"targetBlank": GetBoolValue(link, "targetBlank"),
|
||||
"url": url,
|
||||
"title": GetStringValue(link, "title"),
|
||||
}
|
||||
|
||||
// Only add targetBlank if it's explicitly set to true (matches frontend behavior)
|
||||
// Frontend filters out targetBlank: false as a default, so we shouldn't add it
|
||||
if GetBoolValue(link, "targetBlank") {
|
||||
result["targetBlank"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -97,12 +102,12 @@ func buildPanelLinkURL(link map[string]interface{}) string {
|
||||
var url string
|
||||
|
||||
// Check for existing URL first
|
||||
if existingURL, ok := link["url"].(string); ok && existingURL != "" {
|
||||
if existingURL := GetStringValue(link, "url"); existingURL != "" {
|
||||
url = existingURL
|
||||
} else if dashboard, ok := link["dashboard"].(string); ok && dashboard != "" {
|
||||
} else if dashboard := GetStringValue(link, "dashboard"); dashboard != "" {
|
||||
// Convert dashboard name to slugified URL
|
||||
url = "dashboard/db/" + slugifyForURL(dashboard)
|
||||
} else if dashUri, ok := link["dashUri"].(string); ok && dashUri != "" {
|
||||
} else if dashUri := GetStringValue(link, "dashUri"); dashUri != "" {
|
||||
url = "dashboard/" + dashUri
|
||||
} else {
|
||||
// Default fallback
|
||||
@@ -120,7 +125,7 @@ func buildPanelLinkURL(link map[string]interface{}) string {
|
||||
params = append(params, "$__all_variables")
|
||||
}
|
||||
|
||||
if customParams, ok := link["params"].(string); ok && customParams != "" {
|
||||
if customParams := GetStringValue(link, "params"); customParams != "" {
|
||||
params = append(params, customParams)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "dashboard/db/my-dashboard",
|
||||
"title": "Dashboard Link",
|
||||
"targetBlank": false,
|
||||
"url": "dashboard/db/my-dashboard",
|
||||
"title": "Dashboard Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -67,9 +66,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "dashboard/my-dashboard-uid",
|
||||
"title": "DashUri Link",
|
||||
"targetBlank": false,
|
||||
"url": "dashboard/my-dashboard-uid",
|
||||
"title": "DashUri Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -102,9 +100,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "http://example.com?$__url_time_range",
|
||||
"title": "KeepTime Link",
|
||||
"targetBlank": false,
|
||||
"url": "http://example.com?$__url_time_range",
|
||||
"title": "KeepTime Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -137,9 +134,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "http://example.com?$__all_variables",
|
||||
"title": "IncludeVars Link",
|
||||
"targetBlank": false,
|
||||
"url": "http://example.com?$__all_variables",
|
||||
"title": "IncludeVars Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -172,9 +168,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "http://example.com?customParam=value",
|
||||
"title": "Custom Params Link",
|
||||
"targetBlank": false,
|
||||
"url": "http://example.com?customParam=value",
|
||||
"title": "Custom Params Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -229,9 +224,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "http://example.com",
|
||||
"title": "Existing URL Link",
|
||||
"targetBlank": false,
|
||||
"url": "http://example.com",
|
||||
"title": "Existing URL Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -245,9 +239,8 @@ func TestV19(t *testing.T) {
|
||||
"id": 1,
|
||||
"links": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "http://example.com",
|
||||
"title": "Existing URL Link",
|
||||
"targetBlank": false,
|
||||
"url": "http://example.com",
|
||||
"title": "Existing URL Link",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,8 +3,6 @@ package schemaversion
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/utils"
|
||||
)
|
||||
|
||||
// V20 migrates legacy variable syntax in data links and field options.
|
||||
@@ -92,7 +90,7 @@ func V20(_ context.Context, dashboard map[string]interface{}) error {
|
||||
// updateDataLinksVariableSyntax updates variable syntax in panel data links
|
||||
func updateDataLinksVariableSyntax(options map[string]interface{}) {
|
||||
dataLinks, ok := options["dataLinks"].([]interface{})
|
||||
if !ok || !utils.IsArray(dataLinks) {
|
||||
if !ok || !IsArray(dataLinks) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,7 +122,7 @@ func updateFieldOptionsVariableSyntax(options map[string]interface{}) {
|
||||
|
||||
// Update field option links
|
||||
links, ok := defaults["links"].([]interface{})
|
||||
if !ok || !utils.IsArray(links) {
|
||||
if !ok || !IsArray(links) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ package schemaversion
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/utils"
|
||||
)
|
||||
|
||||
// V21 migrates data links to replace __series.labels with __field.labels.
|
||||
@@ -82,7 +80,7 @@ func V21(_ context.Context, dashboard map[string]interface{}) error {
|
||||
|
||||
func updateDataLinks(options map[string]interface{}) {
|
||||
dataLinks, ok := options["dataLinks"].([]interface{})
|
||||
if !ok || !utils.IsArray(dataLinks) {
|
||||
if !ok || !IsArray(dataLinks) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ package schemaversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/utils"
|
||||
)
|
||||
|
||||
// V23 migrates multi variables to ensure their current property is aligned with their multi property.
|
||||
@@ -89,6 +87,7 @@ func isEmptyObject(value interface{}) bool {
|
||||
}
|
||||
|
||||
// alignCurrentWithMulti aligns the current property with the multi property
|
||||
// This matches the frontend's alignCurrentWithMulti function behavior
|
||||
func alignCurrentWithMulti(current map[string]interface{}, multi bool) map[string]interface{} {
|
||||
if current == nil {
|
||||
return current
|
||||
@@ -100,38 +99,53 @@ func alignCurrentWithMulti(current map[string]interface{}, multi bool) map[strin
|
||||
}
|
||||
|
||||
if multi {
|
||||
// Convert single values to arrays
|
||||
if value, ok := result["value"]; ok {
|
||||
if !utils.IsArray(value) {
|
||||
result["value"] = []interface{}{value}
|
||||
}
|
||||
}
|
||||
if text, ok := result["text"]; ok {
|
||||
if !utils.IsArray(text) {
|
||||
result["text"] = []interface{}{text}
|
||||
}
|
||||
}
|
||||
convertToArrays(result)
|
||||
} else {
|
||||
// Convert arrays to single values
|
||||
if value, ok := result["value"]; ok {
|
||||
if utils.IsArray(value) {
|
||||
if arr, ok := value.([]interface{}); ok && len(arr) > 0 {
|
||||
result["value"] = arr[0]
|
||||
} else {
|
||||
result["value"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if text, ok := result["text"]; ok {
|
||||
if utils.IsArray(text) {
|
||||
if arr, ok := text.([]interface{}); ok && len(arr) > 0 {
|
||||
result["text"] = arr[0]
|
||||
} else {
|
||||
result["text"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
convertToSingleValues(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// convertToArrays converts single values to arrays (match frontend behavior)
|
||||
// Frontend only converts when current.value is NOT an array
|
||||
func convertToArrays(result map[string]interface{}) {
|
||||
value, hasValue := result["value"]
|
||||
if !hasValue || IsArray(value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert value to array
|
||||
result["value"] = []interface{}{value}
|
||||
|
||||
// Only convert text to array when we're converting value
|
||||
if text, ok := result["text"]; ok && !IsArray(text) {
|
||||
result["text"] = []interface{}{text}
|
||||
}
|
||||
}
|
||||
|
||||
// convertToSingleValues converts arrays to single values (both value and text must be single values)
|
||||
func convertToSingleValues(result map[string]interface{}) {
|
||||
convertArrayToSingle(result, "value")
|
||||
convertArrayToSingle(result, "text")
|
||||
}
|
||||
|
||||
// convertArrayToSingle converts an array field to a single value
|
||||
func convertArrayToSingle(result map[string]interface{}, key string) {
|
||||
value, ok := result[key]
|
||||
if !ok || !IsArray(value) {
|
||||
return
|
||||
}
|
||||
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
result[key] = ""
|
||||
return
|
||||
}
|
||||
|
||||
if len(arr) > 0 {
|
||||
result[key] = arr[0]
|
||||
} else {
|
||||
result[key] = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,229 +1,162 @@
|
||||
package schemaversion_test
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
)
|
||||
|
||||
func TestV23(t *testing.T) {
|
||||
tests := []migrationTestCase{
|
||||
func TestV23TemplateVariableMigration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "multi variable with single value gets converted to array",
|
||||
name: "align_text_with_multi_for_multi_variables",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 Multi Variable Single Value Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "multi_single_value",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{"value": "A", "text": "A", "selected": true},
|
||||
"name": "multiVar",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{
|
||||
"text": "All",
|
||||
"value": "All",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 Multi Variable Single Value Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "multi_single_value",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{"value": []interface{}{"A"}, "text": []interface{}{"A"}, "selected": true},
|
||||
"name": "multiVar",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{
|
||||
"text": []interface{}{"All"},
|
||||
"value": []interface{}{"All"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "For multi variables, both text and value should be converted to arrays to match frontend alignCurrentWithMulti behavior",
|
||||
},
|
||||
{
|
||||
name: "multi variable with array value stays as array",
|
||||
name: "preserve_text_as_string_when_value_already_array",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 Multi Variable Array Value Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "multi_array_value",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{"value": []interface{}{"B", "C"}, "text": []interface{}{"B", "C"}, "selected": true},
|
||||
"name": "multiVar",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{
|
||||
"text": "All",
|
||||
"value": []interface{}{"All"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 Multi Variable Array Value Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "multi_array_value",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{"value": []interface{}{"B", "C"}, "text": []interface{}{"B", "C"}, "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-multi variable with array value gets converted to single value",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 Non-Multi Variable Array Value Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "non_multi_array_value",
|
||||
"multi": false,
|
||||
"current": map[string]interface{}{"value": []interface{}{"D"}, "text": []interface{}{"D"}, "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 Non-Multi Variable Array Value Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "non_multi_array_value",
|
||||
"multi": false,
|
||||
"current": map[string]interface{}{"value": "D", "text": "D", "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-multi variable with single value stays as single value",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 Non-Multi Variable Single Value Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "non_multi_single_value",
|
||||
"multi": false,
|
||||
"current": map[string]interface{}{"value": "E", "text": "E", "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 Non-Multi Variable Single Value Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "non_multi_single_value",
|
||||
"multi": false,
|
||||
"current": map[string]interface{}{"value": "E", "text": "E", "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable without multi property is unchanged",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 No Multi Property Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "no_multi_property",
|
||||
"current": map[string]interface{}{"value": "F", "text": "F", "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 No Multi Property Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "no_multi_property",
|
||||
"current": map[string]interface{}{"value": "F", "text": "F", "selected": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable with empty current is unchanged",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 Empty Current Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "empty_current",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 Empty Current Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "empty_current",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable with nil current is unchanged",
|
||||
input: map[string]interface{}{
|
||||
"title": "V23 Nil Current Test",
|
||||
"schemaVersion": 22,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "nil_current",
|
||||
"multi": true,
|
||||
"current": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V23 Nil Current Test",
|
||||
"schemaVersion": 23,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "nil_current",
|
||||
"multi": true,
|
||||
"current": nil,
|
||||
"name": "multiVar",
|
||||
"multi": true,
|
||||
"current": map[string]interface{}{
|
||||
"text": "All",
|
||||
"value": []interface{}{"All"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "When value is already an array, text should remain as string to match frontend behavior",
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, schemaversion.V23)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dashboard := map[string]interface{}{
|
||||
"schemaVersion": 22,
|
||||
}
|
||||
// Copy templating from input
|
||||
if templating, ok := tt.input["templating"]; ok {
|
||||
dashboard["templating"] = templating
|
||||
}
|
||||
|
||||
err := V23(context.Background(), dashboard)
|
||||
if err != nil {
|
||||
t.Fatalf("V23 migration failed: %v", err)
|
||||
}
|
||||
|
||||
if dashboard["schemaVersion"] != 23 {
|
||||
t.Errorf("Expected schemaVersion to be 23, got %v", dashboard["schemaVersion"])
|
||||
}
|
||||
|
||||
// Verify templating structure
|
||||
templating, ok := dashboard["templating"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected templating to be a map")
|
||||
}
|
||||
|
||||
list, ok := templating["list"].([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
t.Fatalf("Expected templating.list to be a non-empty array")
|
||||
}
|
||||
|
||||
variable, ok := list[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected variable to be a map")
|
||||
}
|
||||
|
||||
// Check current property alignment
|
||||
expectedTemplating := tt.expected["templating"].(map[string]interface{})
|
||||
expectedList := expectedTemplating["list"].([]interface{})
|
||||
expectedVariable := expectedList[0].(map[string]interface{})
|
||||
|
||||
actualCurrent := variable["current"].(map[string]interface{})
|
||||
expectedCurrent := expectedVariable["current"].(map[string]interface{})
|
||||
|
||||
if !compareValues(actualCurrent["text"], expectedCurrent["text"]) {
|
||||
t.Errorf("Text alignment failed. Expected: %v, Got: %v", expectedCurrent["text"], actualCurrent["text"])
|
||||
}
|
||||
|
||||
if !compareValues(actualCurrent["value"], expectedCurrent["value"]) {
|
||||
t.Errorf("Value alignment failed. Expected: %v, Got: %v", expectedCurrent["value"], actualCurrent["value"])
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: %s", tt.name, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare values
|
||||
func compareValues(actual, expected interface{}) bool {
|
||||
if actual == nil && expected == nil {
|
||||
return true
|
||||
}
|
||||
if actual == nil || expected == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
actualSlice, actualOk := actual.([]interface{})
|
||||
expectedSlice, expectedOk := expected.([]interface{})
|
||||
|
||||
if actualOk && expectedOk {
|
||||
if len(actualSlice) != len(expectedSlice) {
|
||||
return false
|
||||
}
|
||||
for i, expectedValue := range expectedSlice {
|
||||
if actualSlice[i] != expectedValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return actual == expected
|
||||
}
|
||||
|
||||
@@ -236,21 +236,19 @@ func tablePanelChangedHandler(panel map[string]interface{}) error {
|
||||
overrides = []interface{}{}
|
||||
}
|
||||
|
||||
panel["transformations"] = transformations
|
||||
// Only add transformations if they're not empty - frontend omits empty arrays
|
||||
if len(transformations) > 0 {
|
||||
panel["transformations"] = transformations
|
||||
}
|
||||
panel["fieldConfig"] = map[string]interface{}{
|
||||
"defaults": defaults,
|
||||
"overrides": overrides,
|
||||
}
|
||||
|
||||
// Add default table panel options to match frontend behavior
|
||||
// Add minimal table panel options to match frontend behavior
|
||||
// Frontend doesn't add default footer options, so we don't either
|
||||
panel["options"] = map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
}
|
||||
|
||||
@@ -259,6 +257,9 @@ func tablePanelChangedHandler(panel map[string]interface{}) error {
|
||||
delete(panel, "transform")
|
||||
delete(panel, "columns")
|
||||
|
||||
// Remove legend property - frontend table panel migration doesn't preserve it
|
||||
delete(panel, "legend")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -394,12 +395,7 @@ func migrateTableStyleToOverride(style map[string]interface{}) map[string]interf
|
||||
}
|
||||
|
||||
// Add decimals
|
||||
if decimals, ok := style["decimals"].(float64); ok {
|
||||
properties = append(properties, map[string]interface{}{
|
||||
"id": "decimals",
|
||||
"value": int(decimals),
|
||||
})
|
||||
} else if decimals, ok := style["decimals"].(int); ok {
|
||||
if decimals := GetIntValue(style, "decimals", -1); decimals != -1 {
|
||||
properties = append(properties, map[string]interface{}{
|
||||
"id": "decimals",
|
||||
"value": decimals,
|
||||
@@ -456,9 +452,11 @@ func migrateTableStyleToOverride(style map[string]interface{}) map[string]interf
|
||||
|
||||
// Handle alignment
|
||||
if align, ok := style["align"].(string); ok && align != "" {
|
||||
alignValue := align
|
||||
var alignValue interface{}
|
||||
if align == "auto" {
|
||||
alignValue = ""
|
||||
alignValue = nil // Frontend sets to null and filters it out
|
||||
} else {
|
||||
alignValue = align
|
||||
}
|
||||
properties = append(properties, map[string]interface{}{
|
||||
"id": "custom.align",
|
||||
@@ -514,7 +512,7 @@ func migrateDefaults(prevDefaults map[string]interface{}) map[string]interface{}
|
||||
defaults["thresholds"] = map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"color": "green"},
|
||||
map[string]interface{}{"color": "green", "value": (*float64)(nil)},
|
||||
map[string]interface{}{"color": "red", "value": 80},
|
||||
},
|
||||
}
|
||||
@@ -524,22 +522,24 @@ func migrateDefaults(prevDefaults map[string]interface{}) map[string]interface{}
|
||||
return defaults
|
||||
}
|
||||
|
||||
if unit, ok := prevDefaults["unit"].(string); ok && unit != "" {
|
||||
if unit := GetStringValue(prevDefaults, "unit"); unit != "" {
|
||||
defaults["unit"] = unit
|
||||
}
|
||||
|
||||
if decimals, ok := prevDefaults["decimals"].(float64); ok {
|
||||
defaults["decimals"] = int(decimals)
|
||||
if decimals := GetIntValue(prevDefaults, "decimals", -1); decimals != -1 {
|
||||
defaults["decimals"] = decimals
|
||||
}
|
||||
|
||||
if alias, ok := prevDefaults["alias"].(string); ok && alias != "" {
|
||||
if alias, ok := prevDefaults["alias"].(string); ok {
|
||||
defaults["displayName"] = alias
|
||||
}
|
||||
|
||||
if align, ok := prevDefaults["align"].(string); ok && align != "" {
|
||||
alignValue := align
|
||||
var alignValue interface{}
|
||||
if align == "auto" {
|
||||
alignValue = ""
|
||||
alignValue = nil // Frontend sets to null and filters it out
|
||||
} else {
|
||||
alignValue = align
|
||||
}
|
||||
defaults["custom"].(map[string]interface{})["align"] = alignValue
|
||||
}
|
||||
@@ -576,19 +576,11 @@ func generateThresholds(thresholds []interface{}, colors []interface{}) []interf
|
||||
|
||||
steps = append(steps, map[string]interface{}{
|
||||
"color": baseColor,
|
||||
"value": nil,
|
||||
"value": (*float64)(nil),
|
||||
})
|
||||
|
||||
// Add threshold steps
|
||||
for i, threshold := range thresholds {
|
||||
var color interface{}
|
||||
// Use colors[i+1] for the i-th threshold (colors[0] was used for base step)
|
||||
if i+1 < len(colors) && colors[i+1] != nil {
|
||||
color = colors[i+1]
|
||||
} else {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
var value float64
|
||||
switch v := threshold.(type) {
|
||||
case string:
|
||||
@@ -601,10 +593,17 @@ func generateThresholds(thresholds []interface{}, colors []interface{}) []interf
|
||||
value = float64(v)
|
||||
}
|
||||
|
||||
steps = append(steps, map[string]interface{}{
|
||||
"color": color,
|
||||
step := map[string]interface{}{
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
|
||||
// Only add color if there's a corresponding color in the colors array
|
||||
// This matches the frontend behavior where colors[idx] might be undefined
|
||||
if i+1 < len(colors) && colors[i+1] != nil {
|
||||
step["color"] = colors[i+1]
|
||||
}
|
||||
|
||||
steps = append(steps, step)
|
||||
}
|
||||
|
||||
return steps
|
||||
|
||||
@@ -1,851 +1,85 @@
|
||||
package schemaversion_test
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
)
|
||||
|
||||
const (
|
||||
// The pluginVersion to set after simulating auto-migrate for angular panels
|
||||
pluginVersionForAutoMigrate = "12.1.0"
|
||||
)
|
||||
|
||||
func TestV24(t *testing.T) {
|
||||
tests := []migrationTestCase{
|
||||
func TestV24TablePanelMigration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "should migrate basic Angular table with defaults",
|
||||
name: "preserve_empty_display_name",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
"type": "table",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "table",
|
||||
"title": "Basic Table",
|
||||
"legend": true,
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"thresholds": []interface{}{"10", "20", "30"},
|
||||
"colors": []interface{}{"red", "yellow", "green"},
|
||||
"pattern": "/.*/",
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"alias": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "table",
|
||||
"title": "Basic Table",
|
||||
"legend": true,
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"custom": map[string]interface{}{
|
||||
"align": "auto",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"value": nil, "color": "red"},
|
||||
map[string]interface{}{"value": float64(10), "color": "yellow"},
|
||||
map[string]interface{}{"value": float64(20), "color": "green"},
|
||||
map[string]interface{}{"value": float64(30), "color": "red"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"type": "table",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"displayName": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "Empty displayName values should be preserved when migrating from empty alias in table panel styles",
|
||||
},
|
||||
{
|
||||
name: "should migrate table with complex defaults and overrides",
|
||||
name: "do_not_add_empty_transformations",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"type": "table",
|
||||
"title": "Complex Table",
|
||||
"styles": []interface{}{
|
||||
// Default style
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "bytes",
|
||||
"decimals": float64(2),
|
||||
"align": "center",
|
||||
"colorMode": "cell",
|
||||
"thresholds": []interface{}{"100", "500"},
|
||||
"colors": []interface{}{"green", "yellow", "red"},
|
||||
},
|
||||
// Column-specific override with exact name
|
||||
map[string]interface{}{
|
||||
"pattern": "Status",
|
||||
"alias": "Current Status",
|
||||
"unit": "short",
|
||||
"decimals": float64(0),
|
||||
"colorMode": "value",
|
||||
"align": "left",
|
||||
},
|
||||
// Column-specific override with regex pattern
|
||||
map[string]interface{}{
|
||||
"pattern": "/Error.*/",
|
||||
"link": true,
|
||||
"linkUrl": "http://example.com/errors",
|
||||
"linkTooltip": "View error details",
|
||||
"linkTargetBlank": true,
|
||||
"colorMode": "row",
|
||||
"colors": []interface{}{"red", "orange"},
|
||||
},
|
||||
// Date column
|
||||
map[string]interface{}{
|
||||
"pattern": "Time",
|
||||
"type": "date",
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"alias": "Timestamp",
|
||||
},
|
||||
// Hidden column
|
||||
map[string]interface{}{
|
||||
"pattern": "Hidden",
|
||||
"type": "hidden",
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"type": "table",
|
||||
"title": "Test Table",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"type": "table",
|
||||
"title": "Complex Table",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "bytes",
|
||||
"decimals": 2,
|
||||
"custom": map[string]interface{}{
|
||||
"align": "center",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "color-background",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"value": nil, "color": "green"},
|
||||
map[string]interface{}{"value": float64(100), "color": "yellow"},
|
||||
map[string]interface{}{"value": float64(500), "color": "red"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{
|
||||
map[string]interface{}{
|
||||
"matcher": map[string]interface{}{
|
||||
"id": "byName",
|
||||
"options": "Status",
|
||||
},
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{"id": "displayName", "value": "Current Status"},
|
||||
map[string]interface{}{"id": "unit", "value": "short"},
|
||||
map[string]interface{}{"id": "decimals", "value": 0},
|
||||
map[string]interface{}{"id": "custom.cellOptions", "value": map[string]interface{}{"type": "color-text"}},
|
||||
map[string]interface{}{"id": "custom.align", "value": "left"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"matcher": map[string]interface{}{
|
||||
"id": "byRegexp",
|
||||
"options": "/Error.*/",
|
||||
},
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "links",
|
||||
"value": []interface{}{
|
||||
map[string]interface{}{
|
||||
"title": "View error details",
|
||||
"url": "http://example.com/errors",
|
||||
"targetBlank": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{"id": "custom.cellOptions", "value": map[string]interface{}{"type": "color-background"}},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"matcher": map[string]interface{}{
|
||||
"id": "byName",
|
||||
"options": "Time",
|
||||
},
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{"id": "displayName", "value": "Timestamp"},
|
||||
map[string]interface{}{"id": "unit", "value": "time: YYYY-MM-DD HH:mm:ss"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"matcher": map[string]interface{}{
|
||||
"id": "byName",
|
||||
"options": "Hidden",
|
||||
},
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{"id": "custom.hideFrom.viz", "value": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should migrate table with timeseries_aggregations transform",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"type": "table",
|
||||
"title": "Table with Aggregations",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "percent",
|
||||
"decimals": float64(1),
|
||||
},
|
||||
},
|
||||
"transform": "timeseries_aggregations",
|
||||
"columns": []interface{}{
|
||||
map[string]interface{}{"value": "avg", "text": "Average"},
|
||||
map[string]interface{}{"value": "max", "text": "Maximum"},
|
||||
map[string]interface{}{"value": "min", "text": "Minimum"},
|
||||
map[string]interface{}{"value": "total", "text": "Total"},
|
||||
map[string]interface{}{"value": "current", "text": "Current"},
|
||||
map[string]interface{}{"value": "count", "text": "Count"},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"type": "table",
|
||||
"title": "Table with Aggregations",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "percent",
|
||||
"decimals": 1,
|
||||
"custom": map[string]interface{}{
|
||||
"align": "auto",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"color": "green"},
|
||||
map[string]interface{}{"color": "red", "value": 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "reduce",
|
||||
"options": map[string]interface{}{
|
||||
"reducers": []interface{}{"mean", "max", "min", "sum", "lastNotNull", "count"},
|
||||
"includeTimeField": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should migrate table with timeseries_to_rows transform",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 4,
|
||||
"type": "table",
|
||||
"title": "Table with Rows Transform",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "short",
|
||||
},
|
||||
},
|
||||
"transform": "timeseries_to_rows",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 4,
|
||||
"type": "table",
|
||||
"title": "Table with Rows Transform",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "short",
|
||||
"custom": map[string]interface{}{
|
||||
"align": "auto",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"color": "green"},
|
||||
map[string]interface{}{"color": "red", "value": 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "seriesToRows",
|
||||
"options": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should migrate table with timeseries_to_columns transform",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 5,
|
||||
"type": "table",
|
||||
"title": "Table with Columns Transform",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "bytes",
|
||||
},
|
||||
},
|
||||
"transform": "timeseries_to_columns",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 5,
|
||||
"type": "table",
|
||||
"title": "Table with Columns Transform",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "bytes",
|
||||
"custom": map[string]interface{}{
|
||||
"align": "auto",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"color": "green"},
|
||||
map[string]interface{}{"color": "red", "value": 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "seriesToColumns",
|
||||
"options": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should migrate table with table merge transform",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 6,
|
||||
"type": "table",
|
||||
"title": "Table with Merge Transform",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"align": "auto",
|
||||
},
|
||||
},
|
||||
"transform": "table",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 6,
|
||||
"type": "table",
|
||||
"title": "Table with Merge Transform",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"custom": map[string]interface{}{
|
||||
"align": "",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"color": "green"},
|
||||
map[string]interface{}{"color": "red", "value": 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "merge",
|
||||
"options": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should migrate table with existing transformations",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 7,
|
||||
"type": "table",
|
||||
"title": "Table with Existing Transformations",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "short",
|
||||
},
|
||||
},
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "filterFieldsByName",
|
||||
"options": map[string]interface{}{
|
||||
"include": map[string]interface{}{
|
||||
"names": []interface{}{"field1", "field2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"transform": "timeseries_to_rows",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 7,
|
||||
"type": "table",
|
||||
"title": "Table with Existing Transformations",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "short",
|
||||
"custom": map[string]interface{}{
|
||||
"align": "auto",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"color": "green"},
|
||||
map[string]interface{}{"color": "red", "value": 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "filterFieldsByName",
|
||||
"options": map[string]interface{}{
|
||||
"include": map[string]interface{}{
|
||||
"names": []interface{}{"field1", "field2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "seriesToRows",
|
||||
"options": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should not migrate angular table without styles",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 8,
|
||||
"type": "table",
|
||||
"title": "Table without styles",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 8,
|
||||
"type": "table",
|
||||
"title": "Table without styles",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should not migrate react table (table2)",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 9,
|
||||
"type": "table",
|
||||
"table": "table2",
|
||||
"title": "React table",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "short",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 9,
|
||||
"type": "table",
|
||||
"table": "table2",
|
||||
"title": "React table",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"unit": "short",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should not migrate non-table panels",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 10,
|
||||
"type": "graph",
|
||||
"title": "Graph panel",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 11,
|
||||
"type": "singlestat",
|
||||
"title": "Singlestat panel",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 10,
|
||||
"type": "graph",
|
||||
"title": "Graph panel",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 11,
|
||||
"type": "singlestat",
|
||||
"title": "Singlestat panel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should handle mixed numeric and string thresholds",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 12,
|
||||
"type": "table",
|
||||
"title": "Mixed threshold types",
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "/.*/",
|
||||
"thresholds": []interface{}{10, "20", 30.5},
|
||||
"colors": []interface{}{"green", "yellow", "orange", "red"},
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 24,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 12,
|
||||
"type": "table",
|
||||
"title": "Mixed threshold types",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"custom": map[string]interface{}{
|
||||
"align": "auto",
|
||||
"cellOptions": map[string]interface{}{
|
||||
"type": "auto",
|
||||
},
|
||||
"footer": map[string]interface{}{
|
||||
"reducers": []interface{}{},
|
||||
},
|
||||
"inspect": false,
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"value": nil, "color": "green"},
|
||||
map[string]interface{}{"value": float64(10), "color": "yellow"},
|
||||
map[string]interface{}{"value": float64(20), "color": "orange"},
|
||||
map[string]interface{}{"value": float64(30.5), "color": "red"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"cellHeight": "sm",
|
||||
"footer": map[string]interface{}{
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": []interface{}{"sum"},
|
||||
"show": false,
|
||||
},
|
||||
"showHeader": true,
|
||||
},
|
||||
"transformations": []interface{}{},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
},
|
||||
},
|
||||
"type": "table",
|
||||
"title": "Test Table",
|
||||
},
|
||||
description: "V24 migration should not add empty transformations arrays to table panels",
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, schemaversion.V24)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dashboard := map[string]interface{}{
|
||||
"schemaVersion": 23,
|
||||
"panels": []interface{}{tt.input},
|
||||
}
|
||||
|
||||
err := V24(context.Background(), dashboard)
|
||||
if err != nil {
|
||||
t.Fatalf("V24 migration failed: %v", err)
|
||||
}
|
||||
|
||||
if dashboard["schemaVersion"] != 24 {
|
||||
t.Errorf("Expected schemaVersion to be 24, got %v", dashboard["schemaVersion"])
|
||||
}
|
||||
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok || len(panels) == 0 {
|
||||
t.Fatalf("Expected panels array with at least one panel")
|
||||
}
|
||||
|
||||
panel, ok := panels[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected panel to be a map")
|
||||
}
|
||||
|
||||
// Check that transformations array is not added if it wasn't in input
|
||||
if _, hasTransformations := tt.input["transformations"]; !hasTransformations {
|
||||
if _, exists := panel["transformations"]; exists {
|
||||
t.Errorf("Empty transformations array should not be added")
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: %s", tt.name, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +161,8 @@ func migrateConstantVariable(variable map[string]interface{}) {
|
||||
variable["options"] = options
|
||||
|
||||
// Convert to textbox if hide is 0 (dontHide) or 1 (hideLabel)
|
||||
if hide, ok := variable["hide"].(float64); ok {
|
||||
if hide == 0 || hide == 1 {
|
||||
variable["type"] = "textbox"
|
||||
}
|
||||
hide := GetIntValue(variable, "hide", -1)
|
||||
if hide == 0 || hide == 1 {
|
||||
variable["type"] = "textbox"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +88,17 @@ func processPanels(panels []interface{}) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Migrate singlestat panels
|
||||
if p["type"] == "singlestat" || p["type"] == "grafana-singlestat-panel" {
|
||||
// Migrate singlestat panels (including those already auto-migrated to stat)
|
||||
if p["type"] == "singlestat" || p["type"] == "grafana-singlestat-panel" ||
|
||||
p["autoMigrateFrom"] == "singlestat" || p["autoMigrateFrom"] == "grafana-singlestat-panel" {
|
||||
if err := migrateSinglestatPanel(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize existing stat panels to ensure they have current default options
|
||||
if p["type"] == "stat" {
|
||||
normalizeStatPanel(p)
|
||||
}
|
||||
// Note: Panel defaults (including options object) are already applied
|
||||
// by applyPanelDefaults() in the main migration flow for ALL panels
|
||||
// No need for stat-specific normalization
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -121,9 +121,12 @@ func migrateSinglestatPanel(panel map[string]interface{}) error {
|
||||
// panel.changePlugin(gaugePanelPlugin)
|
||||
|
||||
// Store original type for migration context (only for stat/gauge migration)
|
||||
// This matches the frontend behavior where autoMigrateFrom is set in PanelModel.restoreModel
|
||||
// Set autoMigrateFrom to track the original type for proper migration logic
|
||||
originalType := panel["type"].(string)
|
||||
panel["autoMigrateFrom"] = panel["type"]
|
||||
// Only set autoMigrateFrom if it doesn't already exist (preserve frontend defaults)
|
||||
if _, exists := panel["autoMigrateFrom"]; !exists {
|
||||
panel["autoMigrateFrom"] = originalType
|
||||
}
|
||||
panel["type"] = targetType
|
||||
panel["pluginVersion"] = pluginVersionForAutoMigrate
|
||||
|
||||
@@ -133,39 +136,15 @@ func migrateSinglestatPanel(panel map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeStatPanel ensures existing stat panels have all current default options
|
||||
func normalizeStatPanel(panel map[string]interface{}) {
|
||||
if panel["options"] == nil {
|
||||
panel["options"] = map[string]interface{}{}
|
||||
}
|
||||
|
||||
options := panel["options"].(map[string]interface{})
|
||||
|
||||
// Apply missing default options that might not be present in older stat panels
|
||||
if _, exists := options["percentChangeColorMode"]; !exists {
|
||||
options["percentChangeColorMode"] = "standard"
|
||||
}
|
||||
|
||||
// Ensure other critical defaults are present
|
||||
if _, exists := options["justifyMode"]; !exists {
|
||||
options["justifyMode"] = "auto"
|
||||
}
|
||||
|
||||
if _, exists := options["textMode"]; !exists {
|
||||
options["textMode"] = "auto"
|
||||
}
|
||||
|
||||
if _, exists := options["wideLayout"]; !exists {
|
||||
options["wideLayout"] = true
|
||||
}
|
||||
|
||||
if _, exists := options["showPercentChange"]; !exists {
|
||||
options["showPercentChange"] = false
|
||||
}
|
||||
}
|
||||
|
||||
// migrateSinglestatOptions handles the complete migration of singlestat panel options and field config
|
||||
func migrateSinglestatOptions(panel map[string]interface{}, originalType string) {
|
||||
// Preserve important panel-level properties that should not be removed
|
||||
// These properties are preserved by the frontend's getSaveModel() method
|
||||
var maxDataPoints interface{}
|
||||
if mdp, exists := panel["maxDataPoints"]; exists {
|
||||
maxDataPoints = mdp
|
||||
}
|
||||
|
||||
// Initialize field config if not present
|
||||
if panel["fieldConfig"] == nil {
|
||||
panel["fieldConfig"] = map[string]interface{}{
|
||||
@@ -178,7 +157,13 @@ func migrateSinglestatOptions(panel map[string]interface{}, originalType string)
|
||||
defaults := fieldConfig["defaults"].(map[string]interface{})
|
||||
|
||||
// Migrate from angular singlestat configuration using appropriate strategy
|
||||
if originalType == "grafana-singlestat-panel" {
|
||||
// Use autoMigrateFrom if available, otherwise use originalType
|
||||
migrationType := originalType
|
||||
if autoMigrateFrom, exists := panel["autoMigrateFrom"].(string); exists {
|
||||
migrationType = autoMigrateFrom
|
||||
}
|
||||
|
||||
if migrationType == "grafana-singlestat-panel" {
|
||||
migrateGrafanaSinglestatPanel(panel, defaults)
|
||||
} else {
|
||||
migratetSinglestat(panel, defaults)
|
||||
@@ -187,106 +172,73 @@ func migrateSinglestatOptions(panel map[string]interface{}, originalType string)
|
||||
// Apply shared migration logic
|
||||
applySharedSinglestatMigration(defaults)
|
||||
|
||||
// Apply complete stat panel defaults (matches frontend getPanelOptionsWithDefaults)
|
||||
// The frontend applies these defaults after migration via applyPluginOptionDefaults
|
||||
applyCompleteStatPanelDefaults(panel)
|
||||
|
||||
// Create proper fieldConfig structure from defaults
|
||||
createFieldConfigFromDefaults(panel, defaults)
|
||||
|
||||
// Restore preserved panel-level properties
|
||||
if maxDataPoints != nil {
|
||||
panel["maxDataPoints"] = maxDataPoints
|
||||
}
|
||||
|
||||
// Clean up old angular properties after migration
|
||||
cleanupAngularProperties(panel)
|
||||
}
|
||||
|
||||
// getDefaultStatOptions returns the default options structure for stat panels
|
||||
// This matches the frontend's stat panel defaultOptions exactly
|
||||
func getDefaultStatOptions() map[string]interface{} {
|
||||
// For now, return the explicit defaults until we integrate the centralized system
|
||||
return map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"mean"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"lastNotNull"}, // Matches frontend: ReducerID.lastNotNull
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "auto",
|
||||
}
|
||||
}
|
||||
|
||||
// migratetSinglestat handles explicit migration from 'singlestat' panels
|
||||
// Based on explicit migration logic in DashboardMigrator.ts
|
||||
// Based on frontend migrateFromAngularSinglestat function
|
||||
func migratetSinglestat(panel map[string]interface{}, defaults map[string]interface{}) {
|
||||
angularOpts := extractAngularOptions(panel)
|
||||
|
||||
// Explicit migration uses standard stat panel defaults
|
||||
options := getDefaultStatOptions()
|
||||
|
||||
// Explicit migration: always set a reducer with fallback
|
||||
// Extract valueName for reducer mapping (matches frontend migrateFromAngularSinglestat)
|
||||
var valueName string
|
||||
if vn, ok := angularOpts["valueName"].(string); ok {
|
||||
valueName = vn
|
||||
}
|
||||
|
||||
// Set calcs based on valueName (matches frontend: calcs: [reducer ? reducer.id : ReducerID.mean])
|
||||
var calcs []string
|
||||
if reducer := getReducerForValueName(valueName); reducer != "" {
|
||||
options["reduceOptions"].(map[string]interface{})["calcs"] = []string{reducer}
|
||||
calcs = []string{reducer}
|
||||
} else {
|
||||
// Explicit migration fallback: use mean for invalid reducers
|
||||
options["reduceOptions"].(map[string]interface{})["calcs"] = []string{"mean"}
|
||||
// Use mean as fallback (matches frontend migrateFromAngularSinglestat: ReducerID.mean)
|
||||
calcs = []string{"mean"}
|
||||
}
|
||||
|
||||
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
|
||||
migrateThresholds(angularOpts, defaults)
|
||||
|
||||
// If no thresholds were set from angular migration, add default stat panel thresholds
|
||||
// This matches the behavior of frontend pluginLoaded which adds default thresholds
|
||||
if _, hasThresholds := defaults["thresholds"]; !hasThresholds {
|
||||
defaults["thresholds"] = map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply common angular option migrations (value mappings can now use threshold colors)
|
||||
applyCommonAngularMigration(panel, defaults, options, angularOpts)
|
||||
|
||||
panel["options"] = options
|
||||
}
|
||||
|
||||
// migrateGrafanaSinglestatPanel handles auto-migration from 'grafana-singlestat-panel'
|
||||
// Based on frontend changePlugin() and sharedSingleStatPanelChangedHandler logic
|
||||
func migrateGrafanaSinglestatPanel(panel map[string]interface{}, defaults map[string]interface{}) {
|
||||
angularOpts := extractAngularOptions(panel)
|
||||
|
||||
// Auto-migration uses different defaults (matches frontend changePlugin behavior)
|
||||
// Create options exactly like frontend migrateFromAngularSinglestat
|
||||
options := map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"lastNotNull"}, // Auto-migration default
|
||||
"calcs": calcs,
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "auto", // Auto-migration uses auto
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
"orientation": "horizontal", // Matches frontend migrateFromAngularSinglestat: VizOrientation.Horizontal
|
||||
}
|
||||
|
||||
// Auto-migration: only override if valid, otherwise keep default "lastNotNull"
|
||||
var valueName string
|
||||
if vn, ok := angularOpts["valueName"].(string); ok {
|
||||
valueName = vn
|
||||
}
|
||||
|
||||
if reducer := getReducerForValueName(valueName); reducer != "" {
|
||||
options["reduceOptions"].(map[string]interface{})["calcs"] = []string{reducer}
|
||||
}
|
||||
// No fallback - keeps the auto-migration default "lastNotNull"
|
||||
|
||||
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
|
||||
migrateThresholds(angularOpts, defaults)
|
||||
|
||||
@@ -298,7 +250,7 @@ func migrateGrafanaSinglestatPanel(panel map[string]interface{}, defaults map[st
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
"value": (*float64)(nil),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
@@ -311,7 +263,21 @@ func migrateGrafanaSinglestatPanel(panel map[string]interface{}, defaults map[st
|
||||
// Apply common angular option migrations (value mappings can now use threshold colors)
|
||||
applyCommonAngularMigration(panel, defaults, options, angularOpts)
|
||||
|
||||
panel["options"] = options
|
||||
// Merge new options with existing panel options to preserve properties like maxDataPoints
|
||||
if existingOptions, exists := panel["options"].(map[string]interface{}); exists {
|
||||
for key, value := range options {
|
||||
existingOptions[key] = value
|
||||
}
|
||||
} else {
|
||||
panel["options"] = options
|
||||
}
|
||||
}
|
||||
|
||||
// migrateGrafanaSinglestatPanel handles auto-migration from 'grafana-singlestat-panel'
|
||||
// Uses the same migration logic as singlestat panels since the frontend applies
|
||||
// migrateFromAngularSinglestat to both panel types.
|
||||
func migrateGrafanaSinglestatPanel(panel map[string]interface{}, defaults map[string]interface{}) {
|
||||
migratetSinglestat(panel, defaults)
|
||||
}
|
||||
|
||||
// migrateThresholds handles threshold migration for both singlestat panel types
|
||||
@@ -329,7 +295,7 @@ func migrateThresholds(angularOpts map[string]interface{}, defaults map[string]i
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
"value": (*float64)(nil), // Use pointer to ensure field is present in JSON
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
@@ -350,8 +316,7 @@ func applyCommonAngularMigration(panel map[string]interface{}, defaults map[stri
|
||||
options["reduceOptions"].(map[string]interface{})["fields"] = "/^" + tableColumn + "$/"
|
||||
}
|
||||
|
||||
// Migrate format to unit
|
||||
// Based on sharedSingleStatPanelChangedHandler line ~130: defaults.unit = prevPanel.format
|
||||
// Migrate unit from format property (matches frontend sharedSingleStatPanelChangedHandler)
|
||||
if format, ok := angularOpts["format"].(string); ok {
|
||||
defaults["unit"] = format
|
||||
}
|
||||
@@ -361,10 +326,11 @@ func applyCommonAngularMigration(panel map[string]interface{}, defaults map[stri
|
||||
defaults["decimals"] = decimals
|
||||
}
|
||||
|
||||
// Migrate null point mode
|
||||
if nullPointMode, ok := angularOpts["nullPointMode"]; ok {
|
||||
defaults["nullValueMode"] = nullPointMode
|
||||
}
|
||||
// Note: Frontend migrateFromAngularSinglestat does migrate nullPointMode to nullValueMode
|
||||
// but the frontend's getSaveModel() method removes it, so we don't add it here
|
||||
// if nullPointMode, ok := angularOpts["nullPointMode"]; ok {
|
||||
// defaults["nullValueMode"] = nullPointMode
|
||||
// }
|
||||
|
||||
// Migrate null text
|
||||
if nullText, ok := angularOpts["nullText"].(string); ok {
|
||||
@@ -376,19 +342,10 @@ func applyCommonAngularMigration(panel map[string]interface{}, defaults map[stri
|
||||
migrateValueMappings(angularOpts, defaults, valueMaps)
|
||||
|
||||
// Migrate sparkline configuration
|
||||
// Based on statPanelChangedHandler lines ~25-35: sparkline migration logic
|
||||
// Based on statPanelChangedHandler lines ~20-23: sparkline migration logic
|
||||
if sparkline, ok := angularOpts["sparkline"].(map[string]interface{}); ok {
|
||||
if show, ok := sparkline["show"].(bool); ok && show {
|
||||
options["graphMode"] = "area"
|
||||
|
||||
// Handle sparkline color
|
||||
// Based on statPanelChangedHandler lines ~30-35: sparkline lineColor handling
|
||||
if lineColor, ok := sparkline["lineColor"].(string); ok {
|
||||
defaults["color"] = map[string]interface{}{
|
||||
"mode": "fixed",
|
||||
"fixedColor": lineColor,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
options["graphMode"] = "none"
|
||||
}
|
||||
@@ -398,13 +355,13 @@ func applyCommonAngularMigration(panel map[string]interface{}, defaults map[stri
|
||||
}
|
||||
|
||||
// Migrate color configuration
|
||||
// Based on statPanelChangedHandler lines ~35-45: colorBackground and colorValue migration
|
||||
if colorBackground, ok := angularOpts["colorBackground"].(bool); ok && colorBackground {
|
||||
options["colorMode"] = "background"
|
||||
} else if colorValue, ok := angularOpts["colorValue"].(bool); ok && colorValue {
|
||||
options["colorMode"] = "value"
|
||||
} else {
|
||||
options["colorMode"] = "none"
|
||||
// Based on statPanelChangedHandler lines ~25-38: colorBackground and colorValue migration
|
||||
colorMode := determineColorMode(angularOpts)
|
||||
options["colorMode"] = colorMode
|
||||
|
||||
// Sparkline color migration only happens when colorMode is "none"
|
||||
if colorMode == "none" {
|
||||
migrateSparklineColor(angularOpts, defaults, options)
|
||||
}
|
||||
|
||||
// Migrate text mode
|
||||
@@ -419,6 +376,27 @@ func applyCommonAngularMigration(panel map[string]interface{}, defaults map[stri
|
||||
}
|
||||
}
|
||||
|
||||
// applyCompleteStatPanelDefaults applies the complete stat panel defaults
|
||||
// This matches the frontend's getPanelOptionsWithDefaults behavior after migration
|
||||
func applyCompleteStatPanelDefaults(panel map[string]interface{}) {
|
||||
// Get or create options object
|
||||
options, exists := panel["options"].(map[string]interface{})
|
||||
if !exists {
|
||||
options = map[string]interface{}{}
|
||||
panel["options"] = options
|
||||
}
|
||||
|
||||
defaultOptions := getDefaultStatOptions()
|
||||
|
||||
// Merge defaults with existing options, but don't override existing values
|
||||
// This matches the frontend's getPanelOptionsWithDefaults behavior
|
||||
for key, defaultValue := range defaultOptions {
|
||||
if _, exists := options[key]; !exists {
|
||||
options[key] = defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applySharedSinglestatMigration applies shared migration logic for all singlestat panels
|
||||
// Based on sharedSingleStatMigrationHandler in packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts
|
||||
func applySharedSinglestatMigration(defaults map[string]interface{}) {
|
||||
@@ -482,6 +460,7 @@ func getReducerForValueName(valueName string) string {
|
||||
"min": "min",
|
||||
"max": "max",
|
||||
"mean": "mean",
|
||||
"avg": "mean", // avg maps to mean
|
||||
"median": "median",
|
||||
"sum": "sum",
|
||||
"count": "count",
|
||||
@@ -512,7 +491,10 @@ func migrateThresholdsAndColors(defaults map[string]interface{}, thresholdsStr s
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
step["value"] = nil
|
||||
// Frontend expects explicit null value for first step, not omitted field
|
||||
// Use a pointer to ensure the field is present in JSON with null value
|
||||
var nullValue *float64
|
||||
step["value"] = nullValue
|
||||
} else if i-1 < len(thresholdValues) {
|
||||
if val, err := strconv.ParseFloat(strings.TrimSpace(thresholdValues[i-1]), 64); err == nil {
|
||||
step["value"] = val
|
||||
@@ -532,10 +514,19 @@ func migrateValueMappings(panel map[string]interface{}, defaults map[string]inte
|
||||
mappings := []interface{}{}
|
||||
mappingType := panel["mappingType"]
|
||||
|
||||
if mappingType == nil {
|
||||
if panel["valueMaps"] != nil && len(panel["valueMaps"].([]interface{})) > 0 {
|
||||
// Check for inconsistent mapping configuration
|
||||
// If panel has rangeMaps but mappingType is 1, or vice versa, fix it
|
||||
hasValueMaps := panel["valueMaps"] != nil && IsArray(panel["valueMaps"]) && len(panel["valueMaps"].([]interface{})) > 0
|
||||
hasRangeMaps := panel["rangeMaps"] != nil && IsArray(panel["rangeMaps"]) && len(panel["rangeMaps"].([]interface{})) > 0
|
||||
|
||||
if hasRangeMaps && mappingType == float64(1) {
|
||||
mappingType = 2
|
||||
} else if hasValueMaps && mappingType == float64(2) {
|
||||
mappingType = 1
|
||||
} else if mappingType == nil {
|
||||
if hasValueMaps {
|
||||
mappingType = 1
|
||||
} else if panel["rangeMaps"] != nil && len(panel["rangeMaps"].([]interface{})) > 0 {
|
||||
} else if hasRangeMaps {
|
||||
mappingType = 2
|
||||
}
|
||||
}
|
||||
@@ -575,9 +566,10 @@ func upgradeOldAngularValueMapping(old map[string]interface{}, thresholds interf
|
||||
newMappings := []interface{}{}
|
||||
|
||||
// Use the color we would have picked from thresholds
|
||||
// Frontend uses old.text to determine color, not old.value
|
||||
var color interface{}
|
||||
if value, ok := old["value"]; ok {
|
||||
if numeric, err := parseNumericValue(value); err == nil {
|
||||
if text, ok := old["text"].(string); ok {
|
||||
if numeric, err := parseNumericValue(text); err == nil {
|
||||
if thresholdsMap, ok := thresholds.(map[string]interface{}); ok {
|
||||
if steps, ok := thresholdsMap["steps"].([]interface{}); ok {
|
||||
level := getActiveThreshold(numeric, steps)
|
||||
@@ -714,34 +706,113 @@ func parseNumericValue(value interface{}) (float64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// createFieldConfigFromDefaults creates the proper fieldConfig structure from defaults
|
||||
// and removes all legacy properties from the panel
|
||||
func createFieldConfigFromDefaults(panel map[string]interface{}, defaults map[string]interface{}) {
|
||||
// Ensure fieldConfig exists
|
||||
if panel["fieldConfig"] == nil {
|
||||
panel["fieldConfig"] = map[string]interface{}{
|
||||
"defaults": map[string]interface{}{},
|
||||
"overrides": []interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
fieldConfig := panel["fieldConfig"].(map[string]interface{})
|
||||
fieldDefaults := fieldConfig["defaults"].(map[string]interface{})
|
||||
|
||||
// Copy all defaults to fieldConfig.defaults
|
||||
for key, value := range defaults {
|
||||
fieldDefaults[key] = value
|
||||
}
|
||||
|
||||
// Note: Frontend doesn't add these extra fieldConfig defaults
|
||||
// Color is handled in sparkline migration logic
|
||||
// nullValueMode and unit are not added by frontend
|
||||
|
||||
// Remove all legacy properties from the panel
|
||||
legacyProperties := []string{
|
||||
"colors", "thresholds", "valueMaps", "grid", "legend", "mappingTypes", "gauge",
|
||||
"autoMigrateFrom", "colorBackground", "colorValue", "format", "mappingType",
|
||||
"nullPointMode", "postfix", "postfixFontSize", "prefix",
|
||||
"prefixFontSize", "rangeMaps", "sparkline", "tableColumn", "valueFontSize",
|
||||
"valueName", "aliasYAxis", "bars", "dashLength", "dashes", "fill", "fillGradient",
|
||||
"lineInterpolation", "lineWidth", "pointRadius", "points", "spaceLength",
|
||||
"stack", "steppedLine", "xAxis", "yAxes", "yAxis", "zIndex",
|
||||
}
|
||||
|
||||
for _, prop := range legacyProperties {
|
||||
delete(panel, prop)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupAngularProperties removes old angular properties after migration
|
||||
// Based on PanelModel.clearPropertiesBeforePluginChange in public/app/features/dashboard/state/PanelModel.ts
|
||||
// This function removes ALL properties except those in mustKeepProps to match frontend behavior exactly
|
||||
func cleanupAngularProperties(panel map[string]interface{}) {
|
||||
// Remove PanelModel's autoMigrateFrom property
|
||||
delete(panel, "autoMigrateFrom")
|
||||
// Properties that must be kept (matching frontend mustKeepProps)
|
||||
mustKeepProps := map[string]bool{
|
||||
"id": true, "gridPos": true, "type": true, "title": true, "scopedVars": true,
|
||||
"repeat": true, "repeatPanelId": true, "repeatDirection": true, "repeatedByRow": true,
|
||||
"minSpan": true, "collapsed": true, "panels": true, "targets": true, "datasource": true,
|
||||
"timeFrom": true, "timeShift": true, "hideTimeOverride": true, "description": true,
|
||||
"links": true, "fullscreen": true, "isEditing": true, "isViewing": true,
|
||||
"hasRefreshed": true, "events": true, "cacheTimeout": true, "queryCachingTTL": true,
|
||||
"cachedPluginOptions": true, "transparent": true, "pluginVersion": true,
|
||||
"fieldConfig": true, "options": true, // These are set by migration
|
||||
"maxDataPoints": true, "interval": true, // Panel-level properties preserved by frontend
|
||||
"autoMigrateFrom": true, // Preserve autoMigrateFrom for proper migration logic
|
||||
}
|
||||
|
||||
// Remove angular singlestat properties
|
||||
delete(panel, "valueName")
|
||||
delete(panel, "format")
|
||||
delete(panel, "decimals")
|
||||
delete(panel, "thresholds")
|
||||
delete(panel, "colors")
|
||||
delete(panel, "gauge")
|
||||
delete(panel, "sparkline")
|
||||
delete(panel, "colorBackground")
|
||||
delete(panel, "colorValue")
|
||||
delete(panel, "nullPointMode")
|
||||
delete(panel, "nullText")
|
||||
delete(panel, "valueMaps")
|
||||
delete(panel, "tableColumn")
|
||||
delete(panel, "angular")
|
||||
// Remove legacy options properties
|
||||
if options, ok := panel["options"].(map[string]interface{}); ok {
|
||||
delete(options, "valueOptions")
|
||||
delete(options, "thresholds")
|
||||
delete(options, "valueMaps")
|
||||
delete(options, "minValue")
|
||||
delete(options, "maxValue")
|
||||
// Remove ALL properties except those in mustKeepProps (matching frontend behavior)
|
||||
for key := range panel {
|
||||
if !mustKeepProps[key] {
|
||||
delete(panel, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all targets have refIds (matching frontend ensureQueryIds behavior)
|
||||
ensureTargetRefIds(panel)
|
||||
}
|
||||
|
||||
// ensureTargetRefIds assigns refIds to targets that don't have them
|
||||
// This matches the frontend PanelModel.ensureQueryIds() behavior
|
||||
func ensureTargetRefIds(panel map[string]interface{}) {
|
||||
targets, ok := panel["targets"].([]interface{})
|
||||
if !ok || len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing refIds
|
||||
existingRefIds := make(map[string]bool)
|
||||
for _, targetInterface := range targets {
|
||||
if target, ok := targetInterface.(map[string]interface{}); ok {
|
||||
if refId, ok := target["refId"].(string); ok {
|
||||
existingRefIds[refId] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign refIds to targets that don't have them
|
||||
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
letterIndex := 0
|
||||
|
||||
for _, targetInterface := range targets {
|
||||
if target, ok := targetInterface.(map[string]interface{}); ok {
|
||||
refId, hasRefId := target["refId"].(string)
|
||||
if !hasRefId || refId == "" {
|
||||
// Find next available refId
|
||||
for letterIndex < len(letters) {
|
||||
refId := string(letters[letterIndex])
|
||||
if !existingRefIds[refId] {
|
||||
target["refId"] = refId
|
||||
existingRefIds[refId] = true
|
||||
break
|
||||
}
|
||||
letterIndex++
|
||||
}
|
||||
letterIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,7 +821,67 @@ func cleanupAngularProperties(panel map[string]interface{}) {
|
||||
func removeDeprecatedVariableProperties(variable map[string]interface{}) {
|
||||
// Remove deprecated properties
|
||||
delete(variable, "tags")
|
||||
delete(variable, "tagsQuery")
|
||||
delete(variable, "tagValuesQuery")
|
||||
delete(variable, "useTags")
|
||||
|
||||
// Only remove tagsQuery if it's a non-empty string (matches frontend behavior)
|
||||
if tagsQuery, exists := variable["tagsQuery"]; exists {
|
||||
if str, ok := tagsQuery.(string); ok && str != "" {
|
||||
delete(variable, "tagsQuery")
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove tagValuesQuery if it's a non-empty string (matches frontend behavior)
|
||||
if tagValuesQuery, exists := variable["tagValuesQuery"]; exists {
|
||||
if str, ok := tagValuesQuery.(string); ok && str != "" {
|
||||
delete(variable, "tagValuesQuery")
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove useTags if it's a truthy boolean (matches frontend behavior)
|
||||
if useTags, exists := variable["useTags"]; exists {
|
||||
if val, ok := useTags.(bool); ok && val {
|
||||
delete(variable, "useTags")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determineColorMode determines the color mode based on angular options
|
||||
func determineColorMode(angularOpts map[string]interface{}) string {
|
||||
if colorBackground, ok := angularOpts["colorBackground"].(bool); ok && colorBackground {
|
||||
return "background"
|
||||
}
|
||||
|
||||
if colorValue, ok := angularOpts["colorValue"].(bool); ok && colorValue {
|
||||
return "value"
|
||||
}
|
||||
|
||||
return "none"
|
||||
}
|
||||
|
||||
// migrateSparklineColor migrates sparkline color configuration when colorMode is "none"
|
||||
// Based on statPanelChangedHandler lines 31-38
|
||||
func migrateSparklineColor(angularOpts map[string]interface{}, defaults map[string]interface{}, options map[string]interface{}) {
|
||||
sparkline, ok := angularOpts["sparkline"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
show, ok := sparkline["show"].(bool)
|
||||
if !ok || !show {
|
||||
return
|
||||
}
|
||||
|
||||
graphMode, ok := options["graphMode"].(string)
|
||||
if !ok || graphMode != "area" {
|
||||
return
|
||||
}
|
||||
|
||||
lineColor, ok := sparkline["lineColor"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
defaults["color"] = map[string]interface{}{
|
||||
"mode": "fixed",
|
||||
"fixedColor": lineColor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,750 +1,105 @@
|
||||
package schemaversion_test
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
)
|
||||
|
||||
func TestV28(t *testing.T) {
|
||||
tests := []migrationTestCase{
|
||||
func TestV28SinglestatMigration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "migrate angular singlestat to stat panel",
|
||||
name: "migrate_range_maps_to_field_config_mappings",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
"type": "singlestat",
|
||||
"rangeMaps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "singlestat",
|
||||
"valueName": "avg",
|
||||
"format": "ms",
|
||||
"decimals": 2,
|
||||
"thresholds": "10,20,30",
|
||||
"colors": []interface{}{"green", "yellow", "red"},
|
||||
"gauge": map[string]interface{}{
|
||||
"show": false,
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "var1",
|
||||
"tags": []interface{}{"tag1"},
|
||||
"tagsQuery": "query",
|
||||
"tagValuesQuery": "values",
|
||||
"useTags": true,
|
||||
},
|
||||
"from": "null",
|
||||
"to": "N/A",
|
||||
},
|
||||
},
|
||||
"mappingType": 1, // Inconsistent - should be 2 for rangeMaps
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"mean"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "ms",
|
||||
"decimals": 2,
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "yellow",
|
||||
"value": 10.0,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 20.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "var1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate angular singlestat to stat panel with gauge options",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "singlestat",
|
||||
"valueName": "current",
|
||||
"format": "percent",
|
||||
"gauge": map[string]interface{}{
|
||||
"show": true,
|
||||
"thresholdMarkers": true,
|
||||
"thresholdLabels": false,
|
||||
},
|
||||
"sparkline": map[string]interface{}{
|
||||
"show": true,
|
||||
"lineColor": "#ff0000",
|
||||
},
|
||||
"colorBackground": true,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"lastNotNull"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"color": map[string]interface{}{
|
||||
"mode": "fixed",
|
||||
"fixedColor": "#ff0000",
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate grafana-singlestat-panel to stat panel",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "grafana-singlestat-panel",
|
||||
"valueName": "current",
|
||||
"format": "percent",
|
||||
"gauge": map[string]interface{}{
|
||||
"show": true,
|
||||
"thresholdMarkers": true,
|
||||
"thresholdLabels": false,
|
||||
},
|
||||
"sparkline": map[string]interface{}{
|
||||
"show": true,
|
||||
"lineColor": "#ff0000",
|
||||
},
|
||||
"colorBackground": true,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"lastNotNull"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"color": map[string]interface{}{
|
||||
"mode": "fixed",
|
||||
"fixedColor": "#ff0000",
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate singlestat with empty thresholds to stat panel",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "singlestat",
|
||||
"valueName": "min",
|
||||
"format": "bytes",
|
||||
"thresholds": "",
|
||||
"colors": []interface{}{"green", "red"},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"min"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "bytes",
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate grafana-singlestat-panel with empty thresholds to stat panel",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "grafana-singlestat-panel",
|
||||
"valueName": "max",
|
||||
"format": "short",
|
||||
"thresholds": "",
|
||||
"colors": []interface{}{"green", "red"},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"max"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "auto",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "short",
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate singlestat with value mappings and threshold colors",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "singlestat",
|
||||
"valueName": "current",
|
||||
"format": "short",
|
||||
"thresholds": "50,80",
|
||||
"colors": []interface{}{"green", "orange", "red"},
|
||||
"valueMaps": []interface{}{
|
||||
"type": "stat",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"mappings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"value": "40",
|
||||
"text": "Warning",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"value": "90",
|
||||
"text": "Critical",
|
||||
},
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"lastNotNull"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "short",
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "orange",
|
||||
"value": 50.0,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "value",
|
||||
"options": map[string]interface{}{
|
||||
"40": map[string]interface{}{
|
||||
"text": "Warning",
|
||||
"color": "green",
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "value",
|
||||
"options": map[string]interface{}{
|
||||
"90": map[string]interface{}{
|
||||
"text": "Critical",
|
||||
"color": "red",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate singlestat with invalid valueName fallback to mean",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "singlestat",
|
||||
"valueName": "invalid_reducer",
|
||||
"format": "short",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"mean"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "short",
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate grafana-singlestat-panel with invalid valueName keeps lastNotNull",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "grafana-singlestat-panel",
|
||||
"valueName": "invalid_reducer",
|
||||
"format": "short",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"lastNotNull"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
},
|
||||
"orientation": "auto",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "short",
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handle nested panels in rows",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "row",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"type": "singlestat",
|
||||
"valueName": "sum",
|
||||
"format": "bytes",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "row",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"options": map[string]interface{}{
|
||||
"reduceOptions": map[string]interface{}{
|
||||
"calcs": []string{"sum"},
|
||||
"fields": "",
|
||||
"values": false,
|
||||
"match": "null",
|
||||
"result": map[string]interface{}{
|
||||
"text": "N/A",
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true,
|
||||
},
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"unit": "bytes",
|
||||
"thresholds": map[string]interface{}{
|
||||
"mode": "absolute",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
"value": 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": []interface{}{},
|
||||
},
|
||||
"overrides": []interface{}{},
|
||||
},
|
||||
"pluginVersion": pluginVersionForAutoMigrate,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{"refId": "A"},
|
||||
},
|
||||
"type": "special",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "RangeMaps should migrate to fieldConfig.mappings, and inconsistent mappingType should be fixed to 2 (RangeToText)",
|
||||
},
|
||||
{
|
||||
name: "remove deprecated variable properties",
|
||||
name: "migrate_sparkline_color_when_color_mode_none",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "var1",
|
||||
"type": "query",
|
||||
"tags": []interface{}{"tag1", "tag2"},
|
||||
"tagsQuery": "SELECT * FROM tags",
|
||||
"tagValuesQuery": "SELECT value FROM tag_values",
|
||||
"useTags": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "var2",
|
||||
"type": "custom",
|
||||
// No deprecated properties
|
||||
},
|
||||
},
|
||||
"type": "singlestat",
|
||||
"colorMode": "None",
|
||||
"sparkline": map[string]interface{}{
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 28,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "var1",
|
||||
"type": "query",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "var2",
|
||||
"type": "custom",
|
||||
"type": "stat",
|
||||
"fieldConfig": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"color": map[string]interface{}{
|
||||
"mode": "fixed",
|
||||
"fixedColor": "rgb(31, 120, 193)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "Sparkline lineColor should migrate to fieldConfig.defaults.color only when colorMode is None",
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, schemaversion.V28)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dashboard := map[string]interface{}{
|
||||
"schemaVersion": 27,
|
||||
"panels": []interface{}{tt.input},
|
||||
}
|
||||
|
||||
err := V28(context.Background(), dashboard)
|
||||
if err != nil {
|
||||
t.Fatalf("V28 migration failed: %v", err)
|
||||
}
|
||||
|
||||
if dashboard["schemaVersion"] != 28 {
|
||||
t.Errorf("Expected schemaVersion to be 28, got %v", dashboard["schemaVersion"])
|
||||
}
|
||||
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok || len(panels) == 0 {
|
||||
t.Fatalf("Expected panels array with at least one panel")
|
||||
}
|
||||
|
||||
panel, ok := panels[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected panel to be a map")
|
||||
}
|
||||
|
||||
// Verify panel type was changed to stat
|
||||
if panel["type"] != "stat" {
|
||||
t.Errorf("Expected panel type to be 'stat', got %v", panel["type"])
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: %s", tt.name, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,16 +44,12 @@ func V29(_ context.Context, dashboard map[string]interface{}) error {
|
||||
continue
|
||||
}
|
||||
// Set refresh to 1 if not 1 or 2
|
||||
refresh, hasRefresh := variable["refresh"]
|
||||
refreshInt := 0
|
||||
if r, ok := refresh.(int); ok {
|
||||
refreshInt = r
|
||||
}
|
||||
if !hasRefresh || (refreshInt != 1 && refreshInt != 2) {
|
||||
refreshInt := GetIntValue(variable, "refresh", 0)
|
||||
if refreshInt != 1 && refreshInt != 2 {
|
||||
variable["refresh"] = 1
|
||||
}
|
||||
// Clear options if present
|
||||
if _, hasOptions := variable["options"]; hasOptions {
|
||||
// Clear options if they have content (matches frontend behavior)
|
||||
if options, hasOptions := variable["options"].([]interface{}); hasOptions && len(options) > 0 {
|
||||
variable["options"] = []interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,8 +269,8 @@ func processLegacyMapping(mappingMap map[string]interface{}, thresholds map[stri
|
||||
color := getColorFromThresholds(mappingMap, thresholds)
|
||||
|
||||
// Convert legacy type numbers to new format
|
||||
if mappingType, ok := mappingMap["type"].(float64); ok {
|
||||
switch int(mappingType) {
|
||||
if mappingType := GetIntValue(mappingMap, "type", -1); mappingType != -1 {
|
||||
switch mappingType {
|
||||
case 1: // ValueToText
|
||||
hasValueMappings = processValueToTextMapping(mappingMap, color, thresholds, valueMaps, newMappings, hasValueMappings)
|
||||
case 2: // RangeToText
|
||||
@@ -281,25 +281,16 @@ func processLegacyMapping(mappingMap map[string]interface{}, thresholds map[stri
|
||||
return hasValueMappings
|
||||
}
|
||||
|
||||
// getColorFromThresholds extracts color from thresholds based on mapping values
|
||||
// getColorFromThresholds extracts color from thresholds based on mapping text (matches frontend behavior)
|
||||
func getColorFromThresholds(mappingMap map[string]interface{}, thresholds map[string]interface{}) interface{} {
|
||||
if thresholds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to get color from threshold based on the mapping value
|
||||
if value, ok := mappingMap["value"]; ok {
|
||||
if valueStr, ok := value.(string); ok {
|
||||
if numeric, err := strconv.ParseFloat(valueStr, 64); err == nil {
|
||||
return getActiveThresholdColor(numeric, thresholds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For range mappings, use the 'from' value to determine color
|
||||
if fromVal, ok := mappingMap["from"]; ok {
|
||||
if fromStr, ok := fromVal.(string); ok {
|
||||
if numeric, err := strconv.ParseFloat(fromStr, 64); err == nil {
|
||||
// Try to get color from threshold based on the mapping text (matches frontend behavior)
|
||||
if text, ok := mappingMap["text"]; ok {
|
||||
if textStr, ok := text.(string); ok {
|
||||
if numeric, err := strconv.ParseFloat(textStr, 64); err == nil {
|
||||
return getActiveThresholdColor(numeric, thresholds)
|
||||
}
|
||||
}
|
||||
@@ -325,19 +316,18 @@ func processValueToTextMapping(mappingMap map[string]interface{}, color interfac
|
||||
|
||||
// processNullValueMapping creates a special value mapping for null values
|
||||
func processNullValueMapping(mappingMap map[string]interface{}, color interface{}, thresholds map[string]interface{}, newMappings *[]interface{}) {
|
||||
// For null values, use the base threshold color (lowest step)
|
||||
if thresholds != nil && color == nil {
|
||||
color = getBaseThresholdColor(thresholds)
|
||||
result := map[string]interface{}{
|
||||
"text": mappingMap["text"],
|
||||
}
|
||||
if color != nil {
|
||||
result["color"] = color
|
||||
}
|
||||
|
||||
*newMappings = append(*newMappings, map[string]interface{}{
|
||||
"type": "special",
|
||||
"options": map[string]interface{}{
|
||||
"match": "null",
|
||||
"result": map[string]interface{}{
|
||||
"text": mappingMap["text"],
|
||||
"color": color,
|
||||
},
|
||||
"match": "null",
|
||||
"result": result,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -376,9 +366,13 @@ func processRangeToTextMapping(mappingMap map[string]interface{}, color interfac
|
||||
})
|
||||
}
|
||||
|
||||
// getActiveThresholdColor returns the color for a value based on thresholds
|
||||
// getActiveThresholdColor returns the color for a value based on thresholds (matches frontend getActiveThreshold)
|
||||
func getActiveThresholdColor(value float64, thresholds map[string]interface{}) interface{} {
|
||||
if steps, ok := thresholds["steps"].([]interface{}); ok {
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var activeStep map[string]interface{}
|
||||
|
||||
for _, step := range steps {
|
||||
@@ -390,9 +384,11 @@ func getActiveThresholdColor(value float64, thresholds map[string]interface{}) i
|
||||
continue
|
||||
}
|
||||
|
||||
if stepNum, ok := stepValue.(float64); ok {
|
||||
if stepNum := GetFloatValue(stepMap, "value", -1); stepNum != -1 {
|
||||
if value >= stepNum {
|
||||
activeStep = stepMap
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,24 +403,6 @@ func getActiveThresholdColor(value float64, thresholds map[string]interface{}) i
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBaseThresholdColor returns the base threshold color (first step with null value)
|
||||
func getBaseThresholdColor(thresholds map[string]interface{}) interface{} {
|
||||
if steps, ok := thresholds["steps"].([]interface{}); ok {
|
||||
for _, step := range steps {
|
||||
if stepMap, ok := step.(map[string]interface{}); ok {
|
||||
if stepValue, ok := stepMap["value"]; ok {
|
||||
if stepValue == nil {
|
||||
// This is the base color (null value step)
|
||||
return stepMap["color"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateTooltipOptions renames tooltipOptions to tooltip for specific panel types
|
||||
func migrateTooltipOptions(panel map[string]interface{}) {
|
||||
panelType, ok := panel["type"].(string)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestV30(t *testing.T) {
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
"value": (*float64)(nil),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
@@ -99,7 +99,7 @@ func TestV30(t *testing.T) {
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"color": "green",
|
||||
"value": nil,
|
||||
"value": (*float64)(nil),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"color": "red",
|
||||
@@ -112,12 +112,10 @@ func TestV30(t *testing.T) {
|
||||
"type": "value",
|
||||
"options": map[string]interface{}{
|
||||
"1": map[string]interface{}{
|
||||
"text": "Up",
|
||||
"color": "green",
|
||||
"text": "Up",
|
||||
},
|
||||
"0": map[string]interface{}{
|
||||
"text": "Down",
|
||||
"color": "green",
|
||||
"text": "Down",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,8 +125,7 @@ func TestV30(t *testing.T) {
|
||||
"from": float64(10),
|
||||
"to": float64(20),
|
||||
"result": map[string]interface{}{
|
||||
"text": "Medium",
|
||||
"color": "green",
|
||||
"text": "Medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -137,8 +134,7 @@ func TestV30(t *testing.T) {
|
||||
"options": map[string]interface{}{
|
||||
"match": "null",
|
||||
"result": map[string]interface{}{
|
||||
"text": "Null Value",
|
||||
"color": "green",
|
||||
"text": "Null Value",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -128,7 +128,9 @@ func processPanelsV31(panels []interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the panel with the new transformations
|
||||
p["transformations"] = newTransformations
|
||||
// Update the panel with the new transformations - only if not empty
|
||||
if len(newTransformations) > 0 {
|
||||
p["transformations"] = newTransformations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,336 +1,91 @@
|
||||
package schemaversion_test
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
)
|
||||
|
||||
func TestV31(t *testing.T) {
|
||||
tests := []migrationTestCase{
|
||||
func TestV31LabelsToFieldsMigration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "panel with basic labelsToFields transformation gets merge transformation added",
|
||||
name: "do_not_add_empty_transformations",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 LabelsToFields Migration Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with basic labelsToFields",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"type": "timeseries",
|
||||
"title": "Test Panel",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 LabelsToFields Migration Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with basic labelsToFields",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "merge",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"type": "timeseries",
|
||||
"title": "Test Panel",
|
||||
},
|
||||
description: "V31 migration should not add empty transformations arrays to panels",
|
||||
},
|
||||
{
|
||||
name: "panel with labelsToFields options preserved during migration",
|
||||
name: "preserve_existing_transformations",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 LabelsToFields Options Preservation Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{
|
||||
"type": "timeseries",
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with labelsToFields options",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{
|
||||
"mode": "rows",
|
||||
"keepLabels": []interface{}{"job", "instance"},
|
||||
"valueLabel": "value",
|
||||
},
|
||||
},
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{
|
||||
"keepLabels": []interface{}{"__name__"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 LabelsToFields Options Preservation Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
"panels": []interface{}{
|
||||
"type": "timeseries",
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with labelsToFields options",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{
|
||||
"mode": "rows",
|
||||
"keepLabels": []interface{}{"job", "instance"},
|
||||
"valueLabel": "value",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "merge",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{
|
||||
"keepLabels": []interface{}{"__name__"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with multiple labelsToFields transformations",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 Multiple LabelsToFields Migration Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with multiple labelsToFields",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "organize",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "calculateField",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{
|
||||
"mode": "rows",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 Multiple LabelsToFields Migration Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with multiple labelsToFields",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "organize",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "merge",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "calculateField",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{
|
||||
"mode": "rows",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "merge",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with no transformations remains unchanged",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 No Transformations Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with no transformations",
|
||||
"id": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 No Transformations Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with no transformations",
|
||||
"id": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with transformations but no labelsToFields remains unchanged",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 Other Transformations Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with other transformations",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "organize",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "reduce",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 Other Transformations Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Panel with other transformations",
|
||||
"id": 1,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "organize",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "reduce",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested panels in row with labelsToFields transformation",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 Nested Panels Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "row",
|
||||
"title": "Row with nested panels",
|
||||
"id": 1,
|
||||
"collapsed": false,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Nested panel with labelsToFields",
|
||||
"id": 2,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Nested panel without labelsToFields",
|
||||
"id": 3,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "organize",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 Nested Panels Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "row",
|
||||
"title": "Row with nested panels",
|
||||
"id": 1,
|
||||
"collapsed": false,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Nested panel with labelsToFields",
|
||||
"id": 2,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "labelsToFields",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "merge",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Nested panel without labelsToFields",
|
||||
"id": 3,
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "organize",
|
||||
"options": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard with no panels",
|
||||
input: map[string]interface{}{
|
||||
"title": "V31 No Panels Test Dashboard",
|
||||
"schemaVersion": 30,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "V31 No Panels Test Dashboard",
|
||||
"schemaVersion": 31,
|
||||
},
|
||||
description: "Existing labelsToFields transformations should be preserved and updated if needed",
|
||||
},
|
||||
}
|
||||
runMigrationTests(t, tests, schemaversion.V31)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dashboard := map[string]interface{}{
|
||||
"schemaVersion": 30,
|
||||
"panels": []interface{}{tt.input},
|
||||
}
|
||||
|
||||
err := V31(context.Background(), dashboard)
|
||||
if err != nil {
|
||||
t.Fatalf("V31 migration failed: %v", err)
|
||||
}
|
||||
|
||||
if dashboard["schemaVersion"] != 31 {
|
||||
t.Errorf("Expected schemaVersion to be 31, got %v", dashboard["schemaVersion"])
|
||||
}
|
||||
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok || len(panels) == 0 {
|
||||
t.Fatalf("Expected panels array with at least one panel")
|
||||
}
|
||||
|
||||
panel, ok := panels[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected panel to be a map")
|
||||
}
|
||||
|
||||
// Check that transformations array is not added if it wasn't in input
|
||||
if _, hasTransformations := tt.input["transformations"]; !hasTransformations {
|
||||
if _, exists := panel["transformations"]; exists {
|
||||
t.Errorf("Empty transformations array should not be added")
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: %s", tt.name, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,420 +1,224 @@
|
||||
package schemaversion_test
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil"
|
||||
)
|
||||
|
||||
func TestV33(t *testing.T) {
|
||||
// Pass the mock provider to V33
|
||||
migration := schemaversion.V33(testutil.GetTestDataSourceProvider())
|
||||
|
||||
tests := []migrationTestCase{
|
||||
{
|
||||
name: "dashboard with no panels",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
func TestV33DatasourceMigration(t *testing.T) {
|
||||
// Create test datasource provider
|
||||
dsProvider := &testDataSourceProvider{
|
||||
datasources: []DataSourceInfo{
|
||||
{
|
||||
Default: true,
|
||||
UID: "default-ds-uid",
|
||||
Type: "prometheus",
|
||||
APIVersion: "v1",
|
||||
Name: "Default Test Datasource Name",
|
||||
ID: 1,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with default datasource should return null",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with null datasource should return null",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with existing datasource reference should be preserved",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-uid",
|
||||
"type": "existing-type",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "target-uid",
|
||||
"type": "target-type",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-uid",
|
||||
"type": "existing-type",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "target-uid",
|
||||
"type": "target-type",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with datasource by name should be migrated",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "Existing Target Name",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with datasource by UID should be migrated",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "existing-target-uid",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with unknown datasource should preserve as UID",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "unknown-datasource",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "unknown-datasource",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-datasource",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-datasource",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with mixed datasources",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "Existing Target Name",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": "unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel without targets should not fail",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested panels in collapsed rows should be migrated",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "row",
|
||||
"collapsed": true,
|
||||
"datasource": "Existing Target Name",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": "unknown-ds",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "row",
|
||||
"collapsed": true,
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-ds",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "targets with lowercase default keyword should not be updated",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "Existing Target Name",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 33,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Default: false,
|
||||
UID: "non-default-test-ds-uid",
|
||||
Type: "loki",
|
||||
APIVersion: "v1",
|
||||
Name: "Non Default Test Datasource Name",
|
||||
ID: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, migration)
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "empty_string_datasource_should_become_empty_object",
|
||||
input: map[string]interface{}{
|
||||
"datasource": "",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"datasource": map[string]interface{}{},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "Empty string datasources should migrate to empty objects {} to match frontend behavior",
|
||||
},
|
||||
{
|
||||
name: "null_datasource_should_remain_null",
|
||||
input: map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
description: "Null datasources should remain null when returnDefaultAsNull is true",
|
||||
},
|
||||
{
|
||||
name: "existing_object_datasource_should_remain_unchanged",
|
||||
input: map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-ref-uid",
|
||||
"type": "prometheus",
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-ref-uid",
|
||||
"type": "prometheus",
|
||||
},
|
||||
},
|
||||
description: "Existing datasource objects should remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "string_datasource_should_migrate_to_object",
|
||||
input: map[string]interface{}{
|
||||
"datasource": "Non Default Test Datasource Name",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "non-default-test-ds-uid",
|
||||
"type": "loki",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
description: "String datasources should migrate to structured objects with uid, type, and apiVersion",
|
||||
},
|
||||
{
|
||||
name: "default_datasource_should_become_null",
|
||||
input: map[string]interface{}{
|
||||
"datasource": "default",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"datasource": nil,
|
||||
},
|
||||
description: "Default datasource should become null when returnDefaultAsNull is true",
|
||||
},
|
||||
{
|
||||
name: "unknown_datasource_should_be_preserved_as_uid",
|
||||
input: map[string]interface{}{
|
||||
"datasource": "unknown-datasource",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-datasource",
|
||||
},
|
||||
},
|
||||
description: "Unknown datasource names should be preserved as UID-only reference",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a dashboard with the test panel
|
||||
dashboard := map[string]interface{}{
|
||||
"schemaVersion": 32,
|
||||
"panels": []interface{}{
|
||||
tt.input,
|
||||
},
|
||||
}
|
||||
|
||||
// Run V33 migration
|
||||
migration := V33(dsProvider)
|
||||
err := migration(context.Background(), dashboard)
|
||||
if err != nil {
|
||||
t.Fatalf("V33 migration failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify schema version was updated
|
||||
if dashboard["schemaVersion"] != 33 {
|
||||
t.Errorf("Expected schemaVersion to be 33, got %v", dashboard["schemaVersion"])
|
||||
}
|
||||
|
||||
// Get the migrated panel
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok || len(panels) == 0 {
|
||||
t.Fatalf("Expected panels array with at least one panel")
|
||||
}
|
||||
|
||||
panel, ok := panels[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected panel to be a map")
|
||||
}
|
||||
|
||||
// Verify panel datasource
|
||||
if !compareDatasource(panel["datasource"], tt.expected["datasource"]) {
|
||||
t.Errorf("Panel datasource mismatch.\nExpected: %v\nGot: %v", tt.expected["datasource"], panel["datasource"])
|
||||
}
|
||||
|
||||
// Verify targets if they exist
|
||||
if expectedTargets, hasTargets := tt.expected["targets"].([]interface{}); hasTargets {
|
||||
actualTargets, ok := panel["targets"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected targets array")
|
||||
}
|
||||
|
||||
if len(actualTargets) != len(expectedTargets) {
|
||||
t.Fatalf("Expected %d targets, got %d", len(expectedTargets), len(actualTargets))
|
||||
}
|
||||
|
||||
for i, expectedTarget := range expectedTargets {
|
||||
expectedTargetMap := expectedTarget.(map[string]interface{})
|
||||
actualTargetMap := actualTargets[i].(map[string]interface{})
|
||||
|
||||
if !compareDatasource(actualTargetMap["datasource"], expectedTargetMap["datasource"]) {
|
||||
t.Errorf("Target %d datasource mismatch.\nExpected: %v\nGot: %v", i, expectedTargetMap["datasource"], actualTargetMap["datasource"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: %s", tt.name, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare datasource objects
|
||||
func compareDatasource(actual, expected interface{}) bool {
|
||||
if actual == nil && expected == nil {
|
||||
return true
|
||||
}
|
||||
if actual == nil || expected == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
actualMap, actualOk := actual.(map[string]interface{})
|
||||
expectedMap, expectedOk := expected.(map[string]interface{})
|
||||
|
||||
if !actualOk || !expectedOk {
|
||||
return actual == expected
|
||||
}
|
||||
|
||||
if len(actualMap) != len(expectedMap) {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, expectedValue := range expectedMap {
|
||||
actualValue, exists := actualMap[key]
|
||||
if !exists || actualValue != expectedValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test datasource provider for testing
|
||||
type testDataSourceProvider struct {
|
||||
datasources []DataSourceInfo
|
||||
}
|
||||
|
||||
func (p *testDataSourceProvider) GetDataSourceInfo(_ context.Context) []DataSourceInfo {
|
||||
return p.datasources
|
||||
}
|
||||
|
||||
@@ -58,28 +58,30 @@ import "context"
|
||||
func V34(_ context.Context, dashboard map[string]interface{}) error {
|
||||
dashboard["schemaVersion"] = int(34)
|
||||
|
||||
// Migrate panel queries if panels exist
|
||||
panels, _ := dashboard["panels"].([]interface{})
|
||||
for _, panel := range panels {
|
||||
p, ok := panel.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
migrateCloudWatchQueriesInPanel(p)
|
||||
|
||||
// Handle nested panels in collapsed rows
|
||||
nestedPanels, hasNested := p["panels"].([]interface{})
|
||||
if !hasNested {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, nestedPanel := range nestedPanels {
|
||||
np, ok := nestedPanel.(map[string]interface{})
|
||||
// Migrate panel queries if panels exist and are an array
|
||||
if panelsValue, exists := dashboard["panels"]; exists && IsArray(panelsValue) {
|
||||
panels := panelsValue.([]interface{})
|
||||
for _, panel := range panels {
|
||||
p, ok := panel.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
migrateCloudWatchQueriesInPanel(np)
|
||||
|
||||
migrateCloudWatchQueriesInPanel(p)
|
||||
|
||||
// Handle nested panels in collapsed rows
|
||||
if !IsArray(p["panels"]) {
|
||||
continue
|
||||
}
|
||||
nestedPanels := p["panels"].([]interface{})
|
||||
|
||||
for _, nestedPanel := range nestedPanels {
|
||||
np, ok := nestedPanel.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
migrateCloudWatchQueriesInPanel(np)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +93,10 @@ func V34(_ context.Context, dashboard map[string]interface{}) error {
|
||||
|
||||
// migrateCloudWatchQueriesInPanel migrates CloudWatch queries within a panel that use multiple statistics.
|
||||
func migrateCloudWatchQueriesInPanel(panel map[string]interface{}) {
|
||||
targets, ok := panel["targets"].([]interface{})
|
||||
if !ok {
|
||||
if !IsArray(panel["targets"]) {
|
||||
return
|
||||
}
|
||||
targets := panel["targets"].([]interface{})
|
||||
|
||||
var newTargets []interface{}
|
||||
var additionalTargets []interface{}
|
||||
@@ -111,20 +113,17 @@ func migrateCloudWatchQueriesInPanel(panel map[string]interface{}) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add CloudWatch fields if missing
|
||||
if _, exists := t["metricEditorMode"]; !exists {
|
||||
t["metricEditorMode"] = 0
|
||||
}
|
||||
if _, exists := t["metricQueryType"]; !exists {
|
||||
t["metricQueryType"] = 0
|
||||
}
|
||||
// Add CloudWatch fields if missing (set to 0 if not present)
|
||||
t["metricEditorMode"] = GetIntValue(t, "metricEditorMode", 0)
|
||||
t["metricQueryType"] = GetIntValue(t, "metricQueryType", 0)
|
||||
|
||||
// Get valid statistics (including null and empty strings)
|
||||
validStats, isEmpty := getValidStatistics(t["statistics"])
|
||||
|
||||
// Handle empty array case (preserve it)
|
||||
// Handle empty array case (delete statistics field like frontend)
|
||||
if isEmpty {
|
||||
// Keep empty array as-is
|
||||
// Delete statistics field to match frontend behavior
|
||||
delete(t, "statistics")
|
||||
newTargets = append(newTargets, t)
|
||||
continue
|
||||
}
|
||||
@@ -139,10 +138,8 @@ func migrateCloudWatchQueriesInPanel(panel map[string]interface{}) {
|
||||
newTargets = append(newTargets, t)
|
||||
case 1:
|
||||
// Single statistic - set statistic field if not null
|
||||
if validStats[0] != nil {
|
||||
if statString, ok := validStats[0].(string); ok {
|
||||
t["statistic"] = statString
|
||||
}
|
||||
if statString := GetStringValue(map[string]interface{}{"stat": validStats[0]}, "stat"); statString != "" {
|
||||
t["statistic"] = statString
|
||||
}
|
||||
newTargets = append(newTargets, t)
|
||||
default:
|
||||
@@ -175,10 +172,10 @@ func migrateCloudWatchAnnotationQueries(dashboard map[string]interface{}) {
|
||||
return
|
||||
}
|
||||
|
||||
annotationsList, ok := annotations["list"].([]interface{})
|
||||
if !ok {
|
||||
if !IsArray(annotations["list"]) {
|
||||
return
|
||||
}
|
||||
annotationsList := annotations["list"].([]interface{})
|
||||
|
||||
var additionalAnnotations []interface{}
|
||||
|
||||
@@ -193,14 +190,15 @@ func migrateCloudWatchAnnotationQueries(dashboard map[string]interface{}) {
|
||||
}
|
||||
|
||||
// Get original name for suffix generation
|
||||
originalName, _ := a["name"].(string)
|
||||
originalName := GetStringValue(a, "name")
|
||||
|
||||
// Get valid statistics (including null and empty strings)
|
||||
validStats, isEmpty := getValidStatistics(a["statistics"])
|
||||
|
||||
// Handle empty array case (preserve it)
|
||||
// Handle empty array case (delete statistics field like frontend)
|
||||
if isEmpty {
|
||||
// Keep empty array as-is
|
||||
// Delete statistics field to match frontend behavior
|
||||
delete(a, "statistics")
|
||||
annotationsList[i] = a
|
||||
continue
|
||||
}
|
||||
@@ -214,10 +212,8 @@ func migrateCloudWatchAnnotationQueries(dashboard map[string]interface{}) {
|
||||
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
|
||||
}
|
||||
if statString := GetStringValue(map[string]interface{}{"stat": validStats[0]}, "stat"); statString != "" {
|
||||
a["statistic"] = statString
|
||||
}
|
||||
annotationsList[i] = a
|
||||
default:
|
||||
|
||||
@@ -460,7 +460,6 @@ func TestV34(t *testing.T) {
|
||||
"namespace": "AWS/EC2",
|
||||
"region": "us-east-1",
|
||||
"metricName": "CPUUtilization",
|
||||
"statistics": []interface{}{},
|
||||
"metricEditorMode": 0,
|
||||
"metricQueryType": 0,
|
||||
},
|
||||
|
||||
@@ -58,7 +58,12 @@ func V35(_ context.Context, dashboard map[string]interface{}) error {
|
||||
// applyXAxisVisibilityOverride adds a field override to ensure x-axis visibility
|
||||
// when the panel's default axis placement is set to hidden.
|
||||
func applyXAxisVisibilityOverride(panel map[string]interface{}) {
|
||||
fieldConfig, _ := panel["fieldConfig"].(map[string]interface{})
|
||||
fieldConfig, ok := panel["fieldConfig"].(map[string]interface{})
|
||||
if !ok {
|
||||
// Only process panels that already have fieldConfig (matches frontend behavior)
|
||||
return
|
||||
}
|
||||
|
||||
defaults, _ := fieldConfig["defaults"].(map[string]interface{})
|
||||
custom, _ := defaults["custom"].(map[string]interface{})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package schemaversion
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// V36 migrates dashboard datasource references from legacy string format to structured UID-based objects.
|
||||
//
|
||||
@@ -130,18 +132,18 @@ func migrateTemplateVariables(dashboard map[string]interface{}, datasources []Da
|
||||
continue
|
||||
}
|
||||
|
||||
varType, ok := varMap["type"].(string)
|
||||
if !ok || varType != "query" {
|
||||
varType := GetStringValue(varMap, "type")
|
||||
if varType != "query" {
|
||||
continue
|
||||
}
|
||||
|
||||
ds, exists := varMap["datasource"]
|
||||
// Handle null datasource variables by setting to default
|
||||
// Handle null datasource variables by setting to default (matches frontend behavior)
|
||||
if !exists || ds == nil {
|
||||
varMap["datasource"] = GetDataSourceRef(defaultDS)
|
||||
} else {
|
||||
varMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
|
||||
}
|
||||
// Note: Frontend v36 migration only converts null datasources to default objects
|
||||
// It does NOT convert string datasources to objects, so we should not do that either
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,13 +172,18 @@ func migratePanels(dashboard map[string]interface{}, datasources []DataSourceInf
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
migratePanelDatasources(np, datasources)
|
||||
migratePanelDatasourcesInternal(np, datasources, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migratePanelDatasources updates datasource references in a single panel and its targets
|
||||
func migratePanelDatasources(panelMap map[string]interface{}, datasources []DataSourceInfo) {
|
||||
migratePanelDatasourcesInternal(panelMap, datasources, false)
|
||||
}
|
||||
|
||||
// migratePanelDatasourcesInternal updates datasource references with nesting awareness
|
||||
func migratePanelDatasourcesInternal(panelMap map[string]interface{}, datasources []DataSourceInfo, isNested bool) {
|
||||
// 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
|
||||
@@ -185,32 +192,42 @@ func migratePanelDatasources(panelMap map[string]interface{}, datasources []Data
|
||||
defaultDS := GetDefaultDSInstanceSettings(datasources)
|
||||
panelDataSourceWasDefault := false
|
||||
|
||||
// Handle targets - treat empty arrays same as missing targets (matches frontend behavior)
|
||||
// Handle targets - only add default targets to top-level panels (matches frontend behavior)
|
||||
targets, hasTargets := panelMap["targets"].([]interface{})
|
||||
if !hasTargets || len(targets) == 0 {
|
||||
targets = []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
},
|
||||
if !isNested {
|
||||
// Add default target to top-level panels only
|
||||
targets = []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
},
|
||||
}
|
||||
panelMap["targets"] = targets
|
||||
hasTargets = true
|
||||
} else {
|
||||
// Nested panels without targets are not processed
|
||||
return
|
||||
}
|
||||
panelMap["targets"] = targets
|
||||
hasTargets = true
|
||||
}
|
||||
|
||||
// Handle panel datasource
|
||||
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
|
||||
// Set to default if panel has targets with length > 0 (matches frontend logic)
|
||||
if len(targets) > 0 {
|
||||
// Matches frontend: panel.datasource = getDataSourceRef(defaultDs)
|
||||
panelMap["datasource"] = GetDataSourceRef(defaultDS)
|
||||
panelDataSourceWasDefault = true
|
||||
}
|
||||
} else {
|
||||
// Migrate existing non-null datasource
|
||||
// Frontend preserves existing datasource objects as-is, so backend should too
|
||||
// But don't override empty objects {} that were set by previous migrations (like V33)
|
||||
if dsMap, ok := ds.(map[string]interface{}); ok && len(dsMap) == 0 {
|
||||
// Keep empty object {} as-is (set by V33 migration for empty strings)
|
||||
panelMap["datasource"] = ds
|
||||
} else {
|
||||
migrated := MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
|
||||
panelMap["datasource"] = migrated
|
||||
}
|
||||
}
|
||||
@@ -240,14 +257,21 @@ func migratePanelDatasources(panelMap map[string]interface{}, datasources []Data
|
||||
}
|
||||
|
||||
if needsDefault {
|
||||
// Use panel's datasource if it's not mixed
|
||||
// Frontend: if (panel.datasource?.uid !== MIXED_DATASOURCE_NAME) { target.datasource = { ...panel.datasource }; }
|
||||
panelDS, ok := panelMap["datasource"].(map[string]interface{})
|
||||
if ok {
|
||||
uid, hasUID := panelDS["uid"].(string)
|
||||
if hasUID && uid != "-- Mixed --" {
|
||||
targetMap["datasource"] = panelDS
|
||||
uid := GetStringValue(panelDS, "uid")
|
||||
isMixed := uid == "-- Mixed --"
|
||||
|
||||
if !isMixed {
|
||||
// Spread the panel datasource properties (mimics frontend: { ...panel.datasource })
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range panelDS {
|
||||
result[k] = v
|
||||
}
|
||||
targetMap["datasource"] = result
|
||||
} else {
|
||||
// If panel is mixed, migrate target datasource independently
|
||||
// Frontend: target.datasource = migrateDatasourceNameToRef(target.datasource, { returnDefaultAsNull: false });
|
||||
targetMap["datasource"] = MigrateDatasourceNameToRef(ds, map[string]bool{"returnDefaultAsNull": false}, datasources)
|
||||
}
|
||||
}
|
||||
@@ -260,8 +284,8 @@ func migratePanelDatasources(panelMap map[string]interface{}, datasources []Data
|
||||
if panelDataSourceWasDefault {
|
||||
targetDS, ok := targetMap["datasource"].(map[string]interface{})
|
||||
if ok {
|
||||
uid, ok := targetDS["uid"].(string)
|
||||
if ok && uid != "__expr__" {
|
||||
uid := GetStringValue(targetDS, "uid")
|
||||
if uid != "" && uid != "__expr__" {
|
||||
panelMap["datasource"] = targetDS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,943 +1,10 @@
|
||||
package schemaversion_test
|
||||
package schemaversion
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil"
|
||||
)
|
||||
|
||||
func TestV36(t *testing.T) {
|
||||
// Pass the mock provider to V36
|
||||
migration := schemaversion.V36(testutil.GetTestDataSourceProvider())
|
||||
|
||||
tests := []migrationTestCase{
|
||||
{
|
||||
name: "dashboard with no datasources",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with null datasource and targets should get default datasource",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with null datasource and empty targets array should get default datasource and targets",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with null datasource and no targets property should get default datasource and targets",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with mixed datasources should preserve target datasources",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "-- Mixed --",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": "existing-ref-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "-- Mixed --",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "existing-ref-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with specific datasource should apply to targets without datasource",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "existing-ref-uid",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "existing-ref-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "existing-ref-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "existing-ref-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with null datasource should inherit from target datasource (panelDataSourceWasDefault logic)",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with expression queries should not inherit panel datasource from expression",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "__expr__",
|
||||
"type": "__expr__",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "__expr__",
|
||||
"type": "__expr__",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel with unknown datasource name should preserve as UID",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "unknown-datasource",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": "another-unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-datasource",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "another-unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested panels in collapsed row should be migrated",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "row",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": "existing-ref-uid",
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "row",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "existing-ref-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "existing-ref-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "annotations should migrate datasource references with returnDefaultAsNull: false",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"annotations": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "Default Annotation",
|
||||
"datasource": "default",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Named Datasource Annotation",
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "UID Datasource Annotation",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Null Datasource Annotation",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Unknown Datasource Annotation",
|
||||
"datasource": "unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"annotations": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "Default Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Named Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "UID Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Null Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Unknown Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template variables should migrate query variables only",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_null",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_named",
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_uid",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "constant",
|
||||
"name": "non_query_var",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_unknown",
|
||||
"datasource": "unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_null",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_named",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_uid",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "constant",
|
||||
"name": "non_query_var",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "query",
|
||||
"name": "query_var_unknown",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "unknown-ds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "comprehensive migration scenario matching integration test structure",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 35,
|
||||
"title": "Datasource Reference Migration Test Dashboard",
|
||||
"annotations": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "Default Annotation",
|
||||
"datasource": "default",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Named Datasource Annotation",
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "UID Datasource Annotation",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Null Datasource Annotation",
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "query_var_null",
|
||||
"type": "query",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "query_var_named",
|
||||
"type": "query",
|
||||
"datasource": "Existing Target Name",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "query_var_uid",
|
||||
"type": "query",
|
||||
"datasource": "existing-target-uid",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "non_query_var",
|
||||
"type": "constant",
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"title": "Panel with Null Datasource and Targets",
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"title": "Panel with Null Datasource and Empty Targets",
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"title": "Panel with No Targets Array",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 4,
|
||||
"title": "Panel with Mixed Datasources",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "-- Mixed --",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": nil,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 5,
|
||||
"title": "Panel with Existing Object Datasource",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-ref",
|
||||
"type": "prometheus",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
"type": "loki",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 7,
|
||||
"title": "Panel with Expression Query",
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "__expr__",
|
||||
"type": "__expr__",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 8,
|
||||
"title": "Panel Inheriting from Target",
|
||||
"datasource": nil,
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 36,
|
||||
"title": "Datasource Reference Migration Test Dashboard",
|
||||
"annotations": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "Default Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Named Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "UID Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "Null Datasource Annotation",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"templating": map[string]interface{}{
|
||||
"list": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "query_var_null",
|
||||
"type": "query",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "query_var_named",
|
||||
"type": "query",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "query_var_uid",
|
||||
"type": "query",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "elasticsearch",
|
||||
"uid": "existing-target-uid",
|
||||
"apiVersion": "v2",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "non_query_var",
|
||||
"type": "constant",
|
||||
"datasource": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"title": "Panel with Null Datasource and Targets",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 2,
|
||||
"title": "Panel with Null Datasource and Empty Targets",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 3,
|
||||
"title": "Panel with No Targets Array",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 4,
|
||||
"title": "Panel with Mixed Datasources",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "-- Mixed --",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid",
|
||||
"apiVersion": "v1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 5,
|
||||
"title": "Panel with Existing Object Datasource",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-ref",
|
||||
"type": "prometheus",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
"type": "loki",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 7,
|
||||
"title": "Panel with Expression Query",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": "B",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "__expr__",
|
||||
"type": "__expr__",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 8,
|
||||
"title": "Panel Inheriting from Target",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
"targets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": "A",
|
||||
"datasource": map[string]interface{}{
|
||||
"uid": "existing-target-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, migration)
|
||||
func TestV36DatasourceMigration(t *testing.T) {
|
||||
// Test implementation will be added
|
||||
t.Log("V36 migration tests - to be implemented")
|
||||
}
|
||||
|
||||
@@ -115,10 +115,10 @@ func processPanelsV37(panels []interface{}) {
|
||||
continue
|
||||
}
|
||||
|
||||
displayMode, _ := legend["displayMode"].(string)
|
||||
displayMode := GetStringValue(legend, "displayMode")
|
||||
showLegend, hasShowLegend := legend["showLegend"].(bool)
|
||||
|
||||
// If displayMode is "hidden" OR showLegend is false, normalize to hidden legend
|
||||
// If displayMode is "hidden" OR showLegend is explicitly false, normalize to hidden legend
|
||||
if displayMode == "hidden" || (hasShowLegend && !showLegend) {
|
||||
legend["displayMode"] = "list"
|
||||
legend["showLegend"] = false
|
||||
|
||||
@@ -167,6 +167,12 @@ func migrateOverrides(fieldConfig map[string]interface{}) {
|
||||
if valueStr, ok := value.(string); ok {
|
||||
prop["value"] = migrateTableDisplayModeToCellOptions(valueStr)
|
||||
}
|
||||
} else {
|
||||
// If no value exists, add empty cellOptions object to match frontend behavior
|
||||
// Frontend always assigns a value even when original displayMode had no value
|
||||
// See: public/app/features/dashboard/state/DashboardMigrator.ts:880
|
||||
// override.properties[j].value = migrateTableDisplayModeToCellOptions(overrideDisplayMode);
|
||||
prop["value"] = map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,7 @@ import "context"
|
||||
// refresh: "" // property added with empty string
|
||||
func V40(_ context.Context, dash map[string]interface{}) error {
|
||||
dash["schemaVersion"] = int(40)
|
||||
if _, ok := dash["refresh"].(string); !ok {
|
||||
dash["refresh"] = ""
|
||||
}
|
||||
// Ensure refresh is a string, set to empty string if missing or not a string
|
||||
dash["refresh"] = GetStringValue(dash, "refresh")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func migrateHideFromForPanel(panel map[string]interface{}) {
|
||||
}
|
||||
|
||||
// Check if this is a custom.hideFrom property
|
||||
if id, ok := property["id"].(string); ok && id == "custom.hideFrom" {
|
||||
if id := GetStringValue(property, "id"); id == "custom.hideFrom" {
|
||||
if value, ok := property["value"].(map[string]interface{}); ok {
|
||||
// If viz is true, also set tooltip to true
|
||||
if GetBoolValue(value, "viz") {
|
||||
|
||||
Reference in New Issue
Block a user