Dashboard Migrations v17: Convert minSpan to maxPerRow in panels (#109592)

* Migration to be verified: v17 Convert minSpan to maxPerRow in panels

* Address review comments: use Go idiom for variable naming and sort.Ints for sorting
This commit is contained in:
Dominik Prokop
2025-08-18 08:02:37 -06:00
committed by GitHub
parent 29a652db21
commit 4e5a51968f
5 changed files with 873 additions and 1 deletions
@@ -5,7 +5,7 @@ import (
)
const (
MIN_VERSION = 17
MIN_VERSION = 16
LATEST_VERSION = 41
)
@@ -38,6 +38,7 @@ type PanelPluginInfoProvider interface {
func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc {
return map[int]SchemaVersionMigrationFunc{
17: V17,
18: V18,
19: V19,
20: V20,
@@ -0,0 +1,132 @@
package schemaversion
import (
"math"
"sort"
)
// V17 migrates panel minSpan property to maxPerRow property.
// This migration converts the deprecated minSpan property to the newer maxPerRow property
// which controls how many panels can be displayed in a single row.
//
// The conversion algorithm:
// 1. Calculate max = GRID_COLUMN_COUNT / panel.minSpan
// 2. Get all factors of GRID_COLUMN_COUNT (24): [1, 2, 3, 4, 6, 8, 12, 24]
// 3. Find the first factor greater than max
// 4. Use the previous factor as maxPerRow
//
// Example before migration:
//
// "panels": [
// {
// "id": 1,
// "type": "graph",
// "minSpan": 8
// }
// ]
//
// Example after migration:
//
// "panels": [
// {
// "id": 1,
// "type": "graph",
// "maxPerRow": 3
// }
// ]
//
// The minSpan property is removed after conversion.
func V17(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = 17
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
for _, p := range panels {
panel, ok := p.(map[string]interface{})
if !ok {
continue
}
migrateMinSpanToMaxPerRow(panel)
}
return nil
}
// migrateMinSpanToMaxPerRow converts minSpan to maxPerRow using the same algorithm as the frontend.
func migrateMinSpanToMaxPerRow(panel map[string]interface{}) {
minSpanValue, ok := panel["minSpan"]
if !ok {
return
}
// Convert minSpan to a number (could be int, float64, etc.)
var minSpan float64
switch v := minSpanValue.(type) {
case int:
minSpan = float64(v)
case float64:
minSpan = v
case int64:
minSpan = float64(v)
default:
// If we can't convert minSpan to a number, just delete it and return
delete(panel, "minSpan")
return
}
// Ensure minSpan is positive to avoid division by zero
if minSpan <= 0 {
delete(panel, "minSpan")
return
}
const gridColumnCount = 24
max := gridColumnCount / minSpan
factors := getFactors(gridColumnCount)
// Find the first factor greater than max
factorIndex := -1
for i, factor := range factors {
if float64(factor) > max {
factorIndex = i
break
}
}
// Use the previous factor as maxPerRow
if factorIndex > 0 {
panel["maxPerRow"] = factors[factorIndex-1]
} else if factorIndex == 0 {
// If the first factor is already greater than max, use 1
panel["maxPerRow"] = 1
} else {
// If no factor is greater than max, use the largest factor
panel["maxPerRow"] = factors[len(factors)-1]
}
// Remove the minSpan property
delete(panel, "minSpan")
}
// getFactors returns all factors of a number
// Example: getFactors(24) returns [1, 2, 3, 4, 6, 8, 12, 24]
func getFactors(num int) []int {
factors := []int{}
for i := 1; i <= int(math.Sqrt(float64(num))); i++ {
if num%i == 0 {
factors = append(factors, i)
if i != num/i {
factors = append(factors, num/i)
}
}
}
// Sort factors in ascending order
sort.Ints(factors)
return factors
}
@@ -0,0 +1,297 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV17(t *testing.T) {
tests := []migrationTestCase{
{
name: "panel with minSpan 8 gets converted to maxPerRow 3",
input: map[string]interface{}{
"title": "V17 MinSpan to MaxPerRow Migration Test Dashboard",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graph",
"title": "Test Panel",
"minSpan": 8,
},
},
},
expected: map[string]interface{}{
"title": "V17 MinSpan to MaxPerRow Migration Test Dashboard",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graph",
"title": "Test Panel",
"maxPerRow": 3,
},
},
},
},
{
name: "panel with minSpan 4 gets converted to maxPerRow 6",
input: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 2,
"type": "singlestat",
"title": "Single Stat Panel",
"minSpan": 4,
},
},
},
expected: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 2,
"type": "singlestat",
"title": "Single Stat Panel",
"maxPerRow": 6,
},
},
},
},
{
name: "panel with minSpan 2 gets converted to maxPerRow 12",
input: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 3,
"type": "graph",
"title": "Wide Panel",
"minSpan": 2,
},
},
},
expected: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 3,
"type": "graph",
"title": "Wide Panel",
"maxPerRow": 12,
},
},
},
},
{
name: "panel with minSpan 12 gets converted to maxPerRow 2",
input: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 4,
"type": "graph",
"title": "Narrow Panel",
"minSpan": 12,
},
},
},
expected: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 4,
"type": "graph",
"title": "Narrow Panel",
"maxPerRow": 2,
},
},
},
},
{
name: "panel with minSpan 24 gets converted to maxPerRow 1",
input: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 5,
"type": "graph",
"title": "Full Width Panel",
"minSpan": 24,
},
},
},
expected: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 5,
"type": "graph",
"title": "Full Width Panel",
"maxPerRow": 1,
},
},
},
},
{
name: "panel with minSpan 1 gets converted to maxPerRow 24",
input: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 6,
"type": "graph",
"title": "Tiny Panel",
"minSpan": 1,
},
},
},
expected: map[string]interface{}{
"title": "V17 MinSpan Migration Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 6,
"type": "graph",
"title": "Tiny Panel",
"maxPerRow": 24,
},
},
},
},
{
name: "multiple panels with different minSpan values",
input: map[string]interface{}{
"title": "V17 Multiple Panels Migration Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graph",
"title": "Panel 1",
"minSpan": 8,
},
map[string]interface{}{
"id": 2,
"type": "singlestat",
"title": "Panel 2",
"minSpan": 4,
},
map[string]interface{}{
"id": 3,
"type": "table",
"title": "Panel 3 - No minSpan",
},
},
},
expected: map[string]interface{}{
"title": "V17 Multiple Panels Migration Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graph",
"title": "Panel 1",
"maxPerRow": 3,
},
map[string]interface{}{
"id": 2,
"type": "singlestat",
"title": "Panel 2",
"maxPerRow": 6,
},
map[string]interface{}{
"id": 3,
"type": "table",
"title": "Panel 3 - No minSpan",
},
},
},
},
{
name: "panel with invalid minSpan gets cleaned up",
input: map[string]interface{}{
"title": "V17 Invalid MinSpan Test",
"schemaVersion": 16,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graph",
"title": "Invalid MinSpan Panel",
"minSpan": "invalid",
},
map[string]interface{}{
"id": 2,
"type": "graph",
"title": "Zero MinSpan Panel",
"minSpan": 0,
},
map[string]interface{}{
"id": 3,
"type": "graph",
"title": "Negative MinSpan Panel",
"minSpan": -5,
},
},
},
expected: map[string]interface{}{
"title": "V17 Invalid MinSpan Test",
"schemaVersion": 17,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graph",
"title": "Invalid MinSpan Panel",
},
map[string]interface{}{
"id": 2,
"type": "graph",
"title": "Zero MinSpan Panel",
},
map[string]interface{}{
"id": 3,
"type": "graph",
"title": "Negative MinSpan Panel",
},
},
},
},
{
name: "dashboard with no panels",
input: map[string]interface{}{
"title": "V17 No Panels Test",
"schemaVersion": 16,
},
expected: map[string]interface{}{
"title": "V17 No Panels Test",
"schemaVersion": 17,
},
},
{
name: "dashboard with empty panels array",
input: map[string]interface{}{
"title": "V17 Empty Panels Test",
"schemaVersion": 16,
"panels": []interface{}{},
},
expected: map[string]interface{}{
"title": "V17 Empty Panels Test",
"schemaVersion": 17,
"panels": []interface{}{},
},
},
}
runMigrationTests(t, tests, schemaversion.V17)
}