Files
grafana/apps/dashboard/pkg/migration/schemaversion/v16.go
T
Dominik Prokop 8449a2e4fb Fix v16 grid position calculation for fractional spans (#112428)
Fix v16 grid position calculation for fractional spans

Match frontend span-to-width conversion logic by flooring span first,
then multiplying by width factor, instead of multiplying first then flooring.

This fixes dashboard layout inconsistencies where panels with fractional
spans (e.g. 5.929860088365242) would have different widths between
backend and frontend migration paths.

- Backend old: Math.floor(5.93 * 2) = Math.floor(11.86) = 11
- Backend new: Math.floor(5.93) * 2 = 5 * 2 = 10 (matches frontend)

Includes comprehensive unit test to prevent regression.
2025-10-16 17:32:42 +02:00

354 lines
8.8 KiB
Go

package schemaversion
import (
"context"
"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(_ context.Context, 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 {
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
// Match frontend: dashboard.panels already exists with top-level panels
// The frontend's this.dashboard.panels is initialized in the constructor with existing panels
// Then upgradeToGridLayout adds more panels to it
// Initialize panels array - make a copy to avoid modifying the original
panels := []interface{}{}
if existingPanels, ok := dashboard["panels"].([]interface{}); ok && len(existingPanels) > 0 {
// Copy existing panels to preserve order
panels = append(panels, 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 repeatIteration, hasRepeatIteration := row["repeatIteration"]; hasRepeatIteration && repeatIteration != nil {
continue
}
height := getRowHeight(row)
rowGridHeight := getGridHeight(height)
// Check if collapse property exists and get its value
collapseValue, hasCollapseProperty := row["collapse"]
isCollapsed := false
if hasCollapseProperty {
if b, ok := collapseValue.(bool); ok {
isCollapsed = b
}
}
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"),
"repeat": GetStringValue(row, "repeat"),
"panels": []interface{}{},
"gridPos": map[string]interface{}{
"x": 0,
"y": yPos,
"w": int(gridColumnCount),
"h": rowGridHeight,
},
}
// Match frontend behavior: rowPanel.collapsed = row.collapse (line 1065 in TS)
// Only set collapsed property if the original row had a collapse property
if hasCollapseProperty {
rowPanel["collapsed"] = isCollapsed
}
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
}
// Match frontend logic: panel.span = panel.span || DEFAULT_PANEL_SPAN (line 1082 in TS)
span := GetFloatValue(panel, "span", 0)
if span == 0 {
span = defaultPanelSpan
}
panelWidth, panelHeight := calculatePanelDimensionsFromSpan(span, panel, widthFactor, rowGridHeight)
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")
// Match frontend logic: lines 1101-1105 in TS
if rowPanel != nil && isCollapsed {
// Add to collapsed row's nested panels (line 1102)
if rowPanelPanels, ok := rowPanel["panels"].([]interface{}); ok {
rowPanel["panels"] = append(rowPanelPanels, panel)
}
} else {
// Add directly to panels array like frontend (line 1104)
panels = append(panels, panel)
}
}
// Add row panel after regular panels from this row (lines 1108-1110 in TS)
if rowPanel != nil {
panels = append(panels, rowPanel)
}
// Update yPos (lines 1093-1095 in TS)
if rowPanel == nil || !isCollapsed {
yPos += rowGridHeight
}
}
// Update the dashboard
dashboard["panels"] = panels
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
hasValidID := false
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 > 0 {
hasValidID = true
if id > maxID {
maxID = id
}
}
}
}
}
}
}
// If no valid IDs found, return 0 (matches frontend behavior)
if !hasValidID {
return 0
}
return maxID
}
func shouldShowRows(rows []interface{}) bool {
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
collapse := GetBoolValue(row, "collapse")
showTitle := GetBoolValue(row, "showTitle")
repeat := GetStringValue(row, "repeat")
if collapse || showTitle || 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))
}
func calculatePanelDimensionsFromSpan(span float64, panel map[string]interface{}, widthFactor float64, defaultHeight int) (int, int) {
// span should already be normalized by caller (line 1082 in DashboardMigrator.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))
}
}
// Match frontend logic: Math.floor(panel.span) * widthFactor (line 914 in DashboardMigrator.ts)
// Frontend floors the span FIRST, then multiplies by widthFactor
panelWidth := int(math.Floor(span)) * int(widthFactor)
panelHeight := defaultHeight
if panelHeightValue, hasHeight := panel["height"]; hasHeight {
if h, ok := ConvertToFloat(panelHeightValue); ok {
panelHeight = getGridHeight(h)
}
}
return panelWidth, panelHeight
}