package conversion import ( "errors" "fmt" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) // Conversion Data Loss Detection Strategy for Dashboard Conversions // // This module implements comprehensive data loss detection to identify when data is lost during // dashboard conversions between different API versions (v0alpha1, v1beta1, v2alpha1, v2beta1). // // # What We Detect // // The data loss detection system checks that critical dashboard components are preserved: // // 1. **Panel Count**: Total number of visualization panels (including library panels) // - Counts regular panels and library panel references // - Excludes row panels (which are layout containers, not visualizations) // - Includes panels nested inside collapsed rows // // 2. **Query Count**: Total number of data source queries across all panels // - Each panel can have multiple queries (targets) // - Queries define what data is fetched and displayed // - Loss of queries means loss of data visualization // // 3. **Annotation Count**: Number of annotation configurations // - Annotations mark events and time ranges on visualizations // - Each annotation can query a datasource for events // // 4. **Dashboard Link Count**: Number of navigation links to other dashboards or URLs // - Links enable dashboard navigation workflows // - Important for dashboard ecosystems // ConversionDataLossError represents data loss detected during dashboard conversion type ConversionDataLossError struct { functionName string message string sourceAPIVersion string targetAPIVersion string } // NewConversionDataLossError creates a new ConversionDataLossError func NewConversionDataLossError(functionName, message, sourceAPIVersion, targetAPIVersion string) *ConversionDataLossError { return &ConversionDataLossError{ functionName: functionName, message: message, sourceAPIVersion: sourceAPIVersion, targetAPIVersion: targetAPIVersion, } } // Error implements the error interface func (e *ConversionDataLossError) Error() string { return fmt.Sprintf("data loss detected in %s (%s → %s): %s", e.functionName, e.sourceAPIVersion, e.targetAPIVersion, e.message) } // GetFunctionName returns the function name where data loss was detected func (e *ConversionDataLossError) GetFunctionName() string { return e.functionName } // GetSourceAPIVersion returns the source API version func (e *ConversionDataLossError) GetSourceAPIVersion() string { return e.sourceAPIVersion } // GetTargetAPIVersion returns the target API version func (e *ConversionDataLossError) GetTargetAPIVersion() string { return e.targetAPIVersion } // dashboardStats contains statistics about a dashboard for data loss detection type dashboardStats struct { panelCount int queryCount int annotationCount int linkCount int variableCount int } // countPanelsV0V1 counts panels in v0alpha1 or v1beta1 dashboard spec (unstructured JSON) func countPanelsV0V1(spec map[string]interface{}) int { if spec == nil { return 0 } panels, ok := spec["panels"].([]interface{}) if !ok { return 0 } count := 0 for _, p := range panels { panelMap, ok := p.(map[string]interface{}) if !ok { continue } // Count regular panels (excluding row panels) panelType, _ := panelMap["type"].(string) if panelType != "row" { count++ } // Count collapsed panels inside row panels if panelType == "row" { if collapsedPanels, ok := panelMap["panels"].([]interface{}); ok { count += len(collapsedPanels) } } } return count } // countTargetsFromPanel counts the number of targets/queries in a panel. func countTargetsFromPanel(panelMap map[string]interface{}) int { if targets, ok := panelMap["targets"].([]interface{}); ok { return len(targets) } return 0 } // countQueriesV0V1 counts data queries in v0alpha1 or v1beta1 dashboard spec // Note: Row panels are layout containers and should not have queries. // We ignore any queries on row panels themselves, but count queries in their collapsed panels. func countQueriesV0V1(spec map[string]interface{}) int { if spec == nil { return 0 } panels, ok := spec["panels"].([]interface{}) if !ok { return 0 } count := 0 for _, p := range panels { panelMap, ok := p.(map[string]interface{}) if !ok { continue } panelType, _ := panelMap["type"].(string) // Count queries in regular panels (NOT row panels) if panelType != "row" { count += countTargetsFromPanel(panelMap) } // Count queries in collapsed panels inside row panels if panelType == "row" { if collapsedPanels, ok := panelMap["panels"].([]interface{}); ok { for _, cp := range collapsedPanels { if cpMap, ok := cp.(map[string]interface{}); ok { count += countTargetsFromPanel(cpMap) } } } } } return count } // countAnnotationsV0V1 counts annotations in v0alpha1 or v1beta1 dashboard spec func countAnnotationsV0V1(spec map[string]interface{}) int { if spec == nil { return 0 } annotations, ok := spec["annotations"].(map[string]interface{}) if !ok { return 0 } annotationList, ok := annotations["list"].([]interface{}) if !ok { return 0 } return len(annotationList) } // countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec func countLinksV0V1(spec map[string]interface{}) int { if spec == nil { return 0 } links, ok := spec["links"].([]interface{}) if !ok { return 0 } return len(links) } // countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec func countVariablesV0V1(spec map[string]interface{}) int { if spec == nil { return 0 } templating, ok := spec["templating"].(map[string]interface{}) if !ok { return 0 } variableList, ok := templating["list"].([]interface{}) if !ok { return 0 } return len(variableList) } // collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard func collectStatsV0V1(spec map[string]interface{}) dashboardStats { return dashboardStats{ panelCount: countPanelsV0V1(spec), queryCount: countQueriesV0V1(spec), annotationCount: countAnnotationsV0V1(spec), linkCount: countLinksV0V1(spec), variableCount: countVariablesV0V1(spec), } } // countPanelsV2 counts panels in v2alpha1 or v2beta1 dashboard spec (structured) func countPanelsV2(elements map[string]dashv2alpha1.DashboardElement) int { count := 0 for _, element := range elements { // Check if element is a Panel (not a LibraryPanel) if element.PanelKind != nil { count++ } else if element.LibraryPanelKind != nil { count++ } } return count } // countQueriesV2 counts data queries in v2alpha1 or v2beta1 dashboard spec func countQueriesV2(elements map[string]dashv2alpha1.DashboardElement) int { count := 0 for _, element := range elements { if element.PanelKind != nil { count += len(element.PanelKind.Spec.Data.Spec.Queries) } } return count } // countAnnotationsV2 counts annotations in v2alpha1 or v2beta1 dashboard spec func countAnnotationsV2(annotations []dashv2alpha1.DashboardAnnotationQueryKind) int { return len(annotations) } // countLinksV2 counts dashboard links in v2alpha1 or v2beta1 dashboard spec func countLinksV2(links []dashv2alpha1.DashboardDashboardLink) int { return len(links) } // countVariablesV2 counts template variables in v2alpha1 or v2beta1 dashboard spec func countVariablesV2(variables []dashv2alpha1.DashboardVariableKind) int { return len(variables) } // collectStatsV2alpha1 collects statistics from v2alpha1 dashboard func collectStatsV2alpha1(spec dashv2alpha1.DashboardSpec) dashboardStats { return dashboardStats{ panelCount: countPanelsV2(spec.Elements), queryCount: countQueriesV2(spec.Elements), annotationCount: countAnnotationsV2(spec.Annotations), linkCount: countLinksV2(spec.Links), variableCount: countVariablesV2(spec.Variables), } } // countPanelsV2beta1 counts panels in v2beta1 dashboard spec func countPanelsV2beta1(elements map[string]dashv2beta1.DashboardElement) int { count := 0 for _, element := range elements { // Check if element is a Panel (not a LibraryPanel) if element.PanelKind != nil { count++ } else if element.LibraryPanelKind != nil { count++ } } return count } // countQueriesV2beta1 counts data queries in v2beta1 dashboard spec func countQueriesV2beta1(elements map[string]dashv2beta1.DashboardElement) int { count := 0 for _, element := range elements { if element.PanelKind != nil { count += len(element.PanelKind.Spec.Data.Spec.Queries) } } return count } // countAnnotationsV2beta1 counts annotations in v2beta1 dashboard spec func countAnnotationsV2beta1(annotations []dashv2beta1.DashboardAnnotationQueryKind) int { return len(annotations) } // countLinksV2beta1 counts dashboard links in v2beta1 dashboard spec func countLinksV2beta1(links []dashv2beta1.DashboardDashboardLink) int { return len(links) } // countVariablesV2beta1 counts template variables in v2beta1 dashboard spec func countVariablesV2beta1(variables []dashv2beta1.DashboardVariableKind) int { return len(variables) } // collectStatsV2beta1 collects statistics from v2beta1 dashboard func collectStatsV2beta1(spec dashv2beta1.DashboardSpec) dashboardStats { return dashboardStats{ panelCount: countPanelsV2beta1(spec.Elements), queryCount: countQueriesV2beta1(spec.Elements), annotationCount: countAnnotationsV2beta1(spec.Annotations), linkCount: countLinksV2beta1(spec.Links), variableCount: countVariablesV2beta1(spec.Variables), } } // detectConversionDataLoss detects if critical dashboard data was lost during conversion // Note: We only check for DATA LOSS (target < source), not additions (target > source). // Conversions may add default values (like built-in annotations) which is expected behavior. func detectConversionDataLoss(sourceStats, targetStats dashboardStats, sourceFuncName, targetFuncName string) error { var errors []string // Panel count: detect loss only (target < source) if targetStats.panelCount < sourceStats.panelCount { errors = append(errors, fmt.Sprintf( "panel count decreased: source=%d, target=%d (loss of %d panels)", sourceStats.panelCount, targetStats.panelCount, sourceStats.panelCount-targetStats.panelCount, )) } // Query count: detect loss only (target < source) if targetStats.queryCount < sourceStats.queryCount { errors = append(errors, fmt.Sprintf( "query count decreased: source=%d, target=%d (loss of %d queries)", sourceStats.queryCount, targetStats.queryCount, sourceStats.queryCount-targetStats.queryCount, )) } // Annotation count: detect loss only (target < source) // Note: Conversions may add default annotations, so additions are allowed if targetStats.annotationCount < sourceStats.annotationCount { errors = append(errors, fmt.Sprintf( "annotation count decreased: source=%d, target=%d (loss of %d annotations)", sourceStats.annotationCount, targetStats.annotationCount, sourceStats.annotationCount-targetStats.annotationCount, )) } // Dashboard link count: detect loss only (target < source) if targetStats.linkCount < sourceStats.linkCount { errors = append(errors, fmt.Sprintf( "dashboard link count decreased: source=%d, target=%d (loss of %d links)", sourceStats.linkCount, targetStats.linkCount, sourceStats.linkCount-targetStats.linkCount, )) } // Variable count: detect loss only (target < source) if targetStats.variableCount < sourceStats.variableCount { errors = append(errors, fmt.Sprintf( "variable count decreased: source=%d, target=%d (loss of %d variables)", sourceStats.variableCount, targetStats.variableCount, sourceStats.variableCount-targetStats.variableCount, )) } if len(errors) > 0 { errorMsg := fmt.Sprintf("%v", errors) // Note: sourceAPIVersion and targetAPIVersion are passed from checkConversionDataLoss // For now, use empty strings - they will be set by the caller return NewConversionDataLossError(fmt.Sprintf("%s_to_%s", sourceFuncName, targetFuncName), errorMsg, "", "") } return nil } // checkConversionDataLoss is the data loss check function that can be passed to withConversionMetrics // It collects statistics from source and target dashboards and detects if data was lost // Returns a ConversionDataLossError if data loss is detected, nil otherwise func checkConversionDataLoss(sourceVersionAPI, targetVersionAPI string, a, b interface{}) error { // Collect source statistics sourceStats := collectDashboardStats(a) // Collect target statistics targetStats := collectDashboardStats(b) // Detect if data was lost err := detectConversionDataLoss(sourceStats, targetStats, convertAPIVersionToFuncName(sourceVersionAPI), convertAPIVersionToFuncName(targetVersionAPI)) // If data loss was detected, update the error with API versions if err != nil { var dataLossErr *ConversionDataLossError if errors.As(err, &dataLossErr) { dataLossErr.sourceAPIVersion = sourceVersionAPI dataLossErr.targetAPIVersion = targetVersionAPI } } return err } // collectDashboardStats collects statistics from a dashboard object (any version) func collectDashboardStats(dashboard interface{}) dashboardStats { switch d := dashboard.(type) { case *dashv0.Dashboard: if d.Spec.Object != nil { return collectStatsV0V1(d.Spec.Object) } case *dashv1.Dashboard: if d.Spec.Object != nil { return collectStatsV0V1(d.Spec.Object) } case *dashv2alpha1.Dashboard: return collectStatsV2alpha1(d.Spec) case *dashv2beta1.Dashboard: return collectStatsV2beta1(d.Spec) } return dashboardStats{} }