825f8fc7ff
Fix span: 0 bug and panel ordering in v16 dashboard migration Fix span: 0 handling to match frontend behavior by defaulting to DEFAULT_PANEL_SPAN. Fix panel ordering issue by using stable sort instead of unstable sort. Fix collapsed property handling to only set when input row has collapse property. Add comprehensive test cases for span: 0 bug and collapsed property behavior. Add sanitized test input file for span: 0 demo dashboard with generic values instead of internal Grafana infrastructure references. All backend migration tests and frontend comparison tests pass.
341 lines
8.5 KiB
Go
341 lines
8.5 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
|
|
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 {
|
|
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))
|
|
}
|
|
}
|
|
|
|
panelWidth := int(math.Floor(span * widthFactor))
|
|
panelHeight := defaultHeight
|
|
|
|
if panelHeightValue, hasHeight := panel["height"]; hasHeight {
|
|
if h, ok := ConvertToFloat(panelHeightValue); ok {
|
|
panelHeight = getGridHeight(h)
|
|
}
|
|
}
|
|
|
|
return panelWidth, panelHeight
|
|
}
|