- Implement full conversion pipeline from v1beta1 → v2beta1 - Ensure frontend–backend parity for all dashboard serialization paths - Add automatic data loss detection for conversions (panels, queries, annotations, links, variables) - Extract atomic conversion functions for v0 → v1beta1 → v2alpha1 → v2beta1 - Introduce conversion metrics and detailed logging for loss tracking - Normalize datasource resolution, defaults, and annotation processing - Improve panel layout serialization and y-coordinate normalization - Fix inconsistencies in nested panels and collapsed row behavior - Refine variable handling: - Filter refId from variable query specs - Default variable refresh to 'never' (matches frontend) - Fix constant and interval variable handling for missing queries - Unify schema defaults (enable, hide, iconColor, editable, liveNow) - Fix pluginId usage (UID vs type) and datasource references - Fix metrics.go bug swallowing errors (return nil → return err) - Add tests for version-specific conversion error handling - Add data loss detection tests using source/target version comparison - Clean up lint issues, legacy code, and redundant files - Update OpenAPI snapshots and migrated dashboards - Improve backend migrator to reuse datasource provider and match frontend logic Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com> Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com> Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
199 lines
6.5 KiB
Go
199 lines
6.5 KiB
Go
package migration
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
|
)
|
|
|
|
// Initialize provides the migrator singleton with required dependencies and builds the map of migrations.
|
|
func Initialize(dsInfoProvider schemaversion.DataSourceInfoProvider) {
|
|
migratorInstance.init(dsInfoProvider)
|
|
}
|
|
|
|
// GetDataSourceInfoProvider returns the datasource info provider instance that was initialized.
|
|
// This allows reuse of the same provider instance across migrations and conversions.
|
|
func GetDataSourceInfoProvider() schemaversion.DataSourceInfoProvider {
|
|
// Wait for initialization to complete
|
|
<-migratorInstance.ready
|
|
return migratorInstance.dsInfoProvider
|
|
}
|
|
|
|
// ResetForTesting resets the migrator singleton for testing purposes.
|
|
func ResetForTesting() {
|
|
migratorInstance = &migrator{
|
|
migrations: map[int]schemaversion.SchemaVersionMigrationFunc{},
|
|
ready: make(chan struct{}),
|
|
dsInfoProvider: nil,
|
|
}
|
|
initOnce = sync.Once{}
|
|
}
|
|
|
|
// Migrate migrates the given dashboard to the target version.
|
|
// This will block until the migrator is initialized.
|
|
func Migrate(ctx context.Context, dash map[string]interface{}, targetVersion int) error {
|
|
return migratorInstance.migrate(ctx, dash, targetVersion)
|
|
}
|
|
|
|
var (
|
|
migratorInstance = &migrator{
|
|
migrations: map[int]schemaversion.SchemaVersionMigrationFunc{},
|
|
ready: make(chan struct{}),
|
|
}
|
|
initOnce sync.Once
|
|
)
|
|
|
|
type migrator struct {
|
|
ready chan struct{}
|
|
migrations map[int]schemaversion.SchemaVersionMigrationFunc
|
|
dsInfoProvider schemaversion.DataSourceInfoProvider
|
|
}
|
|
|
|
func (m *migrator) init(dsInfoProvider schemaversion.DataSourceInfoProvider) {
|
|
initOnce.Do(func() {
|
|
m.dsInfoProvider = dsInfoProvider
|
|
m.migrations = schemaversion.GetMigrations(dsInfoProvider)
|
|
close(m.ready)
|
|
})
|
|
}
|
|
|
|
func (m *migrator) migrate(ctx context.Context, dash map[string]interface{}, targetVersion int) error {
|
|
if dash == nil {
|
|
return schemaversion.NewMigrationError("dashboard is nil", 0, targetVersion, "")
|
|
}
|
|
|
|
// wait for the migrator to be initialized
|
|
<-m.ready
|
|
|
|
// 0. Clean up dashboard properties that frontend never includes in save model
|
|
// These properties are added by backend but frontend filters them out
|
|
delete(dash, "__elements")
|
|
delete(dash, "__inputs")
|
|
delete(dash, "__requires")
|
|
|
|
// 1. Track which panels had transformations in original input (before any defaults applied)
|
|
// This is needed to match frontend hasOwnProperty behavior
|
|
trackOriginalTransformations(dash)
|
|
|
|
// 1.1. Track which panels had fieldConfig.defaults.custom in original input
|
|
// This is needed to preserve empty custom objects that were originally present
|
|
trackOriginalFieldConfigCustom(dash)
|
|
|
|
// 2. Apply ALL frontend defaults FIRST (DashboardModel + PanelModel defaults)
|
|
// This replicates the behavior of the frontend DashboardModel and PanelModel constructors
|
|
applyFrontendDefaults(dash)
|
|
|
|
// 2. Apply panel defaults to ALL panels (both top-level and nested in rows)
|
|
// The frontend creates PanelModel instances for all panels, including those in rows
|
|
if dashboardPanels, ok := dash["panels"].([]interface{}); ok {
|
|
for _, panelInterface := range dashboardPanels {
|
|
if panel, ok := panelInterface.(map[string]interface{}); ok {
|
|
applyPanelDefaults(panel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply defaults to panels inside rows (for pre-v16 dashboards)
|
|
// Match frontend upgradeToGridLayout: only panels NOT in collapsed rows get new PanelModel() constructor
|
|
if rows, ok := dash["rows"].([]interface{}); ok {
|
|
showRows := shouldShowRows(rows)
|
|
|
|
for _, rowInterface := range rows {
|
|
row, ok := rowInterface.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
applyRowPanelDefaults(row, showRows)
|
|
}
|
|
}
|
|
|
|
// 3. Ensure panel IDs are unique for ALL panels (including nested ones)
|
|
// This matches the frontend ensurePanelsHaveUniqueIds() behavior
|
|
ensurePanelsHaveUniqueIds(dash)
|
|
|
|
// TODO: Probably we can check if we can migrate at the beginning of the function
|
|
// 4. Ensure schema version is set and if not default to 0
|
|
inputVersion := schemaversion.GetSchemaVersion(dash)
|
|
dash["schemaVersion"] = inputVersion
|
|
|
|
// If the schema version is older than the minimum version, with migration support,
|
|
// we don't migrate the dashboard.
|
|
if inputVersion < schemaversion.MIN_VERSION {
|
|
return schemaversion.NewMinimumVersionError(inputVersion)
|
|
}
|
|
|
|
// 5. Run existing migration pipeline UNCHANGED
|
|
// (All the existing v28, v29, etc. migrators run exactly as before)
|
|
for nextVersion := inputVersion + 1; nextVersion <= targetVersion; nextVersion++ {
|
|
if migration, ok := m.migrations[nextVersion]; ok {
|
|
if err := migration(ctx, dash); err != nil {
|
|
functionName := fmt.Sprintf("V%d", nextVersion)
|
|
return schemaversion.NewMigrationError("migration failed: "+err.Error(), inputVersion, nextVersion, functionName)
|
|
}
|
|
dash["schemaVersion"] = nextVersion
|
|
}
|
|
}
|
|
|
|
// 6. Add built-in annotation query after all migrations are complete
|
|
// This matches the frontend DashboardModel constructor behavior
|
|
addBuiltInAnnotationQuery(dash)
|
|
|
|
// 7. Clean up the dashboard to match frontend getSaveModel behavior
|
|
// This removes properties that shouldn't be persisted and filters out default values
|
|
cleanupDashboardForSave(dash)
|
|
|
|
if schemaversion.GetSchemaVersion(dash) != targetVersion {
|
|
return schemaversion.NewMigrationError("schema version not migrated to target version", inputVersion, targetVersion, "")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// shouldShowRows determines if row panels will be created (showRows logic)
|
|
func shouldShowRows(rows []interface{}) bool {
|
|
for _, rowInterface := range rows {
|
|
row, ok := rowInterface.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
collapse := schemaversion.GetBoolValue(row, "collapse")
|
|
showTitle := schemaversion.GetBoolValue(row, "showTitle")
|
|
repeat := schemaversion.GetStringValue(row, "repeat")
|
|
|
|
if collapse || showTitle || repeat != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// applyRowPanelDefaults applies panel defaults to panels within a row based on frontend logic
|
|
func applyRowPanelDefaults(row map[string]interface{}, showRows bool) {
|
|
rowPanels, ok := row["panels"].([]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
collapse := schemaversion.GetBoolValue(row, "collapse")
|
|
|
|
// Frontend: if (rowPanelModel && rowPanel.collapsed) { push(panel) } else { push(new PanelModel(panel)) }
|
|
// Only non-collapsed panels get PanelModel defaults (refId: "A", overrides: [], etc.)
|
|
applyDefaults := !showRows || !collapse
|
|
|
|
if !applyDefaults {
|
|
return
|
|
}
|
|
|
|
for _, panelInterface := range rowPanels {
|
|
panel, ok := panelInterface.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
applyPanelDefaults(panel)
|
|
}
|
|
}
|