grafana/apps/dashboard/pkg/migration/schemaversion/v24.go

617 lines
18 KiB
Go

package schemaversion
import (
"context"
"strconv"
)
// V24 migration migrates the angular table panel to the standard table panel
// In the frontend, this is an auto-migration meaning that this angular panel is always migrated to table panel.
// The backend replicates the complete frontend auto-migration logic since it cannot rely on frontend auto-migration.
//
// This migration performs:
// 1. Converts 'styles' array to 'fieldConfig' with 'defaults' and 'overrides'
// 2. Migrates thresholds and colors to new threshold format
// 3. Converts column-specific styles to field overrides
// 4. Migrates transformations from old format to new transformation system
// 5. Handles various style properties: unit, decimals, alignment, color modes, links, date formatting, hidden columns
// 6. Removes deprecated properties: styles, transform, columns
// Example 1: Basic table with defaults
// Before migration:
// {
// "panels": [
// {
// "id": 1,
// "type": "table",
// "title": "Basic Table",
// "styles": [
// {
// "pattern": "/.*/",
// "thresholds": ["10", "20", "30"],
// "colors": ["green", "yellow", "red"],
// "unit": "bytes",
// "decimals": 2
// }
// ],
// "targets": [{ "refId": "A" }]
// }
// ]
// }
//
// After migration:
// {
// "panels": [
// {
// "id": 1,
// "type": "table",
// "title": "Basic Table",
// "fieldConfig": {
// "defaults": {
// "unit": "bytes",
// "decimals": 2,
// "custom": {},
// "thresholds": {
// "mode": "absolute",
// "steps": [
// { "color": "green", "value": null },
// { "color": "green", "value": 10 },
// { "color": "yellow", "value": 20 },
// { "color": "red", "value": 30 }
// ]
// }
// },
// "overrides": []
// },
// "transformations": [],
// "targets": [{ "refId": "A" }],
// "pluginVersion": "{current_grafana_version}"
// }
// ]
// }
// Example 2: Complex table with overrides and transformations
// Before migration:
// {
// "panels": [
// {
// "id": 2,
// "type": "table",
// "title": "Complex Table",
// "styles": [
// {
// "pattern": "/.*/",
// "unit": "percent",
// "align": "center",
// "colorMode": "cell"
// },
// {
// "pattern": "Status",
// "alias": "Current Status",
// "colorMode": "value",
// "align": "left"
// },
// {
// "pattern": "/Error.*/",
// "link": true,
// "linkUrl": "http://example.com/errors",
// "linkTooltip": "View errors",
// "linkTargetBlank": true
// },
// {
// "pattern": "Time",
// "type": "date",
// "dateFormat": "YYYY-MM-DD HH:mm:ss",
// "alias": "Timestamp"
// },
// {
// "pattern": "Hidden",
// "type": "hidden"
// }
// ],
// "transform": "timeseries_aggregations",
// "columns": [
// { "value": "avg", "text": "Average" },
// { "value": "max", "text": "Maximum" }
// ],
// "targets": [{ "refId": "A" }]
// }
// ]
// }
//
// After migration:
// {
// "panels": [
// {
// "id": 2,
// "type": "table",
// "title": "Complex Table",
// "fieldConfig": {
// "defaults": {
// "unit": "percent",
// "custom": {
// "align": "center",
// "cellOptions": { "type": "color-background" }
// }
// },
// "overrides": [
// {
// "matcher": { "id": "byName", "options": "Status" },
// "properties": [
// { "id": "displayName", "value": "Current Status" },
// { "id": "custom.cellOptions", "value": { "type": "color-text" } },
// { "id": "custom.align", "value": "left" }
// ]
// },
// {
// "matcher": { "id": "byRegexp", "options": "/Error.*/" },
// "properties": [
// {
// "id": "links",
// "value": [{
// "title": "View errors",
// "url": "http://example.com/errors",
// "targetBlank": true
// }]
// }
// ]
// },
// {
// "matcher": { "id": "byName", "options": "Time" },
// "properties": [
// { "id": "displayName", "value": "Timestamp" },
// { "id": "unit", "value": "time: YYYY-MM-DD HH:mm:ss" }
// ]
// },
// {
// "matcher": { "id": "byName", "options": "Hidden" },
// "properties": [
// { "id": "custom.hideFrom.viz", "value": true }
// ]
// }
// ]
// },
// "transformations": [
// {
// "id": "reduce",
// "options": {
// "reducers": ["mean", "max"],
// "includeTimeField": false
// }
// }
// ],
// "targets": [{ "refId": "A" }],
// "pluginVersion": "{current_grafana_version}"
// }
// ]
// }
func V24(_ context.Context, dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = 24
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
for _, panel := range panels {
panelMap, ok := panel.(map[string]interface{})
if !ok {
continue
}
wasAngularTable := panelMap["type"] == "table"
wasReactTable := panelMap["table"] == "table2"
if wasAngularTable && panelMap["styles"] == nil {
continue
}
if !wasAngularTable || wasReactTable {
continue
}
// The grafana version that matches the hardcoded autoMigrate plugins
panelMap["pluginVersion"] = pluginVersionForAutoMigrate
err := tablePanelChangedHandler(panelMap)
if err != nil {
return err
}
}
return nil
}
func tablePanelChangedHandler(panel map[string]interface{}) error {
prevOptions := getOptionsToRemember(panel)
transformations := migrateTransformations(panel, prevOptions)
prevDefaults := findDefaultStyle(prevOptions)
defaults := migrateDefaults(prevDefaults)
overrides := findNonDefaultStyles(prevOptions)
if len(overrides) == 0 {
overrides = []interface{}{}
}
// Only add transformations if they're not empty - frontend omits empty arrays
if len(transformations) > 0 {
panel["transformations"] = transformations
}
panel["fieldConfig"] = map[string]interface{}{
"defaults": defaults,
"overrides": overrides,
}
// Add minimal table panel options to match frontend behavior
// Frontend doesn't add default footer options, so we don't either
panel["options"] = map[string]interface{}{
"cellHeight": "sm",
"showHeader": true,
}
// Remove deprecated properties
delete(panel, "styles")
delete(panel, "transform")
delete(panel, "columns")
// Remove legend property - frontend table panel migration doesn't preserve it
delete(panel, "legend")
return nil
}
// findDefaultStyle finds the style with pattern '/.*/' (default style)
func findDefaultStyle(prevOptions map[string]interface{}) map[string]interface{} {
if styles, ok := prevOptions["styles"].([]interface{}); ok {
for _, style := range styles {
if styleMap, ok := style.(map[string]interface{}); ok {
if pattern, ok := styleMap["pattern"].(string); ok && pattern == "/.*/" {
return styleMap
}
}
}
}
return nil
}
// findNonDefaultStyles finds all styles that don't have pattern '/.*/'
func findNonDefaultStyles(prevOptions map[string]interface{}) []interface{} {
var overrides []interface{}
if styles, ok := prevOptions["styles"].([]interface{}); ok {
for _, style := range styles {
if styleMap, ok := style.(map[string]interface{}); ok {
if pattern, ok := styleMap["pattern"].(string); ok && pattern != "/.*/" {
override := migrateTableStyleToOverride(styleMap)
overrides = append(overrides, override)
}
}
}
}
return overrides
}
// migrateTransformations converts old table transformations to new format
func migrateTransformations(panel map[string]interface{}, oldOpts map[string]interface{}) []interface{} {
transformations := []interface{}{}
if existing, ok := panel["transformations"].([]interface{}); ok {
transformations = existing
}
// Check if oldOpts has a transform that we can map
if transform, ok := oldOpts["transform"].(string); ok {
if newTransformID, exists := transformsMap[transform]; exists {
opts := map[string]interface{}{
"reducers": []interface{}{},
}
// Handle timeseries_aggregations specifically
if transform == "timeseries_aggregations" {
opts["includeTimeField"] = false
// Map columns to reducers
if columns, ok := oldOpts["columns"].([]interface{}); ok {
var reducers []interface{}
for _, column := range columns {
if columnMap, ok := column.(map[string]interface{}); ok {
if value, ok := columnMap["value"].(string); ok {
if reducer, exists := columnsMap[value]; exists {
reducers = append(reducers, reducer)
}
}
}
}
opts["reducers"] = reducers
}
}
// Add the transformation
transformation := map[string]interface{}{
"id": newTransformID,
"options": opts,
}
transformations = append(transformations, transformation)
}
}
return transformations
}
// transformsMap maps old transform names to new transformation IDs
var transformsMap = map[string]string{
"timeseries_to_rows": "seriesToRows",
"timeseries_to_columns": "seriesToColumns",
"timeseries_aggregations": "reduce",
"table": "merge",
}
// columnsMap maps old column values to new reducer names
var columnsMap = map[string]string{
"avg": "mean",
"min": "min",
"max": "max",
"total": "sum",
"current": "lastNotNull",
"count": "count",
}
// migrateTableStyleToOverride converts a table style to a field config override
func migrateTableStyleToOverride(style map[string]interface{}) map[string]interface{} {
pattern, _ := style["pattern"].(string)
// Determine field matcher ID based on pattern
fieldMatcherID := "byName"
if pattern != "" && len(pattern) >= 2 && pattern[0] == '/' && pattern[len(pattern)-1] == '/' {
fieldMatcherID = "byRegexp"
}
override := map[string]interface{}{
"matcher": map[string]interface{}{
"id": fieldMatcherID,
"options": pattern,
},
"properties": []interface{}{},
}
properties := override["properties"].([]interface{})
// Add display name
if alias, ok := style["alias"].(string); ok && alias != "" {
properties = append(properties, map[string]interface{}{
"id": "displayName",
"value": alias,
})
}
// Add unit
if unit, ok := style["unit"].(string); ok && unit != "" {
properties = append(properties, map[string]interface{}{
"id": "unit",
"value": unit,
})
}
// Add decimals
if decimals := GetIntValue(style, "decimals", -1); decimals != -1 {
properties = append(properties, map[string]interface{}{
"id": "decimals",
"value": decimals,
})
}
// Handle date type
if styleType, ok := style["type"].(string); ok && styleType == "date" {
if dateFormat, ok := style["dateFormat"].(string); ok {
properties = append(properties, map[string]interface{}{
"id": "unit",
"value": "time: " + dateFormat,
})
}
}
// Handle hidden type
if styleType, ok := style["type"].(string); ok && styleType == "hidden" {
properties = append(properties, map[string]interface{}{
"id": "custom.hideFrom.viz",
"value": true,
})
}
// Handle links
if link, ok := style["link"].(bool); ok && link {
linkTooltip, _ := style["linkTooltip"].(string)
linkUrl, _ := style["linkUrl"].(string)
linkTargetBlank, _ := style["linkTargetBlank"].(bool)
properties = append(properties, map[string]interface{}{
"id": "links",
"value": []interface{}{
map[string]interface{}{
"title": linkTooltip,
"url": linkUrl,
"targetBlank": linkTargetBlank,
},
},
})
}
// Handle color mode
if colorMode, ok := style["colorMode"].(string); ok && colorMode != "" {
if newColorMode, exists := colorModeMap[colorMode]; exists {
properties = append(properties, map[string]interface{}{
"id": "custom.cellOptions",
"value": map[string]interface{}{
"type": newColorMode,
},
})
}
}
// Handle alignment
if align, ok := style["align"].(string); ok && align != "" {
var alignValue interface{}
if align == "auto" {
alignValue = nil // Frontend sets to null and filters it out
} else {
alignValue = align
}
properties = append(properties, map[string]interface{}{
"id": "custom.align",
"value": alignValue,
})
}
// Handle thresholds
if thresholds, ok := style["thresholds"].([]interface{}); ok && len(thresholds) > 0 {
if colors, ok := style["colors"].([]interface{}); ok && len(colors) > 0 {
steps := generateThresholds(thresholds, colors)
properties = append(properties, map[string]interface{}{
"id": "thresholds",
"value": map[string]interface{}{
"mode": "absolute",
"steps": steps,
},
})
}
}
override["properties"] = properties
return override
}
// migrateDefaults converts default table styles to field config defaults
func migrateDefaults(prevDefaults map[string]interface{}) map[string]interface{} {
defaults := map[string]interface{}{
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
},
"mappings": []interface{}{},
}
// Add default thresholds for all table panels to match frontend behavior
// The frontend applies the table panel's default field config which includes thresholds
hasThresholds := false
if prevDefaults != nil {
if thresholds, ok := prevDefaults["thresholds"].([]interface{}); ok && len(thresholds) > 0 {
hasThresholds = true
}
}
// Add default thresholds for all table panels (when prevDefaults exists) without existing thresholds
if !hasThresholds {
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green", "value": (*float64)(nil)},
map[string]interface{}{"color": "red", "value": 80},
},
}
}
if prevDefaults == nil {
return defaults
}
if unit := GetStringValue(prevDefaults, "unit"); unit != "" {
defaults["unit"] = unit
}
if decimals := GetIntValue(prevDefaults, "decimals", -1); decimals != -1 {
defaults["decimals"] = decimals
}
if alias, ok := prevDefaults["alias"].(string); ok {
defaults["displayName"] = alias
}
if align, ok := prevDefaults["align"].(string); ok && align != "" {
var alignValue interface{}
if align == "auto" {
alignValue = nil // Frontend sets to null and filters it out
} else {
alignValue = align
}
defaults["custom"].(map[string]interface{})["align"] = alignValue
}
if thresholds, ok := prevDefaults["thresholds"].([]interface{}); ok && len(thresholds) > 0 {
if colors, ok := prevDefaults["colors"].([]interface{}); ok && len(colors) > 0 {
steps := generateThresholds(thresholds, colors)
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": steps,
}
}
}
if colorMode, ok := prevDefaults["colorMode"].(string); ok && colorMode != "" {
if newColorMode, exists := colorModeMap[colorMode]; exists {
defaults["custom"].(map[string]interface{})["cellOptions"] = map[string]interface{}{
"type": newColorMode,
}
}
}
return defaults
}
func generateThresholds(thresholds []interface{}, colors []interface{}) []interface{} {
steps := []interface{}{}
// Add the base step (equivalent to -Infinity)
var baseColor interface{} = "red" // default fallback
if len(colors) > 0 && colors[0] != nil {
baseColor = colors[0]
}
steps = append(steps, map[string]interface{}{
"color": baseColor,
"value": (*float64)(nil),
})
// Add threshold steps
for i, threshold := range thresholds {
var value float64
switch v := threshold.(type) {
case string:
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
value = parsed
}
case float64:
value = v
case int:
value = float64(v)
}
step := map[string]interface{}{
"value": value,
}
// Only add color if there's a corresponding color in the colors array
// This matches the frontend behavior where colors[idx] might be undefined
if i+1 < len(colors) && colors[i+1] != nil {
step["color"] = colors[i+1]
}
steps = append(steps, step)
}
return steps
}
var colorModeMap = map[string]string{
"cell": "color-background",
"row": "color-background",
"value": "color-text",
}