grafana/apps/dashboard/pkg/migration/frontend_defaults.go

1088 lines
32 KiB
Go
Raw Normal View History

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