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
@@ -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