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:
Ivan Ortega Alba
2025-09-24 12:20:25 +02:00
committed by GitHub
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
298 changed files with 158321 additions and 25235 deletions
@@ -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"
}
}
+295 -164
View File
@@ -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") {