8449a2e4fb
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.
354 lines
8.8 KiB
Go
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
|
|
}
|