Files
grafana/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go
Haris Rozajac ea2a0936df Dashboard Conversion: Preserve repeat property when converting tabs to rows (#116180)
* preserve repeat property

* fix test

* preserve repeat when converting panels in tabs or rows with autogrid layout

* fix v1 serialization of autogrid
2026-01-14 07:29:51 -07:00

2320 lines
72 KiB
Go

package conversion
import (
"fmt"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"k8s.io/apimachinery/pkg/conversion"
)
// ConvertDashboard_V2alpha1_to_V1beta1 converts a v2alpha1 dashboard to v1beta1 format.
// The v1beta1 format uses an unstructured JSON structure, so we build a map[string]interface{}
// that represents the v1 dashboard JSON format.
// The dsIndexProvider is used to resolve default datasources when queries/variables/annotations
// don't have explicit datasource references.
func ConvertDashboard_V2alpha1_to_V1beta1(in *dashv2alpha1.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
out.APIVersion = dashv1.APIVERSION
out.Kind = in.Kind // Preserve the Kind from input (should be "Dashboard")
// Convert the spec to v1beta1 unstructured format
dashboardJSON, err := convertDashboardSpec_V2alpha1_to_V1beta1(&in.Spec)
if err != nil {
return fmt.Errorf("failed to convert dashboard spec: %w", err)
}
// Set the dashboard JSON directly at the Spec.Object level
out.Spec.Object = dashboardJSON
return nil
}
func convertDashboardSpec_V2alpha1_to_V1beta1(in *dashv2alpha1.DashboardSpec) (map[string]interface{}, error) {
dashboard := make(map[string]interface{})
// Convert basic fields
dashboard["title"] = in.Title
if in.Description != nil {
dashboard["description"] = *in.Description
}
if len(in.Tags) > 0 {
dashboard["tags"] = in.Tags
}
dashboard["graphTooltip"] = transformCursorSyncFromEnum(in.CursorSync)
dashboard["schemaVersion"] = schemaversion.LATEST_VERSION
dashboard["preload"] = in.Preload
// Default editable to true if not explicitly set, matching frontend DashboardModel behavior
if in.Editable != nil {
dashboard["editable"] = *in.Editable
} else {
dashboard["editable"] = true
}
if in.LiveNow != nil {
dashboard["liveNow"] = *in.LiveNow
}
if in.Revision != nil {
dashboard["revision"] = *in.Revision
}
// Convert time settings
convertTimeSettingsToV1(&in.TimeSettings, dashboard)
// Convert links
if len(in.Links) > 0 {
dashboard["links"] = convertLinksToV1(in.Links)
}
// Convert panels from elements and layout
panels, err := convertPanelsFromElementsAndLayout(in.Elements, in.Layout)
if err != nil {
return nil, fmt.Errorf("failed to convert panels: %w", err)
}
if len(panels) > 0 {
dashboard["panels"] = panels
}
// Convert variables
variables := convertVariablesToV1(in.Variables)
if len(variables) > 0 {
dashboard["templating"] = map[string]interface{}{
"list": variables,
}
}
// Convert annotations - always include even if empty to prevent DashboardModel from adding built-in
annotations := convertAnnotationsToV1(in.Annotations)
dashboard["annotations"] = map[string]interface{}{
"list": annotations,
}
return dashboard, nil
}
// transformCursorSyncFromEnum converts v2alpha1 CursorSync enum to v1 graphTooltip integer
func transformCursorSyncFromEnum(cursorSync dashv2alpha1.DashboardDashboardCursorSync) int {
switch cursorSync {
case dashv2alpha1.DashboardDashboardCursorSyncOff:
return 0
case dashv2alpha1.DashboardDashboardCursorSyncCrosshair:
return 1
case dashv2alpha1.DashboardDashboardCursorSyncTooltip:
return 2
default:
return 0
}
}
func convertTimeSettingsToV1(timeSettings *dashv2alpha1.DashboardTimeSettingsSpec, dashboard map[string]interface{}) {
// Convert time range - use defaults when empty to match DashboardModel behavior
from := timeSettings.From
to := timeSettings.To
if from == "" {
from = "now-6h"
}
if to == "" {
to = "now"
}
dashboard["time"] = map[string]interface{}{
"from": from,
"to": to,
}
if timeSettings.Timezone != nil {
dashboard["timezone"] = *timeSettings.Timezone
}
dashboard["refresh"] = timeSettings.AutoRefresh
dashboard["fiscalYearStartMonth"] = timeSettings.FiscalYearStartMonth
if timeSettings.WeekStart != nil {
dashboard["weekStart"] = string(*timeSettings.WeekStart)
}
timepicker := make(map[string]interface{})
hasTimepicker := false
if len(timeSettings.AutoRefreshIntervals) > 0 {
timepicker["refresh_intervals"] = timeSettings.AutoRefreshIntervals
hasTimepicker = true
}
if timeSettings.HideTimepicker {
timepicker["hidden"] = timeSettings.HideTimepicker
hasTimepicker = true
}
if timeSettings.NowDelay != nil {
timepicker["nowDelay"] = *timeSettings.NowDelay
hasTimepicker = true
}
if len(timeSettings.QuickRanges) > 0 {
quickRanges := make([]map[string]interface{}, 0, len(timeSettings.QuickRanges))
for _, qr := range timeSettings.QuickRanges {
quickRange := map[string]interface{}{
"display": qr.Display,
"from": qr.From,
"to": qr.To,
}
quickRanges = append(quickRanges, quickRange)
}
timepicker["quick_ranges"] = quickRanges
hasTimepicker = true
}
if hasTimepicker {
dashboard["timepicker"] = timepicker
}
}
func convertLinksToV1(links []dashv2alpha1.DashboardDashboardLink) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(links))
for _, link := range links {
linkMap := map[string]interface{}{
"title": link.Title,
"type": string(link.Type),
"icon": link.Icon,
"tooltip": link.Tooltip,
"tags": link.Tags,
"asDropdown": link.AsDropdown,
"targetBlank": link.TargetBlank,
"includeVars": link.IncludeVars,
"keepTime": link.KeepTime,
}
if link.Url != nil {
linkMap["url"] = *link.Url
}
if link.Placement != nil {
linkMap["placement"] = *link.Placement
}
result = append(result, linkMap)
}
return result
}
// convertPanelsFromElementsAndLayout converts V2 layout structures to V1 panel arrays.
// V1 only supports a flat array of panels with row panels for grouping.
// This function dispatches to the appropriate converter based on layout type:
// - GridLayout: Direct 1:1 mapping to V1 panels with gridPos
// - RowsLayout: Rows become row panels; nested structures are flattened
// - AutoGridLayout: Calculates gridPos based on column count and row height
// - TabsLayout: Tabs become expanded row panels; content is flattened
func convertPanelsFromElementsAndLayout(elements map[string]dashv2alpha1.DashboardElement, layout dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind) ([]interface{}, error) {
if layout.GridLayoutKind != nil {
return convertGridLayoutToPanels(elements, layout.GridLayoutKind)
}
if layout.RowsLayoutKind != nil {
return convertRowsLayoutToPanels(elements, layout.RowsLayoutKind)
}
if layout.AutoGridLayoutKind != nil {
return convertAutoGridLayoutToPanels(elements, layout.AutoGridLayoutKind)
}
if layout.TabsLayoutKind != nil {
return convertTabsLayoutToPanels(elements, layout.TabsLayoutKind)
}
// No layout specified, return empty panels
return []interface{}{}, nil
}
func convertGridLayoutToPanels(elements map[string]dashv2alpha1.DashboardElement, gridLayout *dashv2alpha1.DashboardGridLayoutKind) ([]interface{}, error) {
panels := make([]interface{}, 0, len(gridLayout.Spec.Items))
for _, item := range gridLayout.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
panel, err := convertPanelFromElement(&element, &item)
if err != nil {
return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
}
return panels, nil
}
// convertRowsLayoutToPanels converts a RowsLayout to V1 panels.
// All nested structures (rows within rows, tabs within rows) are flattened to the root level.
// Each row becomes a row panel, and nested content is added sequentially after it.
func convertRowsLayoutToPanels(elements map[string]dashv2alpha1.DashboardElement, rowsLayout *dashv2alpha1.DashboardRowsLayoutKind) ([]interface{}, error) {
return convertNestedLayoutToPanels(elements, rowsLayout, nil, 0)
}
// convertNestedLayoutToPanels handles arbitrary nesting of RowsLayout and TabsLayout.
// It processes each row/tab in order, tracking Y position to ensure panels don't overlap.
// The function recursively flattens nested structures to produce a flat V1 panel array.
func convertNestedLayoutToPanels(elements map[string]dashv2alpha1.DashboardElement, rowsLayout *dashv2alpha1.DashboardRowsLayoutKind, tabsLayout *dashv2alpha1.DashboardTabsLayoutKind, yOffset int64) ([]interface{}, error) {
panels := make([]interface{}, 0)
currentY := yOffset
// Process RowsLayout
if rowsLayout != nil {
for _, row := range rowsLayout.Spec.Rows {
rowPanels, newY, err := processRowItem(elements, &row, currentY)
if err != nil {
return nil, err
}
panels = append(panels, rowPanels...)
currentY = newY
}
}
// Process TabsLayout (tabs are converted to rows)
if tabsLayout != nil {
for _, tab := range tabsLayout.Spec.Tabs {
tabPanels, newY, err := processTabItem(elements, &tab, currentY)
if err != nil {
return nil, err
}
panels = append(panels, tabPanels...)
currentY = newY
}
}
return panels, nil
}
// processRowItem converts a single V2 row to V1 panels.
// Behavior depends on row configuration:
// - Hidden header (hideHeader=true): No row panel created; panels added directly
// - Explicit row (hideHeader=false): Row panel created at currentY; content follows
// - Collapsed row: Panels stored inside row.panels with absolute Y positions
// - Expanded row: Panels added to top level after the row panel
// - Nested layouts: Parent row is preserved; nested content is flattened after it
func processRowItem(elements map[string]dashv2alpha1.DashboardElement, row *dashv2alpha1.DashboardRowsLayoutRowKind, startY int64) ([]interface{}, int64, error) {
panels := make([]interface{}, 0)
currentY := startY
isHiddenHeader := row.Spec.HideHeader != nil && *row.Spec.HideHeader
// Handle nested RowsLayout - keep parent row, then flatten nested rows
if row.Spec.Layout.RowsLayoutKind != nil {
// Create parent row panel first (if not hidden header)
if !isHiddenHeader {
rowPanel := map[string]interface{}{
"type": "row",
"id": -1,
"gridPos": map[string]interface{}{
"x": 0,
"y": currentY,
"w": 24,
"h": 1,
},
}
if row.Spec.Title != nil {
rowPanel["title"] = *row.Spec.Title
}
rowPanel["collapsed"] = false
rowPanel["panels"] = []interface{}{}
panels = append(panels, rowPanel)
currentY++
}
// Then process nested rows
nestedPanels, err := convertNestedLayoutToPanels(elements, row.Spec.Layout.RowsLayoutKind, nil, currentY)
if err != nil {
return nil, 0, err
}
panels = append(panels, nestedPanels...)
currentY = getMaxYFromPanels(nestedPanels, currentY)
return panels, currentY, nil
}
// Handle nested TabsLayout - keep parent row, then flatten tabs
if row.Spec.Layout.TabsLayoutKind != nil {
// Create parent row panel first (if not hidden header)
if !isHiddenHeader {
rowPanel := map[string]interface{}{
"type": "row",
"id": -1,
"gridPos": map[string]interface{}{
"x": 0,
"y": currentY,
"w": 24,
"h": 1,
},
}
if row.Spec.Title != nil {
rowPanel["title"] = *row.Spec.Title
}
rowPanel["collapsed"] = false
rowPanel["panels"] = []interface{}{}
panels = append(panels, rowPanel)
currentY++
}
// Then process nested tabs
nestedPanels, err := convertNestedLayoutToPanels(elements, nil, row.Spec.Layout.TabsLayoutKind, currentY)
if err != nil {
return nil, 0, err
}
panels = append(panels, nestedPanels...)
currentY = getMaxYFromPanels(nestedPanels, currentY)
return panels, currentY, nil
}
// Create row panel for explicit rows (not hidden header)
isCollapsed := row.Spec.Collapse != nil && *row.Spec.Collapse
if !isHiddenHeader {
rowPanel := map[string]interface{}{
"type": "row",
"id": -1,
}
if row.Spec.Title != nil {
rowPanel["title"] = *row.Spec.Title
}
if row.Spec.Collapse != nil {
rowPanel["collapsed"] = *row.Spec.Collapse
}
if row.Spec.Repeat != nil && row.Spec.Repeat.Value != "" {
rowPanel["repeat"] = row.Spec.Repeat.Value
}
// Set row gridPos - always set for collapsed rows, otherwise only if has content
hasContent := (row.Spec.Layout.GridLayoutKind != nil && len(row.Spec.Layout.GridLayoutKind.Spec.Items) > 0) ||
row.Spec.Layout.AutoGridLayoutKind != nil
if hasContent || isCollapsed {
rowPanel["gridPos"] = map[string]interface{}{
"x": 0,
"y": currentY,
"w": 24,
"h": 1,
}
}
// Add collapsed panels if row is collapsed (panels use absolute Y positions)
if isCollapsed {
collapsedPanels, err := extractCollapsedPanelsWithAbsoluteY(elements, &row.Spec.Layout, currentY+1)
if err != nil {
return nil, 0, err
}
if len(collapsedPanels) > 0 {
rowPanel["panels"] = collapsedPanels
}
}
panels = append(panels, rowPanel)
currentY++ // Row panel takes 1 grid unit
}
// Add panels from row layout (only for expanded rows or hidden header rows)
if !isCollapsed || isHiddenHeader {
rowPanels, newY, err := extractExpandedPanels(elements, &row.Spec.Layout, currentY, isHiddenHeader, startY)
if err != nil {
return nil, 0, err
}
panels = append(panels, rowPanels...)
currentY = newY
}
return panels, currentY, nil
}
// processTabItem converts a V2 tab to V1 panels.
// Each tab becomes an expanded row panel (collapsed=false) with an empty panels array.
// The tab's content is flattened and added to the top level after the row panel.
// Nested layouts within the tab are recursively processed.
func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dashv2alpha1.DashboardTabsLayoutTabKind, startY int64) ([]interface{}, int64, error) {
panels := make([]interface{}, 0)
currentY := startY
// Create a row panel for this tab (tabs become expanded rows)
rowPanel := map[string]interface{}{
"type": "row",
"id": -1,
"collapsed": false,
"panels": []interface{}{},
}
if tab.Spec.Title != nil {
rowPanel["title"] = *tab.Spec.Title
}
if tab.Spec.Repeat != nil && tab.Spec.Repeat.Value != "" {
// We only use value here as V1 doesn't support mode
rowPanel["repeat"] = tab.Spec.Repeat.Value
}
rowPanel["gridPos"] = map[string]interface{}{
"x": 0,
"y": currentY,
"w": 24,
"h": 1,
}
panels = append(panels, rowPanel)
currentY++
// Handle nested layouts inside the tab
if tab.Spec.Layout.RowsLayoutKind != nil {
// Nested RowsLayout inside tab
nestedPanels, err := convertNestedLayoutToPanels(elements, tab.Spec.Layout.RowsLayoutKind, nil, currentY)
if err != nil {
return nil, 0, err
}
panels = append(panels, nestedPanels...)
currentY = getMaxYFromPanels(nestedPanels, currentY)
} else if tab.Spec.Layout.TabsLayoutKind != nil {
// Nested TabsLayout inside tab
nestedPanels, err := convertNestedLayoutToPanels(elements, nil, tab.Spec.Layout.TabsLayoutKind, currentY)
if err != nil {
return nil, 0, err
}
panels = append(panels, nestedPanels...)
currentY = getMaxYFromPanels(nestedPanels, currentY)
} else if tab.Spec.Layout.GridLayoutKind != nil {
// GridLayout inside tab
baseY := currentY
maxY := currentY
for _, item := range tab.Spec.Layout.GridLayoutKind.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, 0, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
adjustedItem := item
adjustedItem.Spec.Y = item.Spec.Y + baseY
panel, err := convertPanelFromElement(&element, &adjustedItem)
if err != nil {
return nil, 0, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
panelEndY := adjustedItem.Spec.Y + item.Spec.Height
if panelEndY > maxY {
maxY = panelEndY
}
}
currentY = maxY
} else if tab.Spec.Layout.AutoGridLayoutKind != nil {
// AutoGridLayout inside tab - convert with Y offset
autoGridPanels, err := convertAutoGridLayoutToPanelsWithOffset(elements, tab.Spec.Layout.AutoGridLayoutKind, currentY)
if err != nil {
return nil, 0, err
}
panels = append(panels, autoGridPanels...)
currentY = getMaxYFromPanels(autoGridPanels, currentY)
}
return panels, currentY, nil
}
// extractCollapsedPanelsWithAbsoluteY extracts panels for a collapsed row.
// Panels are positioned with absolute Y coordinates (baseY + relative Y).
// This matches V1 behavior where collapsed row panels store their children
// with Y positions as if the row were expanded at that location.
func extractCollapsedPanelsWithAbsoluteY(elements map[string]dashv2alpha1.DashboardElement, layout *dashv2alpha1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind, baseY int64) ([]interface{}, error) {
panels := make([]interface{}, 0)
if layout.GridLayoutKind != nil {
for _, item := range layout.GridLayoutKind.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
// Create a copy with adjusted Y position
adjustedItem := item
adjustedItem.Spec.Y = item.Spec.Y + baseY
panel, err := convertPanelFromElement(&element, &adjustedItem)
if err != nil {
return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
}
}
// Handle AutoGridLayout for collapsed rows with Y offset
if layout.AutoGridLayoutKind != nil {
autoGridPanels, err := convertAutoGridLayoutToPanelsWithOffset(elements, layout.AutoGridLayoutKind, baseY)
if err != nil {
return nil, err
}
panels = append(panels, autoGridPanels...)
}
// For nested rows/tabs in collapsed state, recursively extract all panels
if layout.RowsLayoutKind != nil {
currentY := baseY
for _, row := range layout.RowsLayoutKind.Spec.Rows {
nestedPanels, err := extractCollapsedPanelsWithAbsoluteY(elements, &row.Spec.Layout, currentY)
if err != nil {
return nil, err
}
panels = append(panels, nestedPanels...)
currentY += getLayoutHeight(&row.Spec.Layout)
}
}
if layout.TabsLayoutKind != nil {
currentY := baseY
for _, tab := range layout.TabsLayoutKind.Spec.Tabs {
nestedPanels, err := extractCollapsedPanelsFromTabLayoutWithAbsoluteY(elements, &tab.Spec.Layout, currentY)
if err != nil {
return nil, err
}
panels = append(panels, nestedPanels...)
currentY += getLayoutHeightFromTab(&tab.Spec.Layout)
}
}
return panels, nil
}
// extractCollapsedPanelsFromTabLayoutWithAbsoluteY extracts panels from a tab layout with absolute Y.
// Similar to extractCollapsedPanelsWithAbsoluteY but handles the tab-specific layout type.
func extractCollapsedPanelsFromTabLayoutWithAbsoluteY(elements map[string]dashv2alpha1.DashboardElement, layout *dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, baseY int64) ([]interface{}, error) {
panels := make([]interface{}, 0)
if layout.GridLayoutKind != nil {
for _, item := range layout.GridLayoutKind.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
adjustedItem := item
adjustedItem.Spec.Y = item.Spec.Y + baseY
panel, err := convertPanelFromElement(&element, &adjustedItem)
if err != nil {
return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
}
}
if layout.AutoGridLayoutKind != nil {
autoGridPanels, err := convertAutoGridLayoutToPanelsWithOffset(elements, layout.AutoGridLayoutKind, baseY)
if err != nil {
return nil, err
}
panels = append(panels, autoGridPanels...)
}
if layout.RowsLayoutKind != nil {
currentY := baseY
for _, row := range layout.RowsLayoutKind.Spec.Rows {
nestedPanels, err := extractCollapsedPanelsWithAbsoluteY(elements, &row.Spec.Layout, currentY)
if err != nil {
return nil, err
}
panels = append(panels, nestedPanels...)
currentY += getLayoutHeight(&row.Spec.Layout)
}
}
if layout.TabsLayoutKind != nil {
currentY := baseY
for _, tab := range layout.TabsLayoutKind.Spec.Tabs {
nestedPanels, err := extractCollapsedPanelsFromTabLayoutWithAbsoluteY(elements, &tab.Spec.Layout, currentY)
if err != nil {
return nil, err
}
panels = append(panels, nestedPanels...)
currentY += getLayoutHeightFromTab(&tab.Spec.Layout)
}
}
return panels, nil
}
// getLayoutHeightFromTab calculates the height of a tab's content.
// Similar to getLayoutHeight but handles the tab-specific layout type.
func getLayoutHeightFromTab(layout *dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind) int64 {
var maxY int64 = 0
if layout.GridLayoutKind != nil {
for _, item := range layout.GridLayoutKind.Spec.Items {
if item.Spec.Y+item.Spec.Height > maxY {
maxY = item.Spec.Y + item.Spec.Height
}
}
}
if layout.AutoGridLayoutKind != nil {
// Calculate based on item count and default row height
itemCount := len(layout.AutoGridLayoutKind.Spec.Items)
maxCols := 3
if layout.AutoGridLayoutKind.Spec.MaxColumnCount != nil {
maxCols = int(*layout.AutoGridLayoutKind.Spec.MaxColumnCount)
}
rowCount := (itemCount + maxCols - 1) / maxCols
maxY = int64(rowCount * 9) // default standard height
}
return maxY
}
// extractExpandedPanels extracts panels for an expanded row, adding them to the top level.
// Y position handling:
// - Hidden header: Keep original relative Y (no adjustment)
// - Explicit row: Add (currentY - 1) to relative Y for absolute positioning
//
// Returns the panels and the new Y position for the next row.
func extractExpandedPanels(elements map[string]dashv2alpha1.DashboardElement, layout *dashv2alpha1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind, currentY int64, isHiddenHeader bool, startY int64) ([]interface{}, int64, error) {
panels := make([]interface{}, 0)
// For hidden headers, don't track Y changes (matches original behavior)
maxY := startY
if layout.GridLayoutKind != nil {
var localMaxY int64 = 0
for _, item := range layout.GridLayoutKind.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, 0, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
adjustedItem := item
if !isHiddenHeader {
// For explicit rows: Y = item.Spec.Y + currentY - 1
// currentY has been incremented after row panel, so currentY - 1 gives offset
adjustedItem.Spec.Y = item.Spec.Y + currentY - 1
}
// For hidden headers: don't adjust Y, keep item.Spec.Y as-is
panel, err := convertPanelFromElement(&element, &adjustedItem)
if err != nil {
return nil, 0, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
// Track max extent within this row's panels using ABSOLUTE Y positions
panelEndY := adjustedItem.Spec.Y + item.Spec.Height
if panelEndY > localMaxY {
localMaxY = panelEndY
}
}
// Return maxY for next row position (applies to both explicit rows and hidden headers)
maxY = localMaxY
}
// Handle AutoGridLayout
if layout.AutoGridLayoutKind != nil {
// Calculate Y offset for panels
yOffset := startY
if !isHiddenHeader {
yOffset = currentY - 1
}
autoGridPanels, err := convertAutoGridLayoutToPanelsWithOffset(elements, layout.AutoGridLayoutKind, yOffset)
if err != nil {
return nil, 0, err
}
panels = append(panels, autoGridPanels...)
// Update maxY based on panels added (applies to both explicit rows and hidden headers)
maxY = getMaxYFromPanels(autoGridPanels, yOffset)
}
return panels, maxY, nil
}
// getMaxYFromPanels finds the maximum Y extent (y + h) from a list of panels.
// Used to determine where the next row should start to avoid overlap.
func getMaxYFromPanels(panels []interface{}, currentY int64) int64 {
maxY := currentY
for _, p := range panels {
if pm, ok := p.(map[string]interface{}); ok {
if gridPos, ok := pm["gridPos"].(map[string]interface{}); ok {
var y, h int64
// Handle both int and int64 types for Y
switch yVal := gridPos["y"].(type) {
case int64:
y = yVal
case int:
y = int64(yVal)
}
// Handle both int and int64 types for H
switch hVal := gridPos["h"].(type) {
case int64:
h = hVal
case int:
h = int64(hVal)
}
if y+h > maxY {
maxY = y + h
}
}
}
}
return maxY
}
// getLayoutHeight calculates the total height of a layout's content.
// Used for Y position tracking when processing collapsed rows with nested content.
func getLayoutHeight(layout *dashv2alpha1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind) int64 {
var maxY int64 = 0
if layout.GridLayoutKind != nil {
for _, item := range layout.GridLayoutKind.Spec.Items {
if item.Spec.Y+item.Spec.Height > maxY {
maxY = item.Spec.Y + item.Spec.Height
}
}
}
return maxY
}
// convertAutoGridLayoutToPanelsWithOffset converts AutoGridLayout with a Y offset.
// Same as convertAutoGridLayoutToPanels but starts at yOffset instead of 0.
// Used when AutoGridLayout appears inside rows or tabs.
func convertAutoGridLayoutToPanelsWithOffset(elements map[string]dashv2alpha1.DashboardElement, autoGridLayout *dashv2alpha1.DashboardAutoGridLayoutKind, yOffset int64) ([]interface{}, error) {
panels := make([]interface{}, 0, len(autoGridLayout.Spec.Items))
const (
gridTotalColumns int64 = 24
gridCellHeightPx float64 = 30
gridCellVerticalMargin float64 = 8
defaultMaxColumnCount float64 = 3
)
pixelsToGridUnits := func(pixels float64) int64 {
gridUnitSize := gridCellHeightPx + gridCellVerticalMargin
return int64((pixels + gridUnitSize - 1) / gridUnitSize)
}
maxColumnCount := defaultMaxColumnCount
if autoGridLayout.Spec.MaxColumnCount != nil && *autoGridLayout.Spec.MaxColumnCount > 0 {
maxColumnCount = *autoGridLayout.Spec.MaxColumnCount
}
panelWidth := int64(float64(gridTotalColumns) / maxColumnCount)
var panelHeight int64
switch autoGridLayout.Spec.RowHeightMode {
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeShort:
panelHeight = 5
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeStandard:
panelHeight = 9
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeTall:
panelHeight = 14
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeCustom:
if autoGridLayout.Spec.RowHeight != nil && *autoGridLayout.Spec.RowHeight > 0 {
panelHeight = pixelsToGridUnits(*autoGridLayout.Spec.RowHeight)
} else {
panelHeight = 9
}
default:
panelHeight = 9
}
currentY := yOffset
var currentX int64 = 0
for _, item := range autoGridLayout.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
gridItem := dashv2alpha1.DashboardGridLayoutItemKind{
Kind: "GridLayoutItem",
Spec: dashv2alpha1.DashboardGridLayoutItemSpec{
X: currentX,
Y: currentY,
Width: panelWidth,
Height: panelHeight,
Element: dashv2alpha1.DashboardElementReference{
Kind: item.Spec.Element.Kind,
Name: item.Spec.Element.Name,
},
},
}
// Convert AutoGridRepeatOptions to RepeatOptions if present
// AutoGridRepeatOptions only has mode and value; infer direction and maxPerRow from AutoGrid settings:
// - direction: always "h" (AutoGrid flows horizontally, left-to-right then wraps)
// - maxPerRow: from AutoGrid's maxColumnCount
if item.Spec.Repeat != nil {
directionH := dashv2alpha1.DashboardRepeatOptionsDirectionH
maxPerRow := int64(maxColumnCount)
gridItem.Spec.Repeat = &dashv2alpha1.DashboardRepeatOptions{
Mode: item.Spec.Repeat.Mode,
Value: item.Spec.Repeat.Value,
Direction: &directionH,
MaxPerRow: &maxPerRow,
}
}
panel, err := convertPanelFromElement(&element, &gridItem)
if err != nil {
return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
currentX += panelWidth
if currentX >= gridTotalColumns {
currentX = 0
currentY += panelHeight
}
}
return panels, nil
}
// convertAutoGridLayoutToPanels converts V2 AutoGridLayout to V1 panels with calculated gridPos.
// AutoGridLayout arranges panels automatically; V1 requires explicit x/y/w/h coordinates.
//
// Grid System:
// - V1 uses a 24-column grid where each cell is 30px tall with 8px vertical margin
// - To convert pixels to grid units: ceil(pixels / 38)
// - Panels flow left-to-right, wrapping to next row when column limit reached
//
// Width: 24 / maxColumnCount (default 3 columns = 8 units wide)
// Height: Predefined grid units per mode (see pixelsToGridUnits for custom)
func convertAutoGridLayoutToPanels(elements map[string]dashv2alpha1.DashboardElement, autoGridLayout *dashv2alpha1.DashboardAutoGridLayoutKind) ([]interface{}, error) {
panels := make([]interface{}, 0, len(autoGridLayout.Spec.Items))
const (
gridTotalColumns int64 = 24 // V1 grid is 24 columns wide
gridCellHeightPx float64 = 30 // GRID_CELL_HEIGHT: height of one grid cell in pixels
gridCellVerticalMargin float64 = 8 // GRID_CELL_VMARGIN: margin between grid cells in pixels
defaultMaxColumnCount float64 = 3 // Default number of columns in AutoGrid
)
// pixelsToGridUnits converts a pixel height to grid units.
// Formula: ceil(pixels / (cellHeight + margin)) = ceil(pixels / 38)
pixelsToGridUnits := func(pixels float64) int64 {
gridUnitSize := gridCellHeightPx + gridCellVerticalMargin // 38px per grid unit
return int64((pixels + gridUnitSize - 1) / gridUnitSize) // Ceiling division
}
// Calculate panel width: divide 24-column grid by number of columns
maxColumnCount := defaultMaxColumnCount
if autoGridLayout.Spec.MaxColumnCount != nil && *autoGridLayout.Spec.MaxColumnCount > 0 {
maxColumnCount = *autoGridLayout.Spec.MaxColumnCount
}
panelWidth := int64(float64(gridTotalColumns) / maxColumnCount)
// Calculate panel height based on rowHeightMode
// Each mode has a target pixel height that maps to a specific grid unit count:
// short: ~168px -> 5 grid units
// standard: ~320px -> 9 grid units (default)
// tall: ~512px -> 14 grid units
// custom: user-specified pixels -> calculated grid units
var panelHeight int64
switch autoGridLayout.Spec.RowHeightMode {
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeShort:
panelHeight = 5
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeStandard:
panelHeight = 9
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeTall:
panelHeight = 14
case dashv2alpha1.DashboardAutoGridLayoutSpecRowHeightModeCustom:
if autoGridLayout.Spec.RowHeight != nil && *autoGridLayout.Spec.RowHeight > 0 {
panelHeight = pixelsToGridUnits(*autoGridLayout.Spec.RowHeight)
} else {
panelHeight = 9 // Fall back to standard
}
default:
panelHeight = 9 // Default to standard
}
var currentY int64 = 0
var currentX int64 = 0
for _, item := range autoGridLayout.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
return nil, fmt.Errorf("panel with uid %s not found in the dashboard elements", item.Spec.Element.Name)
}
// Create a GridLayoutItem from the AutoGridLayoutItem for conversion
gridItem := dashv2alpha1.DashboardGridLayoutItemKind{
Kind: "GridLayoutItem",
Spec: dashv2alpha1.DashboardGridLayoutItemSpec{
X: currentX,
Y: currentY,
Width: panelWidth,
Height: panelHeight,
Element: dashv2alpha1.DashboardElementReference{
Kind: item.Spec.Element.Kind,
Name: item.Spec.Element.Name,
},
},
}
// Convert AutoGridRepeatOptions to RepeatOptions if present
// AutoGridRepeatOptions only has mode and value; infer direction and maxPerRow from AutoGrid settings:
// - direction: always "h" (AutoGrid flows horizontally, left-to-right then wraps)
// - maxPerRow: from AutoGrid's maxColumnCount
if item.Spec.Repeat != nil {
directionH := dashv2alpha1.DashboardRepeatOptionsDirectionH
maxPerRow := int64(maxColumnCount)
gridItem.Spec.Repeat = &dashv2alpha1.DashboardRepeatOptions{
Mode: item.Spec.Repeat.Mode,
Value: item.Spec.Repeat.Value,
Direction: &directionH,
MaxPerRow: &maxPerRow,
}
}
panel, err := convertPanelFromElement(&element, &gridItem)
if err != nil {
return nil, fmt.Errorf("failed to convert panel %s: %w", item.Spec.Element.Name, err)
}
panels = append(panels, panel)
// Move to next position: wrap to next row when exceeding maxColumnCount
currentX += panelWidth
if currentX >= gridTotalColumns {
currentX = 0
currentY += panelHeight
}
}
return panels, nil
}
// convertTabsLayoutToPanels converts V2 TabsLayout to V1 row panels.
// V1 has no native tab concept, so tabs are converted to expanded row panels.
// Each tab becomes a row panel (collapsed=false, panels=[]) with its content
// flattened to the top level. Tab order is preserved in the output.
func convertTabsLayoutToPanels(elements map[string]dashv2alpha1.DashboardElement, tabsLayout *dashv2alpha1.DashboardTabsLayoutKind) ([]interface{}, error) {
return convertNestedLayoutToPanels(elements, nil, tabsLayout, 0)
}
func convertPanelFromElement(element *dashv2alpha1.DashboardElement, layoutItem *dashv2alpha1.DashboardGridLayoutItemKind) (map[string]interface{}, error) {
panel := make(map[string]interface{})
// Set grid position
gridPos := map[string]interface{}{
"x": layoutItem.Spec.X,
"y": layoutItem.Spec.Y,
"w": layoutItem.Spec.Width,
"h": layoutItem.Spec.Height,
}
panel["gridPos"] = gridPos
// Handle repeat options
if layoutItem.Spec.Repeat != nil {
panel["repeat"] = layoutItem.Spec.Repeat.Value
if layoutItem.Spec.Repeat.Direction != nil {
switch *layoutItem.Spec.Repeat.Direction {
case dashv2alpha1.DashboardRepeatOptionsDirectionH:
panel["repeatDirection"] = "h"
case dashv2alpha1.DashboardRepeatOptionsDirectionV:
panel["repeatDirection"] = "v"
}
}
if layoutItem.Spec.Repeat.MaxPerRow != nil {
panel["maxPerRow"] = *layoutItem.Spec.Repeat.MaxPerRow
}
}
if element.PanelKind != nil {
return convertPanelKindToV1(element.PanelKind, panel)
}
if element.LibraryPanelKind != nil {
return convertLibraryPanelKindToV1(element.LibraryPanelKind, panel)
}
return nil, fmt.Errorf("element has neither PanelKind nor LibraryPanelKind")
}
func convertPanelKindToV1(panelKind *dashv2alpha1.DashboardPanelKind, panel map[string]interface{}) (map[string]interface{}, error) {
spec := panelKind.Spec
panel["id"] = int(spec.Id)
panel["title"] = spec.Title
panel["pluginVersion"] = spec.VizConfig.Spec.PluginVersion
if spec.Description != "" {
panel["description"] = spec.Description
}
// Convert vizConfig - use the plugin ID from VizConfig.Kind, not PanelKind.Kind
panel["type"] = spec.VizConfig.Kind // panel type from vizConfig kind (plugin ID)
if spec.VizConfig.Spec.Options != nil {
panel["options"] = spec.VizConfig.Spec.Options
}
// Convert field config
fieldConfig := convertFieldConfigSourceToV1(&spec.VizConfig.Spec.FieldConfig)
if fieldConfig != nil {
panel["fieldConfig"] = fieldConfig
}
// Convert data links
if len(spec.Links) > 0 {
links := make([]map[string]interface{}, 0, len(spec.Links))
for _, link := range spec.Links {
linkMap := map[string]interface{}{
"title": link.Title,
"url": link.Url,
}
if link.TargetBlank != nil {
linkMap["targetBlank"] = *link.TargetBlank
}
links = append(links, linkMap)
}
panel["links"] = links
}
// Convert queries (targets)
// Use []interface{} for consistency with JSON unmarshaling and other code paths
targets := make([]interface{}, 0, len(spec.Data.Spec.Queries))
for _, query := range spec.Data.Spec.Queries {
target := convertPanelQueryToV1(&query)
targets = append(targets, target)
}
panel["targets"] = targets
// Set panel-level datasource from queries.
// - If queries use different datasources, set to "mixed"
// - If all queries use the same datasource, set to that datasource
// This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource
// and some components like CSVExportPage rely on it to resolve the datasource. If undefined, it falls back to the default datasource
// which would overwrite the query's datasource.
if panelDS := getPanelDatasource(spec.Data.Spec.Queries); panelDS != nil {
panel["datasource"] = panelDS
}
// Convert transformations
if len(spec.Data.Spec.Transformations) > 0 {
transformations := make([]map[string]interface{}, 0, len(spec.Data.Spec.Transformations))
for _, t := range spec.Data.Spec.Transformations {
transformation := map[string]interface{}{
"id": t.Spec.Id,
"options": t.Spec.Options,
}
// Add disabled if set
if t.Spec.Disabled != nil {
transformation["disabled"] = *t.Spec.Disabled
}
// Add filter if set
if t.Spec.Filter != nil {
transformation["filter"] = map[string]interface{}{
"id": t.Spec.Filter.Id,
"options": t.Spec.Filter.Options,
}
}
transformations = append(transformations, transformation)
}
panel["transformations"] = transformations
}
// Convert query options
queryOptions := spec.Data.Spec.QueryOptions
if queryOptions.CacheTimeout != nil {
panel["cacheTimeout"] = *queryOptions.CacheTimeout
}
if queryOptions.MaxDataPoints != nil {
panel["maxDataPoints"] = *queryOptions.MaxDataPoints
}
if queryOptions.Interval != nil {
panel["interval"] = *queryOptions.Interval
}
if queryOptions.HideTimeOverride != nil {
panel["hideTimeOverride"] = *queryOptions.HideTimeOverride
}
if queryOptions.QueryCachingTTL != nil {
panel["queryCachingTTL"] = *queryOptions.QueryCachingTTL
}
if queryOptions.TimeFrom != nil {
panel["timeFrom"] = *queryOptions.TimeFrom
}
if queryOptions.TimeShift != nil {
panel["timeShift"] = *queryOptions.TimeShift
}
// Convert transparent
if spec.Transparent != nil {
panel["transparent"] = *spec.Transparent
}
return panel, nil
}
func convertPanelQueryToV1(query *dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} {
target := make(map[string]interface{})
// Copy query spec (excluding refId, hide, datasource which are handled separately)
querySpec := query.Spec.Query.Spec
for key, value := range querySpec {
// Skip LEGACY_STRING_VALUE_KEY - handle it specially
if key == LEGACY_STRING_VALUE_KEY {
if strValue, ok := value.(string); ok {
target["query"] = strValue
}
continue
}
target[key] = value
}
// Add refId
target["refId"] = query.Spec.RefId
// Only include hide when true (hidden). This matches frontend behavior which omits falsy values.
if query.Spec.Hidden {
target["hide"] = true
}
// Resolve datasource based on V2 input (reuse shared function)
datasource := getDataSourceForQuery(query.Spec.Datasource, query.Spec.Query.Kind)
if datasource != nil {
target["datasource"] = datasource
}
return target
}
// getDataSourceForQuery converts V2 datasource info to V1 format.
// This preserves exactly what V2 has without runtime resolution:
// - If explicit UID provided → return {uid, type}
// - Else if queryKind (type) is non-empty → return {type} only
// - Else → return nil (no datasource)
// Used for variables and annotations. Panel queries use convertPanelQueryToV1Target.
func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, queryKind string) map[string]interface{} {
// Case 1: Explicit datasource with UID provided
if explicitDS != nil && explicitDS.Uid != nil && *explicitDS.Uid != "" {
datasource := map[string]interface{}{
"uid": *explicitDS.Uid,
}
if explicitDS.Type != nil {
datasource["type"] = *explicitDS.Type
} else if queryKind != "" {
// Use query kind as type if explicit type not provided
datasource["type"] = queryKind
}
return datasource
}
// Case 2: No explicit UID but query kind (type) exists - include only type
if queryKind != "" {
return map[string]interface{}{
"type": queryKind,
}
}
// Case 3: No UID and no query kind - no datasource
return nil
}
// getPanelDatasource determines the panel-level datasource for V1.
// Returns:
// - Mixed datasource reference if queries use different datasources
// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels)
// - Dashboard datasource reference if a single query uses Dashboard datasource
// - First query's datasource if all queries use the same datasource
// - nil if no queries exist
// Compares based on V2 input without runtime resolution:
// - If query has explicit datasource.uid → use that UID and type
// - Else → use query.Kind as type (empty UID)
func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} {
const sharedDashboardQuery = "-- Dashboard --"
if len(queries) == 0 {
return nil
}
// Count how many queries use Dashboard datasource
// Multiple dashboard queries need mixed mode because they fetch from different panels
// which may have different underlying datasources
dashboardDsQueryCount := 0
for _, query := range queries {
if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery {
dashboardDsQueryCount++
}
}
if dashboardDsQueryCount > 1 {
return map[string]interface{}{
"type": "mixed",
"uid": "-- Mixed --",
}
}
var firstUID, firstType string
var hasFirst bool
for _, query := range queries {
var queryUID, queryType string
// Get datasource from query based on V2 input (no runtime resolution)
if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid != "" {
// Explicit datasource reference
queryUID = *query.Spec.Datasource.Uid
if query.Spec.Datasource.Type != nil {
queryType = *query.Spec.Datasource.Type
} else {
queryType = query.Spec.Query.Kind
}
} else {
// No explicit datasource - use query kind as type
queryUID = ""
queryType = query.Spec.Query.Kind
}
if !hasFirst {
firstUID = queryUID
firstType = queryType
hasFirst = true
} else if firstUID != queryUID || firstType != queryType {
// Different datasource found - this is a mixed panel
return map[string]interface{}{
"type": "mixed",
"uid": "-- Mixed --",
}
}
}
// Handle case when a single query uses Dashboard datasource.
// This is needed for the frontend to properly activate and fetch data from source panels.
// See DashboardDatasourceBehaviour.tsx for more details.
if firstUID == sharedDashboardQuery {
return map[string]interface{}{
"type": "datasource",
"uid": sharedDashboardQuery,
}
}
// Not mixed - return the first query's datasource so the panel has a datasource set.
// This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource
// to resolve the datasource, and if undefined, it falls back to the default datasource
// which then overwrites the query's datasource.
if firstType == "" && firstUID == "" {
return nil
}
result := make(map[string]interface{})
if firstType != "" {
result["type"] = firstType
}
if firstUID != "" {
result["uid"] = firstUID
}
return result
}
func convertLibraryPanelKindToV1(libPanelKind *dashv2alpha1.DashboardLibraryPanelKind, panel map[string]interface{}) (map[string]interface{}, error) {
spec := libPanelKind.Spec
panel["id"] = int(spec.Id)
panel["title"] = spec.Title
panel["type"] = "library-panel-ref"
libraryPanel := map[string]interface{}{
"uid": spec.LibraryPanel.Uid,
"name": spec.LibraryPanel.Name,
}
panel["libraryPanel"] = libraryPanel
return panel, nil
}
func convertVariablesToV1(variables []dashv2alpha1.DashboardVariableKind) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(variables))
for _, variable := range variables {
var varMap map[string]interface{}
var err error
if variable.QueryVariableKind != nil {
varMap, err = convertQueryVariableToV1(variable.QueryVariableKind)
} else if variable.DatasourceVariableKind != nil {
varMap, err = convertDatasourceVariableToV1(variable.DatasourceVariableKind)
} else if variable.CustomVariableKind != nil {
varMap, err = convertCustomVariableToV1(variable.CustomVariableKind)
} else if variable.ConstantVariableKind != nil {
varMap, err = convertConstantVariableToV1(variable.ConstantVariableKind)
} else if variable.IntervalVariableKind != nil {
varMap, err = convertIntervalVariableToV1(variable.IntervalVariableKind)
} else if variable.TextVariableKind != nil {
varMap, err = convertTextVariableToV1(variable.TextVariableKind)
} else if variable.GroupByVariableKind != nil {
varMap, err = convertGroupByVariableToV1(variable.GroupByVariableKind)
} else if variable.AdhocVariableKind != nil {
varMap, err = convertAdhocVariableToV1(variable.AdhocVariableKind)
} else if variable.SwitchVariableKind != nil {
varMap, err = convertSwitchVariableToV1(variable.SwitchVariableKind)
}
if err == nil && varMap != nil {
result = append(result, varMap)
}
}
return result
}
func convertQueryVariableToV1(variable *dashv2alpha1.DashboardQueryVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "query",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"multi": spec.Multi,
"includeAll": spec.IncludeAll,
"refresh": transformVariableRefreshFromEnum(spec.Refresh),
"sort": transformVariableSortFromEnum(spec.Sort),
"regex": spec.Regex,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
"options": convertVariableOptionsToV1(spec.Options),
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
if spec.AllValue != nil {
varMap["allValue"] = *spec.AllValue
}
if spec.Definition != nil {
varMap["definition"] = *spec.Definition
}
if spec.RegexApplyTo != nil {
varMap["regexApplyTo"] = string(*spec.RegexApplyTo)
}
varMap["allowCustomValue"] = spec.AllowCustomValue
// Convert query - handle LEGACY_STRING_VALUE_KEY
querySpec := spec.Query.Spec
if legacyValue, ok := querySpec[LEGACY_STRING_VALUE_KEY].(string); ok {
varMap["query"] = legacyValue
} else {
// Copy query spec as-is
queryCopy := make(map[string]interface{})
for k, v := range querySpec {
queryCopy[k] = v
}
varMap["query"] = queryCopy
}
// Resolve datasource - use explicit datasource or resolve from query kind (datasource type)/default
datasource := getDataSourceForQuery(spec.Datasource, spec.Query.Kind)
if datasource != nil {
varMap["datasource"] = datasource
}
return varMap, nil
}
func convertDatasourceVariableToV1(variable *dashv2alpha1.DashboardDatasourceVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "datasource",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"multi": spec.Multi,
"includeAll": spec.IncludeAll,
"refresh": transformVariableRefreshFromEnum(spec.Refresh),
"regex": spec.Regex,
"query": spec.PluginId,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
"options": convertVariableOptionsToV1(spec.Options),
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
if spec.AllValue != nil {
varMap["allValue"] = *spec.AllValue
}
varMap["allowCustomValue"] = spec.AllowCustomValue
return varMap, nil
}
func convertCustomVariableToV1(variable *dashv2alpha1.DashboardCustomVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "custom",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"multi": spec.Multi,
"includeAll": spec.IncludeAll,
"query": spec.Query,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
"options": convertVariableOptionsToV1(spec.Options),
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
if spec.AllValue != nil {
varMap["allValue"] = *spec.AllValue
}
varMap["allowCustomValue"] = spec.AllowCustomValue
return varMap, nil
}
func convertConstantVariableToV1(variable *dashv2alpha1.DashboardConstantVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
// Constant variables in v1beta1 must always be hidden (hide: 2),
// otherwise DashboardMigrator will convert them to textbox variables.
varMap := map[string]interface{}{
"name": spec.Name,
"type": "constant",
"hide": 2, // hideVariable - constant variables must always be hidden in v1beta1
"skipUrlSync": spec.SkipUrlSync,
"query": spec.Query,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
return varMap, nil
}
func convertIntervalVariableToV1(variable *dashv2alpha1.DashboardIntervalVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "interval",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"query": spec.Query,
"auto": spec.Auto,
"auto_min": spec.AutoMin,
"auto_count": spec.AutoCount,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
"options": convertVariableOptionsToV1(spec.Options),
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
return varMap, nil
}
func convertTextVariableToV1(variable *dashv2alpha1.DashboardTextVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "textbox",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"query": spec.Query,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
return varMap, nil
}
func convertGroupByVariableToV1(variable *dashv2alpha1.DashboardGroupByVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "groupby",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"multi": spec.Multi,
"current": map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(spec.Current.Text),
"value": convertStringOrArrayOfStringToV1(spec.Current.Value),
},
"options": convertVariableOptionsToV1(spec.Options),
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
// Resolve datasource - GroupBy variables don't have a query kind, so use empty string (will fall back to default)
datasource := getDataSourceForQuery(spec.Datasource, "")
if datasource != nil {
varMap["datasource"] = datasource
}
return varMap, nil
}
func convertAdhocVariableToV1(variable *dashv2alpha1.DashboardAdhocVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
varMap := map[string]interface{}{
"name": spec.Name,
"type": "adhoc",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
// Always include allowCustomValue for adhoc variables, including false values
varMap["allowCustomValue"] = spec.AllowCustomValue
// Resolve datasource - Adhoc variables don't have a query kind, so use empty string (will fall back to default)
datasource := getDataSourceForQuery(spec.Datasource, "")
if datasource != nil {
varMap["datasource"] = datasource
}
// Convert filters
if len(spec.Filters) > 0 {
filters := make([]map[string]interface{}, 0, len(spec.Filters))
for _, filter := range spec.Filters {
filterMap := map[string]interface{}{
"key": filter.Key,
"operator": filter.Operator,
"value": filter.Value,
}
if filter.KeyLabel != nil {
filterMap["keyLabel"] = *filter.KeyLabel
}
if filter.Condition != nil {
filterMap["condition"] = *filter.Condition
}
if len(filter.Values) > 0 {
filterMap["values"] = filter.Values
}
if len(filter.ValueLabels) > 0 {
filterMap["valueLabels"] = filter.ValueLabels
}
filters = append(filters, filterMap)
}
varMap["filters"] = filters
}
// Convert baseFilters
if len(spec.BaseFilters) > 0 {
baseFilters := make([]map[string]interface{}, 0, len(spec.BaseFilters))
for _, filter := range spec.BaseFilters {
filterMap := map[string]interface{}{
"key": filter.Key,
"operator": filter.Operator,
"value": filter.Value,
}
if filter.KeyLabel != nil {
filterMap["keyLabel"] = *filter.KeyLabel
}
if filter.Condition != nil {
filterMap["condition"] = *filter.Condition
}
if len(filter.Values) > 0 {
filterMap["values"] = filter.Values
}
if len(filter.ValueLabels) > 0 {
filterMap["valueLabels"] = filter.ValueLabels
}
baseFilters = append(baseFilters, filterMap)
}
varMap["baseFilters"] = baseFilters
}
// Convert defaultKeys
if len(spec.DefaultKeys) > 0 {
defaultKeys := make([]interface{}, 0, len(spec.DefaultKeys))
for _, key := range spec.DefaultKeys {
keyMap := map[string]interface{}{
"text": key.Text,
}
if key.Value != nil {
if key.Value.String != nil {
keyMap["value"] = *key.Value.String
} else if key.Value.Float64 != nil {
keyMap["value"] = *key.Value.Float64
}
}
// Preserve optional fields if present in input
if key.Group != nil {
keyMap["group"] = *key.Group
}
if key.Expandable != nil {
keyMap["expandable"] = *key.Expandable
}
defaultKeys = append(defaultKeys, keyMap)
}
varMap["defaultKeys"] = defaultKeys
}
return varMap, nil
}
func convertSwitchVariableToV1(variable *dashv2alpha1.DashboardSwitchVariableKind) (map[string]interface{}, error) {
spec := variable.Spec
// Determine which value is selected based on current
enabledSelected := spec.Current == spec.EnabledValue
disabledSelected := spec.Current == spec.DisabledValue
varMap := map[string]interface{}{
"name": spec.Name,
"type": "switch",
"hide": transformVariableHideFromEnum(spec.Hide),
"skipUrlSync": spec.SkipUrlSync,
"query": "", // Switch variables have empty query in V1
"current": map[string]interface{}{
"text": spec.Current,
"value": spec.Current,
},
"options": []map[string]interface{}{
{
"text": spec.EnabledValue,
"value": spec.EnabledValue,
"selected": enabledSelected,
},
{
"text": spec.DisabledValue,
"value": spec.DisabledValue,
"selected": disabledSelected,
},
},
}
if spec.Label != nil {
varMap["label"] = *spec.Label
}
if spec.Description != nil {
varMap["description"] = *spec.Description
}
return varMap, nil
}
func convertAnnotationsToV1(annotations []dashv2alpha1.DashboardAnnotationQueryKind) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(annotations))
for _, annotation := range annotations {
annotationMap := map[string]interface{}{
"name": annotation.Spec.Name,
"enable": annotation.Spec.Enable,
"hide": annotation.Spec.Hide,
"iconColor": annotation.Spec.IconColor,
}
// Convert builtIn boolean to integer (v1beta1 uses 1 for true, and omits for false)
// Also add type: "dashboard" for built-in annotations
if annotation.Spec.BuiltIn != nil && *annotation.Spec.BuiltIn {
annotationMap["builtIn"] = 1
annotationMap["type"] = "dashboard"
}
// Resolve datasource - use explicit datasource or resolve from query kind (datasource type)/default
var queryKind string
if annotation.Spec.Query != nil {
queryKind = annotation.Spec.Query.Kind
}
datasource := getDataSourceForQuery(annotation.Spec.Datasource, queryKind)
if datasource != nil {
annotationMap["datasource"] = datasource
}
// Convert query to target
if annotation.Spec.Query != nil {
// Copy query spec as target
target := make(map[string]interface{})
for k, v := range annotation.Spec.Query.Spec {
target[k] = v
}
if len(target) > 0 {
annotationMap["target"] = target
}
}
// Convert filter
if annotation.Spec.Filter != nil {
filter := make(map[string]interface{})
if annotation.Spec.Filter.Exclude != nil {
filter["exclude"] = *annotation.Spec.Filter.Exclude
}
if len(annotation.Spec.Filter.Ids) > 0 {
ids := make([]interface{}, 0, len(annotation.Spec.Filter.Ids))
for _, id := range annotation.Spec.Filter.Ids {
ids = append(ids, id)
}
filter["ids"] = ids
}
if len(filter) > 0 {
annotationMap["filter"] = filter
}
}
// Convert mappings from v2alpha1 format back to v1beta1 format
if len(annotation.Spec.Mappings) > 0 {
mappings := convertAnnotationMappings_V2alpha1_to_V1beta1(annotation.Spec.Mappings)
if len(mappings) > 0 {
annotationMap["mappings"] = mappings
}
}
// Copy legacy options
// This is used to copy any unknown properties from the v1 at the root of the annotations that were not handled by the conversion.
// When they are converted into V2 they are moved to legacyOptions. Now we move them back to the root of the annotation.
if annotation.Spec.LegacyOptions != nil {
for k, v := range annotation.Spec.LegacyOptions {
// Skip fields already handled
if k != "name" && k != "enable" && k != "hide" && k != "iconColor" &&
k != "datasource" && k != "target" && k != "filter" && k != "builtIn" && k != "placement" && k != "mappings" {
annotationMap[k] = v
}
}
}
result = append(result, annotationMap)
}
return result
}
// convertAnnotationMappings_V2alpha1_to_V1beta1 converts mappings from v2alpha1 structured format
// back to v1beta1 format. v1beta1 supports both simple string format and structured format with source/value/regex.
// v2alpha1 format: map[string]DashboardAnnotationEventFieldMapping with Source, Value, Regex
// v1beta1 format: map[string]interface{} where values can be either:
// - string (legacy simple format: "fieldName": "targetFieldName")
// - object (structured format: "fieldName": {"source": "field", "value": "...", "regex": "..."})
func convertAnnotationMappings_V2alpha1_to_V1beta1(mappings map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping) map[string]interface{} {
result := make(map[string]interface{})
for key, mapping := range mappings {
// Always convert to structured format with source and value fields
mappingMap := make(map[string]interface{})
// Source defaults to "field" if not specified
source := "field"
if mapping.Source != nil {
source = *mapping.Source
}
mappingMap["source"] = source
// Value is optional (required for "field" and "text" sources, but "skip" doesn't need it)
if mapping.Value != nil && *mapping.Value != "" {
mappingMap["value"] = *mapping.Value
}
// Regex is optional
if mapping.Regex != nil && *mapping.Regex != "" {
mappingMap["regex"] = *mapping.Regex
}
// Include the mapping if it has source (and value for non-skip sources)
// Skip source doesn't require a value
if source == "skip" || (mapping.Value != nil && *mapping.Value != "") {
result[key] = mappingMap
}
}
return result
}
// Enum transformation functions (reverse of v1→v2)
func transformVariableHideFromEnum(hide dashv2alpha1.DashboardVariableHide) interface{} {
switch hide {
case dashv2alpha1.DashboardVariableHideDontHide:
return 0
case dashv2alpha1.DashboardVariableHideHideLabel:
return 1
case dashv2alpha1.DashboardVariableHideHideVariable:
return 2
default:
return 0
}
}
func transformVariableRefreshFromEnum(refresh dashv2alpha1.DashboardVariableRefresh) interface{} {
switch refresh {
case dashv2alpha1.DashboardVariableRefreshNever:
return 0
case dashv2alpha1.DashboardVariableRefreshOnDashboardLoad:
return 1
case dashv2alpha1.DashboardVariableRefreshOnTimeRangeChanged:
return 2
default:
return 0
}
}
func transformVariableSortFromEnum(sort dashv2alpha1.DashboardVariableSort) interface{} {
switch sort {
case dashv2alpha1.DashboardVariableSortDisabled:
return 0
case dashv2alpha1.DashboardVariableSortAlphabeticalAsc:
return 1
case dashv2alpha1.DashboardVariableSortAlphabeticalDesc:
return 2
case dashv2alpha1.DashboardVariableSortNumericalAsc:
return 3
case dashv2alpha1.DashboardVariableSortNumericalDesc:
return 4
case dashv2alpha1.DashboardVariableSortAlphabeticalCaseInsensitiveAsc:
return 5
case dashv2alpha1.DashboardVariableSortAlphabeticalCaseInsensitiveDesc:
return 6
case dashv2alpha1.DashboardVariableSortNaturalAsc:
return 7
case dashv2alpha1.DashboardVariableSortNaturalDesc:
return 8
default:
return 0
}
}
func convertStringOrArrayOfStringToV1(value dashv2alpha1.DashboardStringOrArrayOfString) interface{} {
if value.String != nil {
return *value.String
}
if len(value.ArrayOfString) > 0 {
return value.ArrayOfString
}
return ""
}
func convertVariableOptionsToV1(options []dashv2alpha1.DashboardVariableOption) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(options))
for _, option := range options {
optionMap := map[string]interface{}{
"text": convertStringOrArrayOfStringToV1(option.Text),
"value": convertStringOrArrayOfStringToV1(option.Value),
}
if option.Selected != nil {
optionMap["selected"] = *option.Selected
}
result = append(result, optionMap)
}
return result
}
func convertFieldConfigSourceToV1(fieldConfig *dashv2alpha1.DashboardFieldConfigSource) map[string]interface{} {
if fieldConfig == nil {
return nil
}
result := make(map[string]interface{})
// Convert defaults
defaults := convertFieldConfigDefaultsToV1(&fieldConfig.Defaults)
if defaults != nil {
result["defaults"] = defaults
}
// Convert overrides
overrides := convertFieldConfigOverridesToV1(fieldConfig.Overrides)
if overrides != nil {
result["overrides"] = overrides
}
if len(result) == 0 {
return nil
}
return result
}
func convertFieldConfigDefaultsToV1(defaults *dashv2alpha1.DashboardFieldConfig) map[string]interface{} {
if defaults == nil {
return nil
}
result := make(map[string]interface{})
if defaults.Custom != nil {
result["custom"] = defaults.Custom
}
if defaults.Decimals != nil {
result["decimals"] = *defaults.Decimals
}
if defaults.Max != nil {
result["max"] = *defaults.Max
}
if defaults.Min != nil {
result["min"] = *defaults.Min
}
if defaults.Description != nil {
result["description"] = *defaults.Description
}
if defaults.DisplayName != nil {
result["displayName"] = *defaults.DisplayName
}
if defaults.DisplayNameFromDS != nil {
result["displayNameFromDS"] = *defaults.DisplayNameFromDS
}
if defaults.NoValue != nil {
result["noValue"] = *defaults.NoValue
}
if defaults.Path != nil {
result["path"] = *defaults.Path
}
if defaults.Unit != nil {
result["unit"] = *defaults.Unit
}
if defaults.Filterable != nil {
result["filterable"] = *defaults.Filterable
}
if defaults.Writeable != nil {
result["writeable"] = *defaults.Writeable
}
if defaults.FieldMinMax != nil {
result["fieldMinMax"] = *defaults.FieldMinMax
}
if defaults.NullValueMode != nil {
result["nullValueMode"] = string(*defaults.NullValueMode)
}
if defaults.Links != nil {
result["links"] = defaults.Links
}
if len(defaults.Actions) > 0 {
result["actions"] = convertActionsToV1(defaults.Actions)
}
if defaults.Color != nil {
result["color"] = convertFieldColorToV1(defaults.Color)
}
if defaults.Mappings != nil {
result["mappings"] = convertMappingsToV1(defaults.Mappings)
}
if defaults.Thresholds != nil {
result["thresholds"] = convertThresholdsToV1(defaults.Thresholds)
}
if len(result) == 0 {
return nil
}
return result
}
func convertFieldConfigOverridesToV1(overrides []dashv2alpha1.DashboardV2alpha1FieldConfigSourceOverrides) []map[string]interface{} {
if len(overrides) == 0 {
return nil
}
result := make([]map[string]interface{}, 0, len(overrides))
for _, override := range overrides {
overrideMap := make(map[string]interface{})
if override.SystemRef != nil {
overrideMap["__systemRef"] = *override.SystemRef
}
overrideMap["matcher"] = map[string]interface{}{
"id": override.Matcher.Id,
"options": override.Matcher.Options,
}
properties := make([]map[string]interface{}, 0, len(override.Properties))
if len(override.Properties) > 0 {
for _, prop := range override.Properties {
properties = append(properties, map[string]interface{}{
"id": prop.Id,
"value": prop.Value,
})
}
}
overrideMap["properties"] = properties
result = append(result, overrideMap)
}
return result
}
func convertFieldColorToV1(color *dashv2alpha1.DashboardFieldColor) map[string]interface{} {
if color == nil {
return nil
}
colorMap := map[string]interface{}{
"mode": string(color.Mode),
}
if color.FixedColor != nil {
colorMap["fixedColor"] = *color.FixedColor
}
if color.SeriesBy != nil {
colorMap["seriesBy"] = string(*color.SeriesBy)
}
return colorMap
}
func convertMappingsToV1(mappings []dashv2alpha1.DashboardValueMapping) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(mappings))
for _, mapping := range mappings {
var mappingMap map[string]interface{}
if mapping.ValueMap != nil {
mappingMap = convertValueMapToV1(mapping.ValueMap)
} else if mapping.RangeMap != nil {
mappingMap = convertRangeMapToV1(mapping.RangeMap)
} else if mapping.RegexMap != nil {
mappingMap = convertRegexMapToV1(mapping.RegexMap)
} else if mapping.SpecialValueMap != nil {
mappingMap = convertSpecialValueMapToV1(mapping.SpecialValueMap)
}
if mappingMap != nil {
result = append(result, mappingMap)
}
}
return result
}
func convertValueMapToV1(valueMap *dashv2alpha1.DashboardValueMap) map[string]interface{} {
if valueMap == nil {
return nil
}
options := make(map[string]interface{})
for k, v := range valueMap.Options {
options[k] = convertValueMappingResultToV1(v)
}
return map[string]interface{}{
"type": "value",
"options": options,
}
}
func convertRangeMapToV1(rangeMap *dashv2alpha1.DashboardRangeMap) map[string]interface{} {
if rangeMap == nil {
return nil
}
options := map[string]interface{}{
"result": convertValueMappingResultToV1(rangeMap.Options.Result),
}
if rangeMap.Options.From != nil {
options["from"] = *rangeMap.Options.From
}
if rangeMap.Options.To != nil {
options["to"] = *rangeMap.Options.To
}
return map[string]interface{}{
"type": "range",
"options": options,
}
}
func convertRegexMapToV1(regexMap *dashv2alpha1.DashboardRegexMap) map[string]interface{} {
if regexMap == nil {
return nil
}
options := map[string]interface{}{
"pattern": regexMap.Options.Pattern,
"result": convertValueMappingResultToV1(regexMap.Options.Result),
}
return map[string]interface{}{
"type": "regex",
"options": options,
}
}
func convertSpecialValueMapToV1(specialMap *dashv2alpha1.DashboardSpecialValueMap) map[string]interface{} {
if specialMap == nil {
return nil
}
options := map[string]interface{}{
"match": string(specialMap.Options.Match),
"result": convertValueMappingResultToV1(specialMap.Options.Result),
}
return map[string]interface{}{
"type": "special",
"options": options,
}
}
func convertValueMappingResultToV1(result dashv2alpha1.DashboardValueMappingResult) map[string]interface{} {
resultMap := make(map[string]interface{})
if result.Text != nil {
resultMap["text"] = *result.Text
}
if result.Color != nil {
resultMap["color"] = *result.Color
}
if result.Icon != nil {
resultMap["icon"] = *result.Icon
}
if result.Index != nil {
resultMap["index"] = *result.Index
}
return resultMap
}
func convertThresholdsToV1(thresholds *dashv2alpha1.DashboardThresholdsConfig) map[string]interface{} {
if thresholds == nil {
return nil
}
thresholdsMap := map[string]interface{}{
"mode": string(thresholds.Mode),
"steps": make([]map[string]interface{}, 0, len(thresholds.Steps)),
}
for _, step := range thresholds.Steps {
stepMap := map[string]interface{}{
"color": step.Color,
}
// Preserve null values (representing -Infinity)
if step.Value != nil {
stepMap["value"] = *step.Value
} else {
stepMap["value"] = nil
}
thresholdsMap["steps"] = append(thresholdsMap["steps"].([]map[string]interface{}), stepMap)
}
return thresholdsMap
}
func convertActionsToV1(actions []dashv2alpha1.DashboardAction) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(actions))
for _, action := range actions {
actionMap := map[string]interface{}{
"type": string(action.Type),
"title": action.Title,
}
if action.Confirmation != nil {
actionMap["confirmation"] = *action.Confirmation
}
if action.OneClick != nil {
actionMap["oneClick"] = *action.OneClick
}
if action.Fetch != nil {
actionMap["fetch"] = convertFetchOptionsToV1(action.Fetch)
}
if action.Infinity != nil {
actionMap["infinity"] = convertInfinityOptionsToV1(action.Infinity)
}
if len(action.Variables) > 0 {
actionMap["variables"] = convertActionVariablesToV1(action.Variables)
}
if action.Style != nil {
styleMap := map[string]interface{}{}
if action.Style.BackgroundColor != nil {
styleMap["backgroundColor"] = *action.Style.BackgroundColor
}
if len(styleMap) > 0 {
actionMap["style"] = styleMap
}
}
result = append(result, actionMap)
}
return result
}
func convertFetchOptionsToV1(fetch *dashv2alpha1.DashboardFetchOptions) map[string]interface{} {
result := map[string]interface{}{
"method": string(fetch.Method),
"url": fetch.Url,
}
if fetch.Body != nil {
result["body"] = *fetch.Body
}
if len(fetch.QueryParams) > 0 {
result["queryParams"] = convert2DStringArrayToInterface(fetch.QueryParams)
}
if len(fetch.Headers) > 0 {
result["headers"] = convert2DStringArrayToInterface(fetch.Headers)
}
return result
}
func convertInfinityOptionsToV1(infinity *dashv2alpha1.DashboardInfinityOptions) map[string]interface{} {
result := map[string]interface{}{
"method": string(infinity.Method),
"url": infinity.Url,
"datasourceUid": infinity.DatasourceUid,
}
if infinity.Body != nil {
result["body"] = *infinity.Body
}
if len(infinity.QueryParams) > 0 {
result["queryParams"] = convert2DStringArrayToInterface(infinity.QueryParams)
}
if len(infinity.Headers) > 0 {
result["headers"] = convert2DStringArrayToInterface(infinity.Headers)
}
return result
}
func convertActionVariablesToV1(variables []dashv2alpha1.DashboardActionVariable) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(variables))
for _, v := range variables {
result = append(result, map[string]interface{}{
"key": v.Key,
"name": v.Name,
"type": v.Type,
})
}
return result
}
func convert2DStringArrayToInterface(arr [][]string) []interface{} {
result := make([]interface{}, 0, len(arr))
for _, innerArr := range arr {
interfaceArr := make([]interface{}, 0, len(innerArr))
for _, s := range innerArr {
interfaceArr = append(interfaceArr, s)
}
result = append(result, interfaceArr)
}
return result
}