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