Dashboard Migrations v16: Grid layout migration (#109603)

* 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

* Migration to be verified: 16 Grid layout migration

* Refactor v17 and v19 migrations to use shared helper functions

* Address reviews comments

---------

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
This commit is contained in:
Dominik Prokop
2025-08-20 17:31:32 +00:00
committed by GitHub
co-authored by Haris Rozajac Haris Rozajac
parent 95ea14cc72
commit 49739618fa
9 changed files with 2344 additions and 30 deletions
@@ -0,0 +1,79 @@
package schemaversion
// migration_utils.go contains shared utility functions used across multiple schema version migrations.
// GetStringValue safely extracts a string value from a map, returning empty string if not found or not a string
func GetStringValue(m map[string]interface{}, key string) string {
if value, ok := m[key]; ok {
if s, ok := value.(string); ok {
return s
}
}
return ""
}
// GetBoolValue safely extracts a boolean value from a map, returning false if not found or not a boolean
func GetBoolValue(m map[string]interface{}, key string) bool {
if value, ok := m[key]; ok {
if b, ok := value.(bool); ok {
return b
}
}
return false
}
// GetIntValue safely extracts an integer value from a map, returning defaultValue if not found or not convertible
func GetIntValue(m map[string]interface{}, key string, defaultValue int) int {
if value, ok := m[key]; ok {
if i, ok := ConvertToInt(value); ok {
return i
}
}
return defaultValue
}
// GetFloatValue safely extracts a float value from a map, returning defaultValue if not found or not convertible
func GetFloatValue(m map[string]interface{}, key string, defaultValue float64) float64 {
if value, ok := m[key]; ok {
if f, ok := ConvertToFloat(value); ok {
return f
}
}
return defaultValue
}
// ConvertToFloat converts various numeric types to float64
func ConvertToFloat(value interface{}) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case int:
return float64(v), true
case int64:
return float64(v), true
case float32:
return float64(v), true
case int32:
return float64(v), true
default:
return 0, false
}
}
// ConvertToInt converts various numeric types to int
func ConvertToInt(value interface{}) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case float64:
return int(v), true
case int64:
return int(v), true
case float32:
return int(v), true
case int32:
return int(v), true
default:
return 0, false
}
}
@@ -5,7 +5,7 @@ import (
)
const (
MIN_VERSION = 16
MIN_VERSION = 15
LATEST_VERSION = 41
)
@@ -38,6 +38,7 @@ type PanelPluginInfoProvider interface {
func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc {
return map[int]SchemaVersionMigrationFunc{
16: V16,
17: V17,
18: V18,
19: V19,
@@ -0,0 +1,308 @@
package schemaversion
import (
"math"
)
const (
gridColumnCount = 24.0
defaultPanelSpan = 4.0
defaultRowHeight = 250.0
gridCellHeight = 30.0
gridCellVMargin = 8.0
minPanelHeight = gridCellHeight * 3.0
panelHeightStep = gridCellHeight + gridCellVMargin
)
// V16 migrates dashboard layout from the old row-based system to the modern grid-based layout.
// This migration follows the exact logic from DashboardMigrator.ts to ensure consistency between frontend and backend.
func V16(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = 16
upgradeToGridLayout(dashboard)
return nil
}
func upgradeToGridLayout(dashboard map[string]interface{}) {
rowsInterface, ok := dashboard["rows"]
if !ok {
return
}
rows, ok := rowsInterface.([]interface{})
if !ok {
return
}
// Handle empty rows
if len(rows) == 0 {
dashboard["panels"] = []interface{}{}
delete(dashboard, "rows")
return
}
yPos := 0
widthFactor := gridColumnCount / 12.0
// Find max panel ID (lines 1014-1021 in TS)
maxPanelID := getMaxPanelID(rows)
nextRowID := maxPanelID + 1
// Get existing panels
var finalPanels []interface{}
if existingPanels, ok := dashboard["panels"].([]interface{}); ok {
finalPanels = existingPanels
}
// Add special "row" panels if even one row is collapsed, repeated or has visible title (line 1028 in TS)
showRows := shouldShowRows(rows)
// Process each row (line 1030 in TS)
for _, rowInterface := range rows {
row, ok := rowInterface.(map[string]interface{})
if !ok {
continue
}
// Skip repeated rows (line 1031-1033 in TS)
if _, hasRepeatIteration := row["repeatIteration"]; hasRepeatIteration {
continue
}
height := getRowHeight(row)
rowGridHeight := getGridHeight(height)
isCollapsed := GetBoolValue(row, "collapse")
var rowPanel map[string]interface{}
// First pass: assign IDs to panels that don't have them
panelsInRow, ok := row["panels"].([]interface{})
if !ok {
panelsInRow = []interface{}{}
}
for _, panelInterface := range panelsInRow {
panel, ok := panelInterface.(map[string]interface{})
if !ok {
continue
}
// Assign ID if missing
if _, hasID := panel["id"]; !hasID {
panel["id"] = nextRowID
nextRowID++
}
}
if showRows {
// add special row panel (lines 1041-1058 in TS)
rowPanel = map[string]interface{}{
"id": nextRowID,
"type": "row",
"title": GetStringValue(row, "title"),
"collapsed": isCollapsed,
"repeat": GetStringValue(row, "repeat"),
"panels": []interface{}{},
"gridPos": map[string]interface{}{
"x": 0,
"y": yPos,
"w": int(gridColumnCount),
"h": rowGridHeight,
},
}
nextRowID++
yPos++
}
rowArea := newRowArea(rowGridHeight, gridColumnCount, yPos)
// Process all panels in this row (lines 1062-1087 in TS)
for _, panelInterface := range panelsInRow {
panel, ok := panelInterface.(map[string]interface{})
if !ok {
continue
}
// Set default span (line 1063 in TS)
span := GetFloatValue(panel, "span", defaultPanelSpan)
// Handle minSpan conversion (lines 1064-1066 in TS)
if minSpan, hasMinSpan := panel["minSpan"]; hasMinSpan {
if minSpanFloat, ok := ConvertToFloat(minSpan); ok && minSpanFloat > 0 {
panel["minSpan"] = int(math.Min(float64(gridColumnCount), (float64(gridColumnCount)/12.0)*minSpanFloat))
}
}
panelWidth := int(math.Floor(span * widthFactor))
panelHeight := rowGridHeight
if panelHeightValue, hasHeight := panel["height"]; hasHeight {
if h, ok := ConvertToFloat(panelHeightValue); ok {
panelHeight = getGridHeight(h)
}
}
panelPos := rowArea.getPanelPosition(panelHeight, panelWidth)
yPos = rowArea.yPos
// Set gridPos (lines 1072-1077 in TS)
panel["gridPos"] = map[string]interface{}{
"x": GetIntValue(panelPos, "x", 0),
"y": yPos + GetIntValue(panelPos, "y", 0),
"w": panelWidth,
"h": panelHeight,
}
rowArea.addPanel(panel["gridPos"].(map[string]interface{}))
// Remove span (line 1080 in TS)
delete(panel, "span")
// Exact logic from lines 1082-1086 in TS
if rowPanel != nil && isCollapsed {
// Add to collapsed row's nested panels
if rowPanelPanels, ok := rowPanel["panels"].([]interface{}); ok {
rowPanel["panels"] = append(rowPanelPanels, panel)
}
} else {
// Add directly to dashboard panels
finalPanels = append(finalPanels, panel)
}
}
// Add row panel after processing all panels (lines 1089-1091 in TS)
if rowPanel != nil {
finalPanels = append(finalPanels, rowPanel)
}
// Update yPos (lines 1093-1095 in TS)
if rowPanel == nil || !isCollapsed {
yPos += rowGridHeight
}
}
// Update the dashboard
dashboard["panels"] = finalPanels
delete(dashboard, "rows")
}
// rowArea represents dashboard row filled by panels
type rowArea struct {
area []int
yPos int
height int
}
func newRowArea(height int, width int, rowYPos int) *rowArea {
area := make([]int, width)
return &rowArea{
area: area,
yPos: rowYPos,
height: height,
}
}
func (r *rowArea) reset() {
for i := range r.area {
r.area[i] = 0
}
}
func (r *rowArea) addPanel(gridPos map[string]interface{}) {
x := GetIntValue(gridPos, "x", 0)
y := GetIntValue(gridPos, "y", 0)
w := GetIntValue(gridPos, "w", 0)
h := GetIntValue(gridPos, "h", 0)
for i := x; i < x+w && i < len(r.area); i++ {
newHeight := y + h - r.yPos
if newHeight > r.area[i] {
r.area[i] = newHeight
}
}
}
func (r *rowArea) getPanelPosition(panelHeight int, panelWidth int) map[string]interface{} {
var startPlace, endPlace int
found := false
// Find available space from right to left
for i := len(r.area) - 1; i >= 0; i-- {
if r.height-r.area[i] > 0 {
if !found {
endPlace = i
found = true
} else {
if i < len(r.area)-1 && r.area[i] <= r.area[i+1] {
startPlace = i
} else {
break
}
}
} else {
break
}
}
if found && endPlace-startPlace >= panelWidth-1 {
// Find max height in the range
yPos := 0
for i := startPlace; i <= endPlace && i < len(r.area); i++ {
if r.area[i] > yPos {
yPos = r.area[i]
}
}
return map[string]interface{}{
"x": startPlace,
"y": yPos,
}
}
// Wrap to next row
r.yPos += r.height
r.reset()
return r.getPanelPosition(panelHeight, panelWidth)
}
func getMaxPanelID(rows []interface{}) int {
maxID := 0
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
if panels, ok := row["panels"].([]interface{}); ok {
for _, panelInterface := range panels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
if id := GetIntValue(panel, "id", 0); id > maxID {
maxID = id
}
}
}
}
}
}
return maxID
}
func shouldShowRows(rows []interface{}) bool {
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
if GetBoolValue(row, "collapse") || GetBoolValue(row, "showTitle") || GetStringValue(row, "repeat") != "" {
return true
}
}
}
return false
}
func getRowHeight(row map[string]interface{}) float64 {
if height, ok := row["height"]; ok {
if h, ok := ConvertToFloat(height); ok {
return h
}
}
return defaultRowHeight
}
func getGridHeight(height float64) int {
if height < minPanelHeight {
height = minPanelHeight
}
return int(math.Ceil(height / panelHeightStep))
}
File diff suppressed because it is too large Load Diff
@@ -63,16 +63,9 @@ func migrateMinSpanToMaxPerRow(panel map[string]interface{}) {
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:
// Convert minSpan to a number using shared utility
minSpan, ok := ConvertToFloat(minSpanValue)
if !ok {
// If we can't convert minSpan to a number, just delete it and return
delete(panel, "minSpan")
return
@@ -84,8 +84,8 @@ func upgradePanelLink(link map[string]interface{}) map[string]interface{} {
result := map[string]interface{}{
"url": url,
"title": getStringValue(link, "title"),
"targetBlank": getBoolValue(link, "targetBlank"),
"title": GetStringValue(link, "title"),
"targetBlank": GetBoolValue(link, "targetBlank"),
}
return result
@@ -111,11 +111,11 @@ func buildPanelLinkURL(link map[string]interface{}) string {
// Add query parameters
params := []string{}
if getBoolValue(link, "keepTime") {
if GetBoolValue(link, "keepTime") {
params = append(params, "$__url_time_range")
}
if getBoolValue(link, "includeVars") {
if GetBoolValue(link, "includeVars") {
params = append(params, "$__all_variables")
}
@@ -150,17 +150,3 @@ func slugifyForURL(name string) string {
name = reSpaces.ReplaceAllString(name, "-")
return name
}
func getStringValue(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func getBoolValue(m map[string]interface{}, key string) bool {
if v, ok := m[key].(bool); ok {
return v
}
return false
}
@@ -117,7 +117,7 @@ func removeRepeatedPanels(panels []interface{}) []interface{} {
// Filter out repeats in collapsed rows
if p["type"] == "row" {
if nestedPanels, ok := p["panels"].([]interface{}); ok {
var filteredNestedPanels []interface{}
filteredNestedPanels := []interface{}{}
for _, nestedPanel := range nestedPanels {
if np, ok := nestedPanel.(map[string]interface{}); ok {
if _, hasRepeatPanelId := np["repeatPanelId"]; !hasRepeatPanelId {