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 {
@@ -0,0 +1,119 @@
{
"title": "V16 Grid Layout Migration Test Dashboard",
"schemaVersion": 15,
"rows": [
{
"collapse": false,
"height": 250,
"panels": [
{
"id": 1,
"type": "graph",
"title": "CPU Usage",
"span": 6
},
{
"id": 2,
"type": "singlestat",
"title": "Memory Usage",
"span": 6
}
]
},
{
"collapse": true,
"height": 300,
"title": "Collapsed Row",
"panels": [
{
"id": 3,
"type": "table",
"title": "Process List",
"span": 12
},
{
"id": 4,
"type": "graph",
"title": "Network I/O",
"span": 6,
"height": 200
},
{
"id": 5,
"type": "graph",
"title": "Disk I/O",
"span": 6,
"height": 200
}
]
},
{
"showTitle": true,
"title": "Visible Row Title",
"height": 200,
"panels": [
{
"id": 6,
"type": "gauge",
"title": "Temperature",
"span": 4,
"minSpan": 2
},
{
"id": 7,
"type": "stat",
"title": "Uptime",
"span": 4
},
{
"id": 8,
"type": "bargauge",
"title": "Load Average",
"span": 4
}
]
},
{
"height": 150,
"panels": [
{
"id": 9,
"type": "text",
"title": "Description Panel",
"span": 8
},
{
"id": 10,
"type": "logs",
"title": "System Logs",
"span": 4,
"height": 100
}
]
},
{
"repeat": "server",
"height": 250,
"panels": [
{
"id": 11,
"type": "graph",
"title": "Server Metrics",
"span": 12
}
]
},
{
"repeatIteration": true,
"height": 250,
"panels": [
{
"id": 12,
"type": "graph",
"title": "Should be skipped",
"span": 12
}
]
}
]
}
@@ -0,0 +1,487 @@
{
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 1
},
"id": 1,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "CPU Usage",
"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": 7,
"w": 12,
"x": 12,
"y": 1
},
"id": 2,
"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": "Memory Usage",
"type": "stat"
},
{
"collapsed": false,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 0
},
"id": 13,
"panels": [],
"repeat": "",
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "",
"type": "row"
},
{
"collapsed": true,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 8
},
"id": 14,
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 9
},
"id": 3,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Process List",
"type": "table"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 12,
"x": 0,
"y": 17
},
"height": 200,
"id": 4,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Network I/O",
"type": "graph"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 12,
"x": 12,
"y": 17
},
"height": 200,
"id": 5,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Disk I/O",
"type": "graph"
}
],
"repeat": "",
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Collapsed Row",
"type": "row"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 0,
"y": 18
},
"id": 6,
"maxPerRow": 6,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Temperature",
"type": "gauge"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 8,
"y": 18
},
"id": 7,
"options": {
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Uptime",
"type": "stat"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 16,
"y": 18
},
"id": 8,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Load Average",
"type": "bargauge"
},
{
"collapsed": false,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 6,
"w": 24,
"x": 0,
"y": 17
},
"id": 15,
"panels": [],
"repeat": "",
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Visible Row Title",
"type": "row"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 4,
"w": 16,
"x": 0,
"y": 25
},
"id": 9,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Description Panel",
"type": "text"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 3,
"w": 8,
"x": 16,
"y": 25
},
"height": 100,
"id": 10,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "System Logs",
"type": "logs"
},
{
"collapsed": false,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 24
},
"id": 16,
"panels": [],
"repeat": "",
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "",
"type": "row"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 30
},
"id": 11,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Server Metrics",
"type": "graph"
},
{
"collapsed": false,
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 29
},
"id": 17,
"panels": [],
"repeat": "server",
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "",
"type": "row"
}
],
"refresh": "",
"schemaVersion": 41,
"title": "V16 Grid Layout Migration Test Dashboard"
}