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)
}
@@ -0,0 +1,125 @@
{
"title": "V17 MinSpan to MaxPerRow Migration Test Dashboard",
"schemaVersion": 16,
"panels": [
{
"id": 1,
"type": "graph",
"title": "Panel with minSpan 8",
"minSpan": 8,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
}
},
{
"id": 2,
"type": "singlestat",
"title": "Panel with minSpan 4",
"minSpan": 4,
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 8
}
},
{
"id": 3,
"type": "table",
"title": "Panel with minSpan 2",
"minSpan": 2,
"gridPos": {
"h": 6,
"w": 24,
"x": 0,
"y": 12
}
},
{
"id": 4,
"type": "graph",
"title": "Panel with minSpan 12",
"minSpan": 12,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
}
},
{
"id": 5,
"type": "gauge",
"title": "Panel with minSpan 24",
"minSpan": 24,
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 18
}
},
{
"id": 6,
"type": "stat",
"title": "Panel with minSpan 1",
"minSpan": 1,
"gridPos": {
"h": 3,
"w": 4,
"x": 6,
"y": 8
}
},
{
"id": 7,
"type": "graph",
"title": "Panel without minSpan",
"gridPos": {
"h": 6,
"w": 8,
"x": 12,
"y": 8
}
},
{
"id": 8,
"type": "text",
"title": "Panel with invalid minSpan",
"minSpan": "invalid",
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 8
}
},
{
"id": 9,
"type": "graph",
"title": "Panel with zero minSpan",
"minSpan": 0,
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 22
}
},
{
"id": 10,
"type": "graph",
"title": "Panel with negative minSpan",
"minSpan": -5,
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 22
}
}
]
}
@@ -0,0 +1,317 @@
{
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"maxPerRow": 3,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with minSpan 8",
"type": "graph"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 8
},
"id": 2,
"maxPerRow": 6,
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"mean"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "1.0.0",
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with minSpan 4",
"type": "stat"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 24,
"x": 0,
"y": 12
},
"id": 3,
"maxPerRow": 12,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with minSpan 2",
"type": "table"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 4,
"maxPerRow": 2,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with minSpan 12",
"type": "graph"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 18
},
"id": 5,
"maxPerRow": 1,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with minSpan 24",
"type": "gauge"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 3,
"w": 4,
"x": 6,
"y": 8
},
"id": 6,
"maxPerRow": 24,
"options": {
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with minSpan 1",
"type": "stat"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 12,
"y": 8
},
"id": 7,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel without minSpan",
"type": "graph"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 8
},
"id": 8,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with invalid minSpan",
"type": "text"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 22
},
"id": 9,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with zero minSpan",
"type": "graph"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 22
},
"id": 10,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with negative minSpan",
"type": "graph"
}
],
"refresh": "",
"schemaVersion": 41,
"title": "V17 MinSpan to MaxPerRow Migration Test Dashboard"
}