Files
grafana/apps/dashboard/pkg/migration/frontend_defaults.go
Dominik Prokop 825f8fc7ff Dashboard migrations: Fix span: 0 bug and panel ordering in v16 dashboard migration (#112051)
Fix span: 0 bug and panel ordering in v16 dashboard migration

Fix span: 0 handling to match frontend behavior by defaulting to DEFAULT_PANEL_SPAN.
Fix panel ordering issue by using stable sort instead of unstable sort.
Fix collapsed property handling to only set when input row has collapse property.
Add comprehensive test cases for span: 0 bug and collapsed property behavior.
Add sanitized test input file for span: 0 demo dashboard with generic values instead of internal Grafana infrastructure references.

All backend migration tests and frontend comparison tests pass.
2025-10-06 13:29:33 +02:00

1088 lines
32 KiB
Go

package migration
import (
"sort"
)
// applyFrontendDefaults applies all DashboardModel constructor defaults
func applyFrontendDefaults(dashboard map[string]interface{}) {
// DashboardModel constructor defaults - only set defaults that the frontend actually sets
if dashboard["title"] == nil {
dashboard["title"] = "No Title"
}
if dashboard["tags"] == nil {
dashboard["tags"] = []interface{}{}
}
if dashboard["timezone"] == nil {
dashboard["timezone"] = ""
}
if dashboard["weekStart"] == nil {
dashboard["weekStart"] = ""
}
if dashboard["editable"] == nil {
dashboard["editable"] = true
}
if dashboard["graphTooltip"] == nil {
dashboard["graphTooltip"] = float64(0)
}
if dashboard["time"] == nil {
dashboard["time"] = map[string]interface{}{
"from": "now-6h",
"to": "now",
}
}
if dashboard["timepicker"] == nil {
dashboard["timepicker"] = map[string]interface{}{}
}
if dashboard["schemaVersion"] == nil {
dashboard["schemaVersion"] = float64(0)
}
if dashboard["fiscalYearStartMonth"] == nil {
dashboard["fiscalYearStartMonth"] = float64(0)
}
// Note: version is NOT set as a default - it's managed in metadata, not spec
if dashboard["links"] == nil {
dashboard["links"] = []interface{}{}
}
// Note: gnetId is handled by the frontend constructor as: this.gnetId = data.gnetId || null;
// But the frontend's JSON.stringify/parse in getSaveModelClone() removes null values
// So we should NOT set gnetId to null here - let it be handled by the cleanup phase
// Note: The frontend does NOT set defaults for these properties:
// - liveNow: copied as-is from input data
// - refresh: copied as-is from input data
// - snapshot: copied as-is from input data
// - scopeMeta: copied as-is from input data
// Structure normalizations
ensureTemplatingExists(dashboard)
ensureAnnotationsExist(dashboard)
// Note: ensurePanelsHaveUniqueIds is called AFTER applyPanelDefaults in migrate()
// to preserve original panel IDs and match frontend behavior
sortPanelsByGridPos(dashboard)
// Built-in components
addBuiltInAnnotationQuery(dashboard)
initMeta(dashboard)
// Variable cleanup
removeNullValuesFromVariables(dashboard)
}
// applyPanelDefaults applies all PanelModel constructor defaults
func applyPanelDefaults(panel map[string]interface{}) {
// PanelModel constructor defaults - only apply if property doesn't exist
// This matches the frontend's defaultsDeep behavior
if panel["gridPos"] == nil {
panel["gridPos"] = map[string]interface{}{
"x": float64(0), "y": float64(0), "h": float64(3), "w": float64(6),
}
}
if panel["targets"] == nil {
panel["targets"] = []interface{}{
map[string]interface{}{"refId": "A"},
}
}
if panel["cachedPluginOptions"] == nil {
panel["cachedPluginOptions"] = map[string]interface{}{}
}
if panel["transparent"] == nil {
panel["transparent"] = false
}
if panel["options"] == nil {
panel["options"] = map[string]interface{}{}
}
if panel["links"] == nil {
panel["links"] = []interface{}{}
}
if _, exists := panel["fieldConfig"]; !exists {
panel["fieldConfig"] = map[string]interface{}{
"defaults": map[string]interface{}{},
"overrides": []interface{}{},
}
} else {
// Add missing defaults and overrides (matches frontend defaultsDeep behavior)
if fieldConfig, ok := panel["fieldConfig"].(map[string]interface{}); ok {
if _, hasDefaults := fieldConfig["defaults"]; !hasDefaults {
fieldConfig["defaults"] = map[string]interface{}{}
}
if _, hasOverrides := fieldConfig["overrides"]; !hasOverrides {
fieldConfig["overrides"] = []interface{}{}
}
}
}
if panel["title"] == nil {
panel["title"] = ""
}
// Auto-migration logic is now applied during cleanup phase to match frontend behavior
// Structure normalizations
ensureQueryIds(panel)
}
// ensureTemplatingExists ensures templating.list exists
func ensureTemplatingExists(dashboard map[string]interface{}) {
if templating, ok := dashboard["templating"].(map[string]interface{}); ok {
if templating["list"] == nil {
templating["list"] = []interface{}{}
}
} else {
dashboard["templating"] = map[string]interface{}{
"list": []interface{}{},
}
}
}
// ensureAnnotationsExist ensures annotations.list exists
func ensureAnnotationsExist(dashboard map[string]interface{}) {
if annotations, ok := dashboard["annotations"].(map[string]interface{}); ok {
if annotations["list"] == nil {
annotations["list"] = []interface{}{}
}
} else {
dashboard["annotations"] = map[string]interface{}{
"list": []interface{}{},
}
}
}
// ensurePanelsHaveUniqueIds ensures all panels have unique IDs
func ensurePanelsHaveUniqueIds(dashboard map[string]interface{}) {
panels := getPanels(dashboard)
if len(panels) == 0 {
return
}
ids := make(map[float64]bool)
nextPanelId := getNextPanelId(panels)
for _, panel := range panels {
if panelID, ok := panel["id"].(float64); ok && panelID > 0 {
if ids[panelID] {
// Duplicate ID found, assign new one
panel["id"] = float64(nextPanelId)
nextPanelId++
} else {
// Valid unique ID, keep it
ids[panelID] = true
}
} else {
// No ID or invalid ID, assign new one
panel["id"] = float64(nextPanelId)
nextPanelId++
}
}
}
// getNextPanelId finds the next available panel ID
func getNextPanelId(panels []map[string]interface{}) int {
max := 0
for _, panel := range panels {
if panelID, ok := panel["id"].(float64); ok && panelID > 0 {
if int(panelID) > max {
max = int(panelID)
}
}
}
return max + 1
}
// sortPanelsByGridPos sorts panels by grid position (y first, then x)
func sortPanelsByGridPos(dashboard map[string]interface{}) {
panels := getPanels(dashboard)
if len(panels) == 0 {
return
}
sort.SliceStable(panels, func(i, j int) bool {
panelA := panels[i]
panelB := panels[j]
gridPosA, okA := panelA["gridPos"].(map[string]interface{})
gridPosB, okB := panelB["gridPos"].(map[string]interface{})
if !okA || !okB {
return false
}
yA, okA := gridPosA["y"].(float64)
yB, okB := gridPosB["y"].(float64)
if !okA || !okB {
return false
}
if yA == yB {
// Same row, sort by x
xA, okA := gridPosA["x"].(float64)
xB, okB := gridPosB["x"].(float64)
if !okA || !okB {
return false
}
return xA < xB
}
return yA < yB
})
}
// addBuiltInAnnotationQuery adds the built-in "Annotations & Alerts" annotation
func addBuiltInAnnotationQuery(dashboard map[string]interface{}) {
annotations, ok := dashboard["annotations"].(map[string]interface{})
if !ok {
return
}
list, ok := annotations["list"].([]interface{})
if !ok {
return
}
// Check if built-in annotation already exists
for _, item := range list {
if annotation, ok := item.(map[string]interface{}); ok {
if builtIn, ok := annotation["builtIn"].(float64); ok && builtIn == 1 {
return // Already exists
}
}
}
// Add built-in annotation
builtInAnnotation := map[string]interface{}{
"datasource": map[string]interface{}{
"uid": "-- Grafana --",
"type": "grafana",
},
"name": "Annotations & Alerts",
"type": "dashboard",
"iconColor": "rgba(0, 211, 255, 1)", // DEFAULT_ANNOTATION_COLOR
"enable": true,
"hide": true,
"builtIn": float64(1),
}
// Insert at the beginning
annotations["list"] = append([]interface{}{builtInAnnotation}, list...)
}
// initMeta initializes meta properties with defaults
func initMeta(dashboard map[string]interface{}) {
meta, ok := dashboard["meta"].(map[string]interface{})
if !ok {
meta = make(map[string]interface{})
dashboard["meta"] = meta
}
// Apply defaults
if meta["canShare"] == nil {
meta["canShare"] = true
}
if meta["canSave"] == nil {
meta["canSave"] = true
}
if meta["canStar"] == nil {
meta["canStar"] = true
}
if meta["canEdit"] == nil {
meta["canEdit"] = true
}
if meta["canDelete"] == nil {
meta["canDelete"] = true
}
// Derived properties
meta["showSettings"] = meta["canEdit"]
editable, _ := dashboard["editable"].(bool)
if meta["canSave"] == true && !editable {
meta["canMakeEditable"] = true
} else {
meta["canMakeEditable"] = false
}
meta["hasUnsavedFolderChange"] = false
// If dashboard is not editable, restrict permissions
if !editable {
meta["canEdit"] = false
meta["canDelete"] = false
meta["canSave"] = false
}
}
// removeNullValuesFromVariables removes null values from variable.current.value
func removeNullValuesFromVariables(dashboard map[string]interface{}) {
templating, ok := dashboard["templating"].(map[string]interface{})
if !ok {
return
}
list, ok := templating["list"].([]interface{})
if !ok || len(list) == 0 {
return
}
for _, item := range list {
if variable, ok := item.(map[string]interface{}); ok {
if current, ok := variable["current"].(map[string]interface{}); ok {
if value, exists := current["value"]; exists {
// Check for null value
if value == nil {
delete(current, "value")
} else if valueArray, isArray := value.([]interface{}); isArray {
// Check for null values in arrays
hasNull := false
for _, v := range valueArray {
if v == nil {
hasNull = true
break
}
}
if hasNull {
delete(current, "value")
}
}
}
}
}
}
}
// ensureQueryIds ensures all queries have refId
func ensureQueryIds(panel map[string]interface{}) {
targets, ok := panel["targets"].([]interface{})
if !ok || len(targets) == 0 {
return
}
// Check if any target is missing refId
hasMissingRefId := false
for _, target := range targets {
if targetMap, ok := target.(map[string]interface{}); ok {
if targetMap["refId"] == nil {
hasMissingRefId = true
break
}
}
}
if hasMissingRefId {
// Find existing refIds
existingRefIds := make(map[string]bool)
for _, target := range targets {
if targetMap, ok := target.(map[string]interface{}); ok {
if refId, ok := targetMap["refId"].(string); ok {
existingRefIds[refId] = true
}
}
}
// Assign refIds to targets that don't have them
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
letterIndex := 0
for _, target := range targets {
if targetMap, ok := target.(map[string]interface{}); ok {
if targetMap["refId"] == nil {
// Find next available refId
for letterIndex < len(letters) {
refId := string(letters[letterIndex])
if !existingRefIds[refId] {
targetMap["refId"] = refId
existingRefIds[refId] = true
break
}
letterIndex++
}
letterIndex++
}
}
}
}
}
// getPanels extracts all panels from the dashboard (including nested panels)
// This matches the frontend's depth-first iteration order in panelIterator()
func getPanels(dashboard map[string]interface{}) []map[string]interface{} {
var panels []map[string]interface{}
// Get top-level panels
if dashboardPanels, ok := dashboard["panels"].([]interface{}); ok {
for _, panelInterface := range dashboardPanels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
// Add top-level panel first
panels = append(panels, panel)
// Then add its nested panels (depth-first order)
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
for _, nestedPanelInterface := range nestedPanels {
if nestedPanel, ok := nestedPanelInterface.(map[string]interface{}); ok {
panels = append(panels, nestedPanel)
}
}
}
}
}
}
return panels
}
// cleanupPanelForSaveWithContext mimics the PanelModel.getSaveModel() behavior
// This removes properties that shouldn't be persisted and filters out default values
func cleanupPanelForSaveWithContext(panel map[string]interface{}, isNested bool) {
// Apply auto-migration logic (matches frontend PanelModel constructor)
// This happens during cleanup phase to match when frontend applies auto-migration
// Only apply auto-migration to top-level panels, not nested ones (matches frontend behavior)
if !isNested {
applyPanelAutoMigration(panel)
}
// Library panel specific cleanup (matches frontend behavior)
// Frontend only preserves id, title, gridPos, and libraryPanel for library panels
if libraryPanel, hasLibraryPanel := panel["libraryPanel"]; hasLibraryPanel && libraryPanel != nil {
// Create a new panel with only the essential properties
essentialProps := map[string]interface{}{
"id": panel["id"],
"title": panel["title"],
"gridPos": panel["gridPos"],
"libraryPanel": libraryPanel,
}
// Clear the original panel and copy back only essential properties
for key := range panel {
delete(panel, key)
}
for key, value := range essentialProps {
if value != nil {
panel[key] = value
}
}
return // Skip the rest of the cleanup for library panels
}
// Row panel specific cleanup (matches frontend behavior)
cleanupRowPanelProperties(panel)
// Track which properties were present in the input to preserve them even if they become empty
originalProperties := make(map[string]bool)
for key := range panel {
originalProperties[key] = true
}
// Properties that should never be persisted (notPersistedProperties)
notPersistedProps := map[string]bool{
"events": true,
"isViewing": true,
"isEditing": true,
"isInView": true,
"hasRefreshed": true,
"cachedPluginOptions": true, // This is the key one causing issues
"plugin": true,
"queryRunner": true,
"replaceVariables": true,
"configRev": true,
"hasSavedPanelEditChange": true,
"getDisplayTitle": true,
"dataSupport": true,
"key": true,
"isNew": true,
"refreshWhenInView": true,
"scopedVars": true, // Frontend removes scopedVars from save model
}
// Default values that should be filtered out if they match (defaults)
defaults := map[string]interface{}{
"gridPos": map[string]interface{}{
"x": float64(0), "y": float64(0), "h": float64(3), "w": float64(6),
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"cachedPluginOptions": map[string]interface{}{},
"transparent": false,
"options": map[string]interface{}{},
"links": []interface{}{},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{},
"overrides": []interface{}{},
},
"title": "",
}
// Remove notPersistedProperties
for prop := range notPersistedProps {
delete(panel, prop)
}
// Filter out properties that match defaults
for prop, defaultValue := range defaults {
if panelValue, exists := panel[prop]; exists {
if isEqual(panelValue, defaultValue) {
delete(panel, prop)
}
}
}
// Remove empty transformations array unless nested panel had them originally
if transformations, ok := panel["transformations"].([]interface{}); ok && len(transformations) == 0 {
if panel["_originallyHadTransformations"] != true || !isNested {
delete(panel, "transformations")
}
}
// Remove null values recursively to match frontend's JSON.stringify/parse behavior
// Pass panel type information to help with threshold handling
panelType := ""
if t, ok := panel["type"].(string); ok {
panelType = t
}
removeNullValuesRecursivelyWithContext(panel, panelType)
// Filter out properties that match defaults (matches frontend's isEqual logic)
filterDefaultValues(panel, originalProperties)
// Clean up internal markers
delete(panel, "_originallyHadTransformations")
}
// filterDefaultValues removes properties that match the default values (matches frontend's isEqual logic)
func filterDefaultValues(panel map[string]interface{}, originalProperties map[string]bool) {
// Get panel type for panel-specific defaults
panelType := ""
if t, ok := panel["type"].(string); ok {
panelType = t
}
// PanelModel defaults from frontend
defaults := map[string]interface{}{
"gridPos": map[string]interface{}{
"x": 0, "y": 0, "h": 3, "w": 6,
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"cachedPluginOptions": map[string]interface{}{},
"transparent": false,
"options": map[string]interface{}{},
"links": []interface{}{},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{},
"overrides": []interface{}{},
},
"title": "",
}
// Add panel-specific defaults
if panelType == "table" {
// Remove legacy table properties (matches frontend getSaveModel filtering)
// Exception: preserve original properties for old table panels with autoMigrateFrom="table-old"
legacyTableProps := []string{"pageSize", "scroll", "fontSize", "showHeader", "sort"}
for _, prop := range legacyTableProps {
if _, exists := panel[prop]; exists {
if autoMigrateFrom, hasAutoMigrate := panel["autoMigrateFrom"]; hasAutoMigrate && autoMigrateFrom == "table-old" {
if !originalProperties[prop] {
delete(panel, prop)
}
} else {
delete(panel, prop)
}
}
}
}
// Remove properties that match defaults, but preserve properties that were originally present
for prop, defaultValue := range defaults {
if panelValue, exists := panel[prop]; exists {
if isEqual(panelValue, defaultValue) {
// Special case: fieldConfig is always removed if it matches defaults (frontend getSaveModel behavior)
if prop == "fieldConfig" {
delete(panel, prop)
} else {
// Only remove if it wasn't originally present in the input
if !originalProperties[prop] {
delete(panel, prop)
}
}
}
}
}
// Remove empty targets arrays (frontend removes them in cleanup)
removeIfDefaultValue(panel, "targets", []interface{}{})
// Clean up fieldConfig to match frontend behavior
if fieldConfig, exists := panel["fieldConfig"].(map[string]interface{}); exists {
// Clean up fieldConfig defaults to match frontend behavior
if defaults, hasDefaults := fieldConfig["defaults"].(map[string]interface{}); hasDefaults {
// Remove properties that frontend considers as defaults and omits
cleanupFieldConfigDefaults(defaults, panel)
}
}
}
// isEqual checks if two values are equal (simplified version)
func isEqual(a, b interface{}) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
// For simple types, use direct comparison
switch aVal := a.(type) {
case bool:
if bVal, ok := b.(bool); ok {
return aVal == bVal
}
case string:
if bVal, ok := b.(string); ok {
return aVal == bVal
}
case float64:
if bVal, ok := b.(float64); ok {
return aVal == bVal
}
case []interface{}:
if bVal, ok := b.([]interface{}); ok {
if len(aVal) != len(bVal) {
return false
}
for i, v := range aVal {
if !isEqual(v, bVal[i]) {
return false
}
}
return true
}
case map[string]interface{}:
if bVal, ok := b.(map[string]interface{}); ok {
if len(aVal) != len(bVal) {
return false
}
for k, v := range aVal {
if !isEqual(v, bVal[k]) {
return false
}
}
return true
}
}
return false
}
// cleanupDashboardForSave applies the same cleanup logic as the frontend
func cleanupDashboardForSave(dashboard map[string]interface{}) {
removeNonPersistedProperties(dashboard)
removeNullValues(dashboard)
cleanupTemplating(dashboard)
cleanupPanels(dashboard)
cleanupDashboardDefaults(dashboard)
}
// removeNonPersistedProperties removes non-persisted dashboard properties
func removeNonPersistedProperties(dashboard map[string]interface{}) {
nonPersistedProperties := map[string]bool{
"events": true,
"meta": true,
"panels": true, // handled specially below
"templating": true, // handled specially below
"originalTime": true,
"originalTemplating": true,
"originalLibraryPanels": true,
"panelInEdit": true,
"panelInView": true,
"getVariablesFromState": true,
"formatDate": true,
"appEventsSubscription": true,
"panelsAffectedByVariableChange": true,
"lastRefresh": true,
"timeRangeUpdatedDuringEditOrView": true,
"originalDashboard": true,
}
for k, v := range nonPersistedProperties {
// Do not remove "panels" and "templating" here, as they are handled specially
if (k == "panels" || k == "templating") && v {
continue
}
if v {
delete(dashboard, k)
}
}
// Remove properties that frontend omits in getSaveModel
delete(dashboard, "variables")
}
// removeNullValues removes null values to match frontend's JSON.stringify/parse behavior
func removeNullValues(dashboard map[string]interface{}) {
// This handles gnetId: null and other null properties
removeIfDefaultValue(dashboard, "gnetId", nil)
}
// cleanupTemplating cleans up templating to match frontend's getTemplatingSaveModel behavior
func cleanupTemplating(dashboard map[string]interface{}) {
if templating, ok := dashboard["templating"].(map[string]interface{}); ok {
removeNullValuesRecursively(templating)
cleanupTemplatingVariables(templating)
}
}
// cleanupTemplatingVariables applies variable adapter logic
func cleanupTemplatingVariables(templating map[string]interface{}) {
if list, ok := templating["list"].([]interface{}); ok {
for _, variableInterface := range list {
if variable, ok := variableInterface.(map[string]interface{}); ok {
cleanupVariable(variable)
}
}
}
}
// cleanupVariable cleans up individual variable properties
func cleanupVariable(variable map[string]interface{}) {
// Remove null datasource
removeIfDefaultValue(variable, "datasource", nil)
// Remove properties that frontend omits in getSaveModel
delete(variable, "index")
// Apply variable type-specific logic
if variableType, ok := variable["type"].(string); ok {
switch variableType {
case "query":
// Query variables: keep options: [] if refresh !== never
// Since refresh is not specified in the input, it defaults to not "never"
if _, hasOptions := variable["options"]; !hasOptions {
variable["options"] = []interface{}{}
}
case "constant":
// Constant variables: remove options completely
delete(variable, "options")
case "datasource":
// Datasource variables: always set options to empty array
variable["options"] = []interface{}{}
case "custom":
// Custom variables: no special handling (just return rest)
// This is the default behavior - no additional processing needed
case "textbox":
// Textbox variables: handle query vs originalQuery logic
// For now, just return rest (no special handling needed for basic cases)
case "adhoc":
// Adhoc variables: no special handling
// This is the default behavior - no additional processing needed
}
}
}
// cleanupPanels cleans up panels and ensures panels property always exists
func cleanupPanels(dashboard map[string]interface{}) {
if panels, ok := dashboard["panels"].([]interface{}); ok {
// Filter out repeated panels (matches frontend getPanelSaveModels behavior)
// Frontend filters: !(panel.repeatPanelId || panel.repeatedByRow)
filteredPanels := []interface{}{}
for _, panelInterface := range panels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
// Skip panels with repeatPanelId or repeatedByRow
if _, hasRepeatPanelId := panel["repeatPanelId"]; hasRepeatPanelId {
continue
}
if _, hasRepeatedByRow := panel["repeatedByRow"]; hasRepeatedByRow {
continue
}
filteredPanels = append(filteredPanels, panel)
}
}
cleanupPanelList(filteredPanels)
sortPanelsByGridPosition(filteredPanels)
dashboard["panels"] = filteredPanels
} else {
// Ensure panels property exists even if empty (matches frontend behavior)
dashboard["panels"] = []interface{}{}
}
}
// cleanupPanelList cleans up all panels including nested ones
func cleanupPanelList(panels []interface{}) {
for _, panelInterface := range panels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
cleanupPanelForSaveWithContext(panel, false)
// Handle nested panels in row panels
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
for _, nestedPanelInterface := range nestedPanels {
if nestedPanel, ok := nestedPanelInterface.(map[string]interface{}); ok {
cleanupPanelForSaveWithContext(nestedPanel, true)
}
}
}
}
}
}
// sortPanelsByGridPosition sorts panels by grid position (matches frontend sortPanelsByGridPos behavior)
func sortPanelsByGridPosition(panels []interface{}) {
sort.SliceStable(panels, func(i, j int) bool {
panelA, okA := panels[i].(map[string]interface{})
panelB, okB := panels[j].(map[string]interface{})
if !okA || !okB {
return false
}
// Get gridPos or use default values if missing
gridPosA, okA := panelA["gridPos"].(map[string]interface{})
gridPosB, okB := panelB["gridPos"].(map[string]interface{})
// Default gridPos values (matches frontend PanelModel defaults)
defaultY := float64(0)
defaultX := float64(0)
yA := defaultY
if okA {
if y, ok := gridPosA["y"].(float64); ok {
yA = y
} else if y, ok := gridPosA["y"].(int); ok {
yA = float64(y)
}
}
yB := defaultY
if okB {
if y, ok := gridPosB["y"].(float64); ok {
yB = y
} else if y, ok := gridPosB["y"].(int); ok {
yB = float64(y)
}
}
if yA == yB {
xA := defaultX
if okA {
if x, ok := gridPosA["x"].(float64); ok {
xA = x
} else if x, ok := gridPosA["x"].(int); ok {
xA = float64(x)
}
}
xB := defaultX
if okB {
if x, ok := gridPosB["x"].(float64); ok {
xB = x
} else if x, ok := gridPosB["x"].(int); ok {
xB = float64(x)
}
}
return xA < xB
}
return yA < yB
})
}
// cleanupRowPanelProperties removes default row panel properties that frontend filters out
func cleanupRowPanelProperties(panel map[string]interface{}) {
panelType, ok := panel["type"].(string)
if !ok || panelType != "row" {
return
}
// Remove repeat if empty string (default value)
removeIfDefaultValue(panel, "repeat", "")
}
// applyPanelAutoMigration applies the same auto-migration logic as the frontend PanelModel constructor
func applyPanelAutoMigration(panel map[string]interface{}) {
panelType, ok := panel["type"].(string)
if !ok {
return
}
var newType string
// Graph needs special logic as it can be migrated to multiple panels
if panelType == "graph" {
// Check xaxis mode for special cases
if xaxis, ok := panel["xaxis"].(map[string]interface{}); ok {
if mode, ok := xaxis["mode"].(string); ok {
switch mode {
case "series":
// Check legend values for bargauge
if legend, ok := panel["legend"].(map[string]interface{}); ok {
if values, ok := legend["values"].(bool); ok && values {
newType = "bargauge"
} else {
newType = "barchart"
}
} else {
newType = "barchart"
}
case "histogram":
newType = "histogram"
}
}
}
// Default graph migration to timeseries
if newType == "" {
newType = "timeseries"
}
} else {
// Check autoMigrateAngular mapping
autoMigrateAngular := map[string]string{
"table-old": "table",
"singlestat": "stat",
"grafana-singlestat-panel": "stat",
"grafana-piechart-panel": "piechart",
"grafana-worldmap-panel": "geomap",
"natel-discrete-panel": "state-timeline",
}
if mappedType, exists := autoMigrateAngular[panelType]; exists {
newType = mappedType
}
}
// Apply auto-migration if a new type was determined
if newType != "" {
panel["autoMigrateFrom"] = panelType
panel["type"] = newType
}
}
// removeNullValuesRecursively removes null values from nested objects and arrays
// This matches the frontend's JSON.stringify/parse behavior
func removeNullValuesRecursively(data interface{}) {
removeNullValuesRecursivelyWithContext(data, "")
}
// removeNullValuesRecursivelyWithContext removes null values from nested objects and arrays
// This matches the frontend's JSON.stringify/parse behavior in getSaveModelClone()
func removeNullValuesRecursivelyWithContext(data interface{}, panelType string) {
switch v := data.(type) {
case map[string]interface{}:
// Remove null values from map
for key, value := range v {
if value == nil {
// Frontend removes null values via JSON serialization, so we should too
// No special case needed for threshold steps
delete(v, key)
} else {
// Recursively process nested values
removeNullValuesRecursivelyWithContext(value, panelType)
}
}
case []interface{}:
// Process array elements
for _, item := range v {
if item != nil {
removeNullValuesRecursivelyWithContext(item, panelType)
}
}
}
}
// removeIfDefaultValue removes the key from the map if its value equals the defaultValue
func removeIfDefaultValue(data map[string]interface{}, key string, defaultValue interface{}) {
if val, ok := data[key]; ok && isEqual(val, defaultValue) {
delete(data, key)
}
}
// cleanupDashboardDefaults removes dashboard-level default values that frontend filters out
func cleanupDashboardDefaults(dashboard map[string]interface{}) {
// Remove style if it's the default "dark" value
removeIfDefaultValue(dashboard, "style", "dark")
// Remove hideControls if it's the default false value
removeIfDefaultValue(dashboard, "hideControls", false)
// Remove dashboard id if it's null
removeIfDefaultValue(dashboard, "id", nil)
// Remove version property - it's managed by the backend metadata, not the spec
delete(dashboard, "version")
// Remove transient properties that frontend filters out during getSaveModelClone()
// These properties are lost during frontend's property copying loop in getSaveModelCloneOld()
delete(dashboard, "preload") // Transient dashboard loading state
delete(dashboard, "iteration") // Template variable iteration timestamp
}
// cleanupFieldConfigDefaults removes properties that frontend considers as defaults and omits
func cleanupFieldConfigDefaults(defaults map[string]interface{}, panel map[string]interface{}) {
// Don't remove mappings, color objects, or unit properties - frontend preserves them
// Remove empty custom objects from migrated singlestat panels (frontend filters them out)
if custom, exists := defaults["custom"].(map[string]interface{}); exists {
if len(custom) == 0 {
// Check if this is a migrated singlestat panel by looking for characteristic properties
isMigratedSinglestat := false
// Check for autoMigrateFrom property first
if autoMigrateFrom, exists := panel["autoMigrateFrom"]; exists {
if autoMigrateFrom == "singlestat" || autoMigrateFrom == "grafana-singlestat-panel" {
isMigratedSinglestat = true
}
}
// If autoMigrateFrom is not present, check for characteristic migrated singlestat properties
if !isMigratedSinglestat {
// Check for color with fixedColor and mode "fixed" (from sparkline migration)
if color, hasColor := defaults["color"].(map[string]interface{}); hasColor {
if _, hasFixedColor := color["fixedColor"].(string); hasFixedColor {
if mode, hasMode := color["mode"].(string); hasMode && mode == "fixed" {
// Check for mappings array (from valueMaps migration)
if _, hasMappings := defaults["mappings"].([]interface{}); hasMappings {
isMigratedSinglestat = true
}
}
}
}
}
// Only remove empty custom objects for migrated singlestat panels
if isMigratedSinglestat {
delete(defaults, "custom")
}
}
}
}
// trackOriginalTransformations marks panels that had transformations in the original input
// This is needed to match frontend hasOwnProperty behavior
func trackOriginalTransformations(dashboard map[string]interface{}) {
if panels, ok := dashboard["panels"].([]interface{}); ok {
for _, panelInterface := range panels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
trackPanelOriginalTransformations(panel)
}
}
}
}
// trackPanelOriginalTransformations recursively tracks transformations in panels and nested panels
func trackPanelOriginalTransformations(panel map[string]interface{}) {
// Mark if this panel had transformations in original input
if _, hasTransformations := panel["transformations"]; hasTransformations {
panel["_originallyHadTransformations"] = true
}
// Handle nested panels in row panels
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
for _, nestedPanelInterface := range nestedPanels {
if nestedPanel, ok := nestedPanelInterface.(map[string]interface{}); ok {
trackPanelOriginalTransformations(nestedPanel)
}
}
}
}