Fix dashboard migration discrepancies between backend and frontend implementations (use toEqual) (#110268)
**Highlights**
* **Single-version migrations**: add `targetVersion` to migrator & model, separate outputs, enforce exact version.
* **Datasource fixes**: include `apiVersion` in tests, empty-string → `{}`, preserve `{}` refs, drop unwanted defaults.
* **Panel defaults & nesting**: only top-level panels get defaults; preserve empty `transformations` context-aware; filter repeated panels.
* **Migration parity**
* V16: collapsed rows, grid height parsing (`px`).
* V17: omit `maxPerRow` when `minSpan=1`.
* V19–V20: cleanup defaults (`targetBlank`, style).
* V23–V24: template vars + table panel consistency.
* V28: full singlestat/stat parity, mappings & color.
* V30–V36: threshold logic, empty refs, nested targets.
* **Save-model cleanup**: replicate frontend defaults/filtering, drop null IDs, metadata, unused props.
* **Testing**: unified suites, dev dashboards (v42), full unit coverage for major migrations.
Co-authored-by: Ivan Ortega [ivanortegaalba@gmail.com](mailto:ivanortegaalba@gmail.com)
Co-authored-by: Dominik Prokop [dominik.prokop@grafana.com](mailto:dominik.prokop@grafana.com)
This commit is contained in:
co-authored by
Ivan Ortega [ivanortegaalba@gmail.com](mailto:ivanortegaalba@gmail.com)
Dominik Prokop [dominik.prokop@grafana.com](mailto:dominik.prokop@grafana.com)
parent
98fd3e8fe9
commit
a72e02f88a
@@ -13,6 +13,15 @@ func Initialize(dsInfoProvider schemaversion.DataSourceInfoProvider) {
|
||||
migratorInstance.init(dsInfoProvider)
|
||||
}
|
||||
|
||||
// ResetForTesting resets the migrator singleton for testing purposes.
|
||||
func ResetForTesting() {
|
||||
migratorInstance = &migrator{
|
||||
migrations: map[int]schemaversion.SchemaVersionMigrationFunc{},
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
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 {
|
||||
@@ -47,6 +56,51 @@ func (m *migrator) migrate(ctx context.Context, dash map[string]interface{}, tar
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -56,6 +110,8 @@ func (m *migrator) migrate(ctx context.Context, dash map[string]interface{}, tar
|
||||
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 {
|
||||
@@ -66,9 +122,58 @@ func (m *migrator) migrate(ctx context.Context, dash map[string]interface{}, tar
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user