mirror of https://github.com/grafana/grafana.git
				
				
				
			
		
			
				
	
	
		
			1155 lines
		
	
	
		
			35 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			1155 lines
		
	
	
		
			35 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")
 | 
						|
	delete(panel, "_originallyHadFieldConfigCustom")
 | 
						|
}
 | 
						|
 | 
						|
// 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 - handle preservation of original custom object first
 | 
						|
				if prop == "fieldConfig" {
 | 
						|
					// Check if we need to preserve the custom object before removing fieldConfig
 | 
						|
					if panel["_originallyHadFieldConfigCustom"] == true {
 | 
						|
						// Ensure fieldConfig structure exists with custom object
 | 
						|
						if fieldConfig, ok := panelValue.(map[string]interface{}); ok {
 | 
						|
							if defaults, ok := fieldConfig["defaults"].(map[string]interface{}); ok {
 | 
						|
								if _, hasCustom := defaults["custom"]; !hasCustom {
 | 
						|
									defaults["custom"] = map[string]interface{}{}
 | 
						|
								}
 | 
						|
								// Don't remove fieldConfig if we added custom back
 | 
						|
								continue
 | 
						|
							}
 | 
						|
						}
 | 
						|
					}
 | 
						|
					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{}{})
 | 
						|
 | 
						|
	// Handle case where fieldConfig was removed but originally had custom object
 | 
						|
	if panel["_originallyHadFieldConfigCustom"] == true {
 | 
						|
		if _, hasFieldConfig := panel["fieldConfig"]; !hasFieldConfig {
 | 
						|
			// Recreate fieldConfig with custom object
 | 
						|
			panel["fieldConfig"] = map[string]interface{}{
 | 
						|
				"defaults": map[string]interface{}{
 | 
						|
					"custom": map[string]interface{}{},
 | 
						|
				},
 | 
						|
				"overrides": []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)
 | 
						|
 | 
						|
			// Preserve custom object if it was originally present, even if empty
 | 
						|
			if panel["_originallyHadFieldConfigCustom"] == true {
 | 
						|
				if _, hasCustom := defaults["custom"]; !hasCustom {
 | 
						|
					defaults["custom"] = map[string]interface{}{}
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// 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)
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// trackOriginalFieldConfigCustom marks panels that had fieldConfig.defaults.custom in the original input
 | 
						|
// This is needed to match frontend hasOwnProperty behavior and preserve empty custom objects
 | 
						|
func trackOriginalFieldConfigCustom(dashboard map[string]interface{}) {
 | 
						|
	if panels, ok := dashboard["panels"].([]interface{}); ok {
 | 
						|
		for _, panelInterface := range panels {
 | 
						|
			if panel, ok := panelInterface.(map[string]interface{}); ok {
 | 
						|
				trackPanelOriginalFieldConfigCustom(panel)
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// trackPanelOriginalFieldConfigCustom recursively tracks fieldConfig.defaults.custom in panels and nested panels
 | 
						|
func trackPanelOriginalFieldConfigCustom(panel map[string]interface{}) {
 | 
						|
	// Mark if this panel had fieldConfig.defaults.custom in original input
 | 
						|
	if fieldConfig, ok := panel["fieldConfig"].(map[string]interface{}); ok {
 | 
						|
		if defaults, ok := fieldConfig["defaults"].(map[string]interface{}); ok {
 | 
						|
			if _, hasCustom := defaults["custom"]; hasCustom {
 | 
						|
				panel["_originallyHadFieldConfigCustom"] = 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 {
 | 
						|
				trackPanelOriginalFieldConfigCustom(nestedPanel)
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 |