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
+3
View File
@@ -2153,6 +2153,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "26"],
[0, 0, 0, "Unexpected any. Specify a different type.", "27"]
],
"public/app/features/dashboard/state/DashboardMigratorToBackend.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard/state/DashboardModel.repeat.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@@ -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{}{},
@@ -0,0 +1,345 @@
{
"schemaVersion": 23,
"panels": [
{
"id": 1,
"type": "table",
"title": "Basic Angular Table with Defaults",
"description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.",
"legend": true,
"styles": [
{
"thresholds": ["10", "20", "30"],
"colors": ["red", "yellow", "green"],
"pattern": "/.*/"
}
],
"targets": [{ "refId": "A" }, {}]
},
{
"id": 2,
"type": "table",
"title": "Complex Table with All Style Features",
"description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.",
"styles": [
{
"pattern": "/.*/",
"thresholds": ["100", "500"],
"colors": ["green", "yellow", "red"],
"unit": "bytes",
"decimals": 2,
"align": "center",
"colorMode": "cell"
},
{
"pattern": "Status",
"alias": "Current Status",
"unit": "short",
"decimals": 0,
"colorMode": "value",
"align": "left"
},
{
"pattern": "/Error.*/",
"link": true,
"linkUrl": "http://example.com/errors",
"linkTooltip": "View error details",
"linkTargetBlank": true,
"colorMode": "row"
},
{
"pattern": "Time",
"type": "date",
"dateFormat": "YYYY-MM-DD HH:mm:ss",
"alias": "Timestamp"
},
{
"pattern": "Hidden",
"type": "hidden"
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 3,
"type": "table",
"title": "Table with Timeseries Aggregations Transform",
"description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg->mean, max->max, min->min, total->sum, current->lastNotNull, count->count).",
"styles": [
{
"pattern": "/.*/",
"unit": "percent",
"decimals": 1
}
],
"transform": "timeseries_aggregations",
"columns": [
{ "value": "avg", "text": "Average" },
{ "value": "max", "text": "Maximum" },
{ "value": "min", "text": "Minimum" },
{ "value": "total", "text": "Total" },
{ "value": "current", "text": "Current" },
{ "value": "count", "text": "Count" }
],
"targets": [{ "refId": "A" }]
},
{
"id": 4,
"type": "table",
"title": "Table with Timeseries to Rows Transform",
"description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.",
"styles": [
{
"pattern": "/.*/",
"unit": "short"
}
],
"transform": "timeseries_to_rows",
"targets": [{ "refId": "A" }]
},
{
"id": 5,
"type": "table",
"title": "Table with Timeseries to Columns Transform",
"description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.",
"styles": [
{
"pattern": "/.*/",
"unit": "bytes"
}
],
"transform": "timeseries_to_columns",
"targets": [{ "refId": "A" }]
},
{
"id": 6,
"type": "table",
"title": "Table with Merge Transform",
"description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.",
"styles": [
{
"pattern": "/.*/",
"align": "auto"
}
],
"transform": "table",
"targets": [{ "refId": "A" }]
},
{
"id": 7,
"type": "table",
"title": "Table with Existing Transformations",
"description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.",
"styles": [
{
"pattern": "/.*/",
"unit": "short"
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": {
"names": ["field1", "field2"]
}
}
}
],
"transform": "timeseries_to_rows",
"targets": [{ "refId": "A" }]
},
{
"id": 8,
"type": "table",
"title": "Mixed Threshold Types",
"description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.",
"styles": [
{
"pattern": "/.*/",
"thresholds": [10, "20", 30.5],
"colors": ["green", "yellow", "orange", "red"]
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 9,
"type": "table",
"title": "All Color Modes Test",
"description": "Tests all color mode mappings: cell->color-background, row->color-background, value->color-text.",
"styles": [
{
"pattern": "/.*/",
"colorMode": "cell"
},
{
"pattern": "CellColumn",
"colorMode": "cell"
},
{
"pattern": "RowColumn",
"colorMode": "row"
},
{
"pattern": "ValueColumn",
"colorMode": "value"
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 10,
"type": "table",
"title": "All Alignment Options",
"description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).",
"styles": [
{
"pattern": "/.*/",
"align": "center"
},
{
"pattern": "LeftColumn",
"align": "left"
},
{
"pattern": "RightColumn",
"align": "right"
},
{
"pattern": "AutoColumn",
"align": "auto"
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 11,
"type": "table",
"title": "Field Matcher Types Test",
"description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.",
"styles": [
{
"pattern": "/.*/",
"unit": "short"
},
{
"pattern": "ExactColumnName",
"alias": "Exact Match"
},
{
"pattern": "/Regex.*Pattern/",
"alias": "Regex Match"
},
{
"pattern": "/^Start/",
"alias": "Start Pattern"
},
{
"pattern": "/End$/",
"alias": "End Pattern"
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 12,
"type": "table",
"title": "Link Configuration Test",
"description": "Tests various link configurations: with and without tooltip, with and without target blank.",
"styles": [
{
"pattern": "/.*/",
"unit": "short"
},
{
"pattern": "LinkWithTooltip",
"link": true,
"linkUrl": "http://example.com/with-tooltip",
"linkTooltip": "Click to view details",
"linkTargetBlank": true
},
{
"pattern": "LinkWithoutTooltip",
"link": true,
"linkUrl": "http://example.com/no-tooltip",
"linkTargetBlank": false
},
{
"pattern": "LinkMinimal",
"link": true,
"linkUrl": "http://example.com/minimal"
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 13,
"type": "table",
"title": "Date Format Variations",
"description": "Tests various date format patterns and aliases.",
"styles": [
{
"pattern": "/.*/",
"unit": "short"
},
{
"pattern": "DateISO",
"type": "date",
"dateFormat": "YYYY-MM-DD",
"alias": "ISO Date"
},
{
"pattern": "DateTime",
"type": "date",
"dateFormat": "YYYY-MM-DD HH:mm:ss",
"alias": "Full DateTime"
},
{
"pattern": "TimeOnly",
"type": "date",
"dateFormat": "HH:mm:ss",
"alias": "Time Only"
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 14,
"type": "table",
"title": "React Table - Should NOT Migrate",
"description": "React table (table2) should not be migrated. Properties should remain unchanged.",
"table": "table2",
"styles": [
{
"pattern": "/.*/",
"unit": "short",
"thresholds": ["10", "20"],
"colors": ["red", "yellow", "green"]
}
],
"targets": [{ "refId": "A" }]
},
{
"id": 15,
"type": "table",
"title": "Angular Table without Styles - Should NOT Migrate",
"description": "Angular table without styles property should not be migrated.",
"targets": [{ "refId": "A" }]
},
{
"id": 16,
"type": "graph",
"title": "Non-Table Panel - Should NOT Migrate",
"description": "Non-table panels should remain completely unchanged.",
"targets": [{ "refId": "A" }]
},
{
"id": 17,
"type": "singlestat",
"title": "Singlestat Panel - Should NOT Migrate",
"description": "Other panel types should not be affected by table migration.",
"targets": [{ "refId": "A" }]
}
]
}
@@ -0,0 +1,56 @@
{
"title": "V25 No-Op Migration Test Dashboard",
"schemaVersion": 24,
"panels": [
{
"type": "timeseries",
"title": "Panel with transformations remains unchanged",
"id": 1,
"transformations": [
]
},
{
"type": "graph",
"title": "Graph",
"id": 2,
"yAxes": [
{
"show": true,
"min": null,
"max": null
}
]
}
],
"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": []
}
]
},
"annotations": {
"list": [
{
"name": "Deployments",
"datasource": "grafana",
"enable": true
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
{
"annotations": {
"list": [
{
"datasource": {
"uid": "grafana"
},
"enable": true,
"name": "Deployments"
}
]
},
"panels": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 1,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Panel with transformations remains unchanged",
"transformations": [],
"type": "timeseries"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 2,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Graph",
"type": "graph",
"yAxes": [
{
"max": null,
"min": null,
"show": true
}
]
}
],
"refresh": "",
"schemaVersion": 41,
"templating": {
"list": [
{
"datasource": {
"uid": "prometheus"
},
"name": "tags should not be removed",
"options": [],
"refresh": 1,
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"title": "V25 No-Op Migration Test Dashboard"
}
@@ -78,6 +78,10 @@ func (m *TestPanelProvider) GetPanels() []schemaversion.PanelPluginInfo {
ID: "stat",
Version: "1.0.0",
},
{
ID: "table",
Version: "1.0.0",
},
// Note: grafana-singlestat-panel is not included to match frontend test environment
// This ensures both frontend and backend migrations produce the same result
}
@@ -6,6 +6,7 @@ import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { plugin as statPanelPlugin } from 'app/plugins/panel/stat/module';
import { plugin as tablePanelPlugin } from 'app/plugins/panel/table/module';
import { DASHBOARD_SCHEMA_VERSION } from './DashboardMigrator';
import { DashboardModel } from './DashboardModel';
@@ -141,7 +142,7 @@ describe('Backend / Frontend result comparison', () => {
which means that the actual migration logic is not run.
We need to manually run the pluginLoaded logic to ensure the panels are migrated correctly.
*/
if (jsonInput.schemaVersion === 27) {
if (jsonInput.schemaVersion <= 27) {
for (const panel of frontendModel.panels) {
if (panel.type === 'stat') {
// Set the plugin version if it doesn't exist
@@ -165,6 +166,28 @@ describe('Backend / Frontend result comparison', () => {
await panel.pluginLoaded(statPanelPlugin);
}
if (panel.type === 'table' && panel.autoMigrateFrom === 'table-old') {
// Set the plugin version if it doesn't exist
if (!tablePanelPlugin.meta.info) {
tablePanelPlugin.meta.info = {
author: {
name: 'Grafana Labs',
url: 'url/to/GrafanaLabs',
},
description: 'table plugin',
links: [{ name: 'project', url: 'one link' }],
logos: { small: 'small/logo', large: 'large/logo' },
screenshots: [],
updated: '2024-01-01',
version: '1.0.0',
};
}
if (!tablePanelPlugin.meta.info.version) {
tablePanelPlugin.meta.info.version = '1.0.0';
}
await panel.pluginLoaded(tablePanelPlugin as any);
}
}
}
@@ -176,6 +199,18 @@ describe('Backend / Frontend result comparison', () => {
// Because Go and TS handle -Infinity differently.
const cleanedFrontendResult = sortedDeepCloneWithoutNulls(frontendMigrationResult);
// Remove deprecated angular properties that backend shouldn't return, but DashboardModel will still set them
for (const panel of cleanedFrontendResult.panels ?? []) {
// @ts-expect-error
delete panel.autoMigrateFrom;
// @ts-expect-error
delete panel.styles;
// @ts-expect-error - Backend removes these deprecated table properties
delete panel.transform;
// @ts-expect-error - Backend removes these deprecated table properties
delete panel.columns;
}
expect(backendMigrationResult).toMatchObject(cleanedFrontendResult);
});
});
+3 -3
View File
@@ -136,7 +136,7 @@ const migrateTableStyleToOverride = (style: Style) => {
});
}
if (style.decimals) {
if (style.decimals !== undefined) {
override.properties.push({
id: 'decimals',
value: style.decimals,
@@ -186,7 +186,7 @@ const migrateTableStyleToOverride = (style: Style) => {
});
}
if (style.thresholds?.length) {
if (style.thresholds?.length && style.colors?.length) {
override.properties.push({
id: 'thresholds',
value: {
@@ -216,7 +216,7 @@ const migrateDefaults = (prevDefaults: Style) => {
isNil
);
if (prevDefaults.thresholds.length) {
if (prevDefaults.thresholds && prevDefaults.thresholds.length) {
const thresholds: ThresholdsConfig = {
mode: ThresholdsMode.Absolute,
steps: generateThresholds(prevDefaults.thresholds, prevDefaults.colors),