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.
1088 lines
32 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|