package migration import ( "context" "fmt" "sync" "time" "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" ) // DefaultCacheTTL is the default TTL for the datasource and library element caches. const DefaultCacheTTL = time.Minute // Initialize provides the migrator singleton with required dependencies and builds the map of migrations. func Initialize(dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider, cacheTTL time.Duration) { migratorInstance.init(dsIndexProvider, leIndexProvider, cacheTTL) } // GetDataSourceIndexProvider returns the datasource index provider instance that was initialized. func GetDataSourceIndexProvider() schemaversion.DataSourceIndexProvider { // Wait for initialization to complete <-migratorInstance.ready return migratorInstance.dsIndexProvider } // GetLibraryElementIndexProvider returns the library element index provider instance that was initialized. func GetLibraryElementIndexProvider() schemaversion.LibraryElementIndexProvider { // Wait for initialization to complete <-migratorInstance.ready return migratorInstance.leIndexProvider } // ResetForTesting resets the migrator singleton for testing purposes. func ResetForTesting() { migratorInstance = &migrator{ migrations: map[int]schemaversion.SchemaVersionMigrationFunc{}, ready: make(chan struct{}), dsIndexProvider: nil, leIndexProvider: nil, } initOnce = sync.Once{} } // PreloadCache preloads the datasource and library element caches for the given namespaces. func PreloadCache(ctx context.Context, nsInfos []types.NamespaceInfo) { // Wait for initialization to complete <-migratorInstance.ready // Try to preload datasource cache if preloadable, ok := migratorInstance.dsIndexProvider.(schemaversion.PreloadableCache); ok { preloadable.Preload(ctx, nsInfos) } // Try to preload library element cache if preloadable, ok := migratorInstance.leIndexProvider.(schemaversion.PreloadableCache); ok { preloadable.Preload(ctx, nsInfos) } } // PreloadCacheInBackground starts a goroutine that preloads the caches for the given namespaces. func PreloadCacheInBackground(nsInfos []types.NamespaceInfo) { go func() { defer func() { if r := recover(); r != nil { logging.DefaultLogger.Error("panic during cache preloading", "error", r) } }() PreloadCache(context.Background(), nsInfos) }() } // 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 dsIndexProvider schemaversion.DataSourceIndexProvider leIndexProvider schemaversion.LibraryElementIndexProvider } func (m *migrator) init(dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider, cacheTTL time.Duration) { initOnce.Do(func() { // Wrap the provider with org-aware TTL caching for all conversions. // This prevents repeated DB queries across multiple conversion calls while allowing // the cache to refresh periodically, making it suitable for long-lived singleton usage. m.dsIndexProvider = schemaversion.WrapIndexProviderWithCache(dsIndexProvider, cacheTTL) // Wrap library element provider with caching as well m.leIndexProvider = schemaversion.WrapLibraryElementProviderWithCache(leIndexProvider, cacheTTL) m.migrations = schemaversion.GetMigrations(m.dsIndexProvider, m.leIndexProvider) 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) } }