Dashboard Migrations: v25 & v24; migrate angular table (#108826)

* Dashboard Migrations: V31 LabelsToFields-Merge Migration

* Dashboard Migrations: V32 No-op migration

* simplify

* Refactor to reduce nesting

* Dashboard Migrations: V30 value mappings and tooltip options

* Do not automigrate since graph is migrated in v27

* Refactor to reduce nesting

* Add test case for invalid mapping

* migrate to v29

* wip

* Fix tests

* fix output

* wip

* fix min version issue

* fix wire

* ignore gauge logic as it never get's executed

* add panel migration to test

* improvements

* update

* cleanup

* address mappings inconsistencies

* cleanup

* fix lint issues

* add cfg when initializing

* v27 migration

* migrate to v26

* preallocate array

* remove logic for grafana-singlestat because it's shared with stat logic; improve error handling and testing

* fix go lint

* don't preallocate; cleanup comments

* cleanup

* wip

* run internal provider function when getting a single panel

* clean up; add tests

* add tests for panel plugin service

* remove obsolete mock for getting panel plugin

* add tests for the whole pipeline

* fix test and lint

* fix test

* Fix missing scenarios

---------

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
Haris Rozajac
2025-08-07 14:14:39 -06:00
committed by GitHub
co-authored by Ivan Ortega
parent 3920b25aee
commit 6ca8c6c6da
16 changed files with 3615 additions and 8 deletions
@@ -0,0 +1,89 @@
package schemaversion
var notPersistedProperties = []string{
"events",
"isViewing",
"isEditing",
"isInView",
"hasRefreshed",
"cachedPluginOptions",
"plugin",
"queryRunner",
"replaceVariables",
"configRev",
"hasSavedPanelEditChange",
"getDisplayTitle",
"dataSupport",
"key",
"isNew",
"refreshWhenInView",
}
var mustKeepProperties = []string{
"id",
"gridPos",
"type",
"title",
"scopedVars",
"repeat",
"repeatPanelId",
"repeatDirection",
"repeatedByRow",
"minSpan",
"collapsed",
"panels",
"targets",
"datasource",
"timeFrom",
"timeShift",
"hideTimeOverride",
"description",
"links",
"fullscreen",
"isEditing",
"isViewing",
"hasRefreshed",
"events",
"cacheTimeout",
"queryCachingTTL",
"cachedPluginOptions",
"transparent",
"pluginVersion",
"queryRunner",
"transformations",
"fieldConfig",
"maxDataPoints",
"interval",
"replaceVariables",
"libraryPanel",
"getDisplayTitle",
"configRev",
"key",
}
// getOptionsToRemember returns a map of panel properties that should be remembered
// during panel type changes, excluding notPersistedProperties and mustKeepProperties
func getOptionsToRemember(panel map[string]interface{}) map[string]interface{} {
// Create sets for faster lookup
notPersistedSet := make(map[string]bool)
for _, prop := range notPersistedProperties {
notPersistedSet[prop] = true
}
mustKeepSet := make(map[string]bool)
for _, prop := range mustKeepProperties {
mustKeepSet[prop] = true
}
// Filter the panel properties
result := make(map[string]interface{})
for key, value := range panel {
// Skip properties that are in notPersistedProperties or mustKeepProperties
if notPersistedSet[key] || mustKeepSet[key] {
continue
}
result[key] = value
}
return result
}
@@ -5,7 +5,7 @@ import (
)
const (
MIN_VERSION = 25
MIN_VERSION = 23
LATEST_VERSION = 41
)
@@ -38,6 +38,8 @@ type PanelPluginInfoProvider interface {
func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc {
return map[int]SchemaVersionMigrationFunc{
24: V24(panelProvider),
25: V25,
26: V26,
27: V27,
28: V28(panelProvider),
@@ -0,0 +1,631 @@
package schemaversion
import (
"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": "1.0.0"
// }
// ]
// }
// 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.hidden", "value": true }
// ]
// }
// ]
// },
// "transformations": [
// {
// "id": "reduce",
// "options": {
// "reducers": ["mean", "max"],
// "includeTimeField": false
// }
// }
// ],
// "targets": [{ "refId": "A" }],
// "pluginVersion": "1.0.0"
// }
// ]
// }
type v24Migrator struct {
panelProvider PanelPluginInfoProvider
panelPlugins []PanelPluginInfo
}
func V24(panelProvider PanelPluginInfoProvider) SchemaVersionMigrationFunc {
migrator := &v24Migrator{
panelProvider: panelProvider,
panelPlugins: panelProvider.GetPanels(),
}
return migrator.migrate
}
func (m *v24Migrator) migrate(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
}
// Find if the panel plugin exists
tablePanelPlugin := m.panelProvider.GetPanelPlugin("table")
if tablePanelPlugin.ID == "" {
return NewMigrationError("table panel plugin not found when migrating dashboard to schema version 24", 24, LATEST_VERSION)
}
panelMap["pluginVersion"] = tablePanelPlugin.Version
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{}{}
}
panel["transformations"] = transformations
panel["fieldConfig"] = map[string]interface{}{
"defaults": defaults,
"overrides": overrides,
}
// Add default table panel options to match frontend behavior
panel["options"] = map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
}
// Remove deprecated properties
delete(panel, "styles")
delete(panel, "transform")
delete(panel, "columns")
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, ok := style["decimals"].(float64); ok {
properties = append(properties, map[string]interface{}{
"id": "decimals",
"value": int(decimals),
})
} else if decimals, ok := style["decimals"].(int); ok {
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.hidden",
"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 != "" {
alignValue := align
if align == "auto" {
alignValue = ""
}
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,
},
"mappings": []interface{}{},
}
// Only add default thresholds if we have prevDefaults (meaning this is a table panel being migrated)
// and no specific thresholds exist in prevDefaults
hasThresholds := false
if prevDefaults != nil {
if thresholds, ok := prevDefaults["thresholds"].([]interface{}); ok && len(thresholds) > 0 {
hasThresholds = true
}
// Only add default thresholds for table panels (when prevDefaults exists) without existing thresholds
if !hasThresholds {
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green"},
map[string]interface{}{"color": "red", "value": 80},
},
}
}
}
if prevDefaults == nil {
return defaults
}
if unit, ok := prevDefaults["unit"].(string); ok && unit != "" {
defaults["unit"] = unit
}
if decimals, ok := prevDefaults["decimals"].(float64); ok {
defaults["decimals"] = int(decimals)
}
if alias, ok := prevDefaults["alias"].(string); ok && alias != "" {
defaults["displayName"] = alias
}
if align, ok := prevDefaults["align"].(string); ok && align != "" {
alignValue := align
if align == "auto" {
alignValue = ""
}
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": nil,
})
// Add threshold steps
for i, threshold := range thresholds {
var color interface{}
// Use colors[i+1] for the i-th threshold (colors[0] was used for base step)
if i+1 < len(colors) && colors[i+1] != nil {
color = colors[i+1]
} else {
color = "red"
}
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)
}
steps = append(steps, map[string]interface{}{
"color": color,
"value": value,
})
}
return steps
}
var colorModeMap = map[string]string{
"cell": "color-background",
"row": "color-background",
"value": "color-text",
}
@@ -0,0 +1,823 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil"
)
func TestV24(t *testing.T) {
tests := []migrationTestCase{
{
name: "should migrate basic Angular table with defaults",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "table",
"title": "Basic Table",
"legend": true,
"styles": []interface{}{
map[string]interface{}{
"thresholds": []interface{}{"10", "20", "30"},
"colors": []interface{}{"red", "yellow", "green"},
"pattern": "/.*/",
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "table",
"title": "Basic Table",
"legend": true,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"value": nil, "color": "red"},
map[string]interface{}{"value": float64(10), "color": "yellow"},
map[string]interface{}{"value": float64(20), "color": "green"},
map[string]interface{}{"value": float64(30), "color": "red"},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should migrate table with complex defaults and overrides",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 2,
"type": "table",
"title": "Complex Table",
"styles": []interface{}{
// Default style
map[string]interface{}{
"pattern": "/.*/",
"unit": "bytes",
"decimals": float64(2),
"align": "center",
"colorMode": "cell",
"thresholds": []interface{}{"100", "500"},
"colors": []interface{}{"green", "yellow", "red"},
},
// Column-specific override with exact name
map[string]interface{}{
"pattern": "Status",
"alias": "Current Status",
"unit": "short",
"decimals": float64(0),
"colorMode": "value",
"align": "left",
},
// Column-specific override with regex pattern
map[string]interface{}{
"pattern": "/Error.*/",
"link": true,
"linkUrl": "http://example.com/errors",
"linkTooltip": "View error details",
"linkTargetBlank": true,
"colorMode": "row",
"colors": []interface{}{"red", "orange"},
},
// Date column
map[string]interface{}{
"pattern": "Time",
"type": "date",
"dateFormat": "YYYY-MM-DD HH:mm:ss",
"alias": "Timestamp",
},
// Hidden column
map[string]interface{}{
"pattern": "Hidden",
"type": "hidden",
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 2,
"type": "table",
"title": "Complex Table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "bytes",
"decimals": 2,
"custom": map[string]interface{}{
"align": "center",
"cellOptions": map[string]interface{}{
"type": "color-background",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"value": nil, "color": "green"},
map[string]interface{}{"value": float64(100), "color": "yellow"},
map[string]interface{}{"value": float64(500), "color": "red"},
},
},
},
"overrides": []interface{}{
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Status",
},
"properties": []interface{}{
map[string]interface{}{"id": "displayName", "value": "Current Status"},
map[string]interface{}{"id": "unit", "value": "short"},
map[string]interface{}{"id": "decimals", "value": 0},
map[string]interface{}{"id": "custom.cellOptions", "value": map[string]interface{}{"type": "color-text"}},
map[string]interface{}{"id": "custom.align", "value": "left"},
},
},
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byRegexp",
"options": "/Error.*/",
},
"properties": []interface{}{
map[string]interface{}{
"id": "links",
"value": []interface{}{
map[string]interface{}{
"title": "View error details",
"url": "http://example.com/errors",
"targetBlank": true,
},
},
},
map[string]interface{}{"id": "custom.cellOptions", "value": map[string]interface{}{"type": "color-background"}},
},
},
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Time",
},
"properties": []interface{}{
map[string]interface{}{"id": "displayName", "value": "Timestamp"},
map[string]interface{}{"id": "unit", "value": "time: YYYY-MM-DD HH:mm:ss"},
},
},
map[string]interface{}{
"matcher": map[string]interface{}{
"id": "byName",
"options": "Hidden",
},
"properties": []interface{}{
map[string]interface{}{"id": "custom.hidden", "value": true},
},
},
},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should migrate table with timeseries_aggregations transform",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 3,
"type": "table",
"title": "Table with Aggregations",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"unit": "percent",
"decimals": float64(1),
},
},
"transform": "timeseries_aggregations",
"columns": []interface{}{
map[string]interface{}{"value": "avg", "text": "Average"},
map[string]interface{}{"value": "max", "text": "Maximum"},
map[string]interface{}{"value": "min", "text": "Minimum"},
map[string]interface{}{"value": "total", "text": "Total"},
map[string]interface{}{"value": "current", "text": "Current"},
map[string]interface{}{"value": "count", "text": "Count"},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 3,
"type": "table",
"title": "Table with Aggregations",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "percent",
"decimals": 1,
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green"},
map[string]interface{}{"color": "red", "value": 80},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{
map[string]interface{}{
"id": "reduce",
"options": map[string]interface{}{
"reducers": []interface{}{"mean", "max", "min", "sum", "lastNotNull", "count"},
"includeTimeField": false,
},
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should migrate table with timeseries_to_rows transform",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 4,
"type": "table",
"title": "Table with Rows Transform",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"unit": "short",
},
},
"transform": "timeseries_to_rows",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 4,
"type": "table",
"title": "Table with Rows Transform",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green"},
map[string]interface{}{"color": "red", "value": 80},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{
map[string]interface{}{
"id": "seriesToRows",
"options": map[string]interface{}{
"reducers": []interface{}{},
},
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should migrate table with timeseries_to_columns transform",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 5,
"type": "table",
"title": "Table with Columns Transform",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"unit": "bytes",
},
},
"transform": "timeseries_to_columns",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 5,
"type": "table",
"title": "Table with Columns Transform",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "bytes",
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green"},
map[string]interface{}{"color": "red", "value": 80},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{
map[string]interface{}{
"id": "seriesToColumns",
"options": map[string]interface{}{
"reducers": []interface{}{},
},
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should migrate table with table merge transform",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 6,
"type": "table",
"title": "Table with Merge Transform",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"align": "auto",
},
},
"transform": "table",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 6,
"type": "table",
"title": "Table with Merge Transform",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"align": "",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green"},
map[string]interface{}{"color": "red", "value": 80},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{
map[string]interface{}{
"id": "merge",
"options": map[string]interface{}{
"reducers": []interface{}{},
},
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should migrate table with existing transformations",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 7,
"type": "table",
"title": "Table with Existing Transformations",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"unit": "short",
},
},
"transformations": []interface{}{
map[string]interface{}{
"id": "filterFieldsByName",
"options": map[string]interface{}{
"include": map[string]interface{}{
"names": []interface{}{"field1", "field2"},
},
},
},
},
"transform": "timeseries_to_rows",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 7,
"type": "table",
"title": "Table with Existing Transformations",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"color": "green"},
map[string]interface{}{"color": "red", "value": 80},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{
map[string]interface{}{
"id": "filterFieldsByName",
"options": map[string]interface{}{
"include": map[string]interface{}{
"names": []interface{}{"field1", "field2"},
},
},
},
map[string]interface{}{
"id": "seriesToRows",
"options": map[string]interface{}{
"reducers": []interface{}{},
},
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
{
name: "should not migrate angular table without styles",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 8,
"type": "table",
"title": "Table without styles",
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 8,
"type": "table",
"title": "Table without styles",
},
},
},
},
{
name: "should not migrate react table (table2)",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 9,
"type": "table",
"table": "table2",
"title": "React table",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"unit": "short",
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 9,
"type": "table",
"table": "table2",
"title": "React table",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"unit": "short",
},
},
},
},
},
},
{
name: "should not migrate non-table panels",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 10,
"type": "graph",
"title": "Graph panel",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
map[string]interface{}{
"id": 11,
"type": "singlestat",
"title": "Singlestat panel",
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 10,
"type": "graph",
"title": "Graph panel",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
map[string]interface{}{
"id": 11,
"type": "singlestat",
"title": "Singlestat panel",
},
},
},
},
{
name: "should handle mixed numeric and string thresholds",
input: map[string]interface{}{
"schemaVersion": 23,
"panels": []interface{}{
map[string]interface{}{
"id": 12,
"type": "table",
"title": "Mixed threshold types",
"styles": []interface{}{
map[string]interface{}{
"pattern": "/.*/",
"thresholds": []interface{}{10, "20", 30.5},
"colors": []interface{}{"green", "yellow", "orange", "red"},
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"id": 12,
"type": "table",
"title": "Mixed threshold types",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"align": "auto",
"cellOptions": map[string]interface{}{
"type": "auto",
},
"inspect": false,
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{"value": nil, "color": "green"},
map[string]interface{}{"value": float64(10), "color": "yellow"},
map[string]interface{}{"value": float64(20), "color": "orange"},
map[string]interface{}{"value": float64(30.5), "color": "red"},
},
},
},
"overrides": []interface{}{},
},
"options": map[string]interface{}{
"cellHeight": "sm",
"footer": map[string]interface{}{
"countRows": false,
"fields": "",
"reducer": []interface{}{"sum"},
"show": false,
},
"showHeader": true,
},
"transformations": []interface{}{},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
"pluginVersion": "1.0.0",
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V24(testutil.GetTestPanelProvider()))
}
@@ -0,0 +1,49 @@
package schemaversion
// V25 migration is a no-op migration
// It only updates the schema version to 25
// It's created to keep the migration history consistent with frontend migrator
// Variable tag removal is handled in v28 migration
// Example before migration:
// {
// "templating": {
// "list": [
// {
// "name": "tags should not be removed",
// "type": "query",
// "datasource": "prometheus",
// "tags": ["tags should not be removed"],
// "tagsQuery": "tag should not be removed",
// "tagValuesQuery": "tag should not be removed",
// "useTags": true,
// "options": []
// }
// ]
// }
// }
// Example after migration:
// {
// "templating": {
// "list": [
// {
// "name": "tags should not be removed",
// "type": "query",
// "datasource": "prometheus",
// "tags": ["tags should not be removed"],
// "tagsQuery": "tag should not be removed",
// "tagValuesQuery": "tag should not be removed",
// "useTags": true,
// "options": []
// }
// ]
// }
// }
func V25(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(25)
return nil
}
@@ -0,0 +1,38 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV25(t *testing.T) {
tests := []migrationTestCase{
{
name: "v25 no-op migration, updates schema version only",
input: map[string]interface{}{
"title": "V25 No-Op Migration Test Dashboard",
"schemaVersion": 24,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel remains unchanged",
"id": 1,
},
},
},
expected: map[string]interface{}{
"title": "V25 No-Op Migration Test Dashboard",
"schemaVersion": 25,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel remains unchanged",
"id": 1,
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V25)
}
@@ -258,6 +258,24 @@ func (m *v28Migrator) migratetSinglestat(panel map[string]interface{}, defaults
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
m.migrateThresholds(angularOpts, defaults)
// If no thresholds were set from angular migration, add default stat panel thresholds
// This matches the behavior of frontend pluginLoaded which adds default thresholds
if _, hasThresholds := defaults["thresholds"]; !hasThresholds {
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
}
}
// Apply common angular option migrations (value mappings can now use threshold colors)
m.applyCommonAngularMigration(panel, defaults, options, angularOpts)
@@ -298,6 +316,24 @@ func (m *v28Migrator) migrateGrafanaSinglestatPanel(panel map[string]interface{}
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
m.migrateThresholds(angularOpts, defaults)
// If no thresholds were set from angular migration, add default stat panel thresholds
// This matches the behavior of frontend pluginLoaded which adds default thresholds
if _, hasThresholds := defaults["thresholds"]; !hasThresholds {
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
}
}
// Apply common angular option migrations (value mappings can now use threshold colors)
m.applyCommonAngularMigration(panel, defaults, options, angularOpts)
@@ -160,6 +160,19 @@ func TestV28(t *testing.T) {
"fixedColor": "#ff0000",
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
},
},
"overrides": []interface{}{},
},
@@ -228,6 +241,19 @@ func TestV28(t *testing.T) {
"fixedColor": "#ff0000",
},
"mappings": []interface{}{},
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
},
},
"overrides": []interface{}{},
},
@@ -512,7 +538,20 @@ func TestV28(t *testing.T) {
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"unit": "short",
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
},
"mappings": []interface{}{},
},
"overrides": []interface{}{},
@@ -564,7 +603,20 @@ func TestV28(t *testing.T) {
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"unit": "short",
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
},
"mappings": []interface{}{},
},
"overrides": []interface{}{},
@@ -626,7 +678,20 @@ func TestV28(t *testing.T) {
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "bytes",
"unit": "bytes",
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
},
"mappings": []interface{}{},
},
"overrides": []interface{}{},