Dashboard Migrations: V28 singlestat panel and deprecated variable properties (#108416)

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
Haris Rozajac
2025-08-06 17:51:55 +02:00
committed by GitHub
co-authored by Ivan Ortega
parent 3dcda77462
commit 5ad751ea28
22 changed files with 3067 additions and 134 deletions
@@ -5,7 +5,7 @@ import (
)
const (
MIN_VERSION = 28
MIN_VERSION = 27
LATEST_VERSION = 41
)
@@ -24,8 +24,21 @@ type DataSourceInfoProvider interface {
GetDataSourceInfo() []DataSourceInfo
}
func GetMigrations(dsInfoProvider DataSourceInfoProvider) map[int]SchemaVersionMigrationFunc {
type PanelPluginInfo struct {
ID string
Version string
}
type PanelPluginInfoProvider interface {
// Gets all the panels from the plugin store.
// Equivalent to grafanaBootData.settings.panels on the frontend.
GetPanels() []PanelPluginInfo
GetPanelPlugin(id string) PanelPluginInfo
}
func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc {
return map[int]SchemaVersionMigrationFunc{
28: V28(panelProvider),
29: V29,
30: V30,
31: V31,
@@ -57,9 +57,10 @@ func TestGetSchemaVersion(t *testing.T) {
}
type migrationTestCase struct {
name string
input map[string]interface{}
expected map[string]interface{}
name string
input map[string]interface{}
expected map[string]interface{}
expectedError string
}
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
@@ -68,8 +69,13 @@ func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFun
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
err := migrationFunc(tt.input)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
if tt.expectedError != "" {
require.Error(t, err)
require.Equal(t, tt.expectedError, err.Error())
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
}
})
}
}
@@ -0,0 +1,746 @@
package schemaversion
import (
"fmt"
"strconv"
"strings"
)
// V28 migrates singlestat panels to stat/gauge panels and removes deprecated variable properties.
//
// The migration performs two main tasks:
// 1. Migrates singlestat panels to either stat or gauge panels based on their configuration
// 2. Removes deprecated variable properties (tags, tagsQuery, tagValuesQuery, useTags)
//
// The migration includes comprehensive logic from the frontend:
// - Panel type migration (singlestat -> stat/gauge)
// - Field config migration with thresholds, mappings, and display options
// - Options migration including reduceOptions, orientation, and other panel-specific settings
// - Support for both angular singlestat and grafana-singlestat-panel migrations
//
// Example before migration:
//
// "panels": [
// {
// "type": "singlestat",
// "gauge": { "show": true },
// "targets": [{ "refId": "A" }]
// }
// ],
// "templating": {
// "list": [
// { "name": "var1", "tags": ["tag1"], "tagsQuery": "query", "tagValuesQuery": "values", "useTags": true }
// ]
// }
//
// Example after migration:
//
// "panels": [
// {
// "type": "gauge",
// "targets": [{ "refId": "A" }]
// }
// ],
// "templating": {
// "list": [
// { "name": "var1" }
// ]
// }
type v28Migrator struct {
panelProvider PanelPluginInfoProvider
panelPlugins []PanelPluginInfo
statPanelVersion string // Cached stat panel version
}
func V28(panelProvider PanelPluginInfoProvider) SchemaVersionMigrationFunc {
// Get stat panel version once during initialization
statPanelPlugin := panelProvider.GetPanelPlugin("stat")
statPanelVersion := ""
if statPanelPlugin.ID != "" {
statPanelVersion = statPanelPlugin.Version
}
migrator := &v28Migrator{
panelProvider: panelProvider,
panelPlugins: panelProvider.GetPanels(),
statPanelVersion: statPanelVersion,
}
return func(dashboard map[string]interface{}) error {
return migrator.migrate(dashboard)
}
}
func (m *v28Migrator) migrate(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = 28
// Migrate singlestat panels
if panels, ok := dashboard["panels"].([]interface{}); ok {
if err := m.processPanels(panels); err != nil {
return err
}
}
// Remove deprecated variable properties
if templating, ok := dashboard["templating"].(map[string]interface{}); ok {
if list, ok := templating["list"].([]interface{}); ok {
for _, v := range list {
if variable, ok := v.(map[string]interface{}); ok {
removeDeprecatedVariableProperties(variable)
}
}
}
}
return nil
}
func (m *v28Migrator) processPanels(panels []interface{}) error {
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
// Process nested panels if this is a row panel
if p["type"] == "row" {
if nestedPanels, ok := p["panels"].([]interface{}); ok {
if err := m.processPanels(nestedPanels); err != nil {
return err
}
}
continue
}
// Migrate singlestat panels
if p["type"] == "singlestat" || p["type"] == "grafana-singlestat-panel" {
if err := m.migrateSinglestatPanel(p); err != nil {
return err
}
}
// Normalize existing stat panels to ensure they have current default options
if p["type"] == "stat" {
m.normalizeStatPanel(p)
}
}
return nil
}
func (m *v28Migrator) migrateSinglestatPanel(panel map[string]interface{}) error {
targetType := "stat"
// NOTE: DashboardMigrator's migrateSinglestat function has some logic that never gets called
// migrateSinglestat will only run if (panel.type === 'singlestat')
// but this will not be the case because PanelModel runs restoreModel in the constructor
// and since singlestat is in the autoMigrateAngular map, it will be migrated to stat,
// and therefore migrateSinglestat will never run so this logic inside of it will never apply
// if ((panel as any).gauge?.show) {
// gaugePanelPlugin.meta = config.panels['gauge']
// panel.changePlugin(gaugePanelPlugin)
// Store original type for migration context (only for stat/gauge migration)
// This matches the frontend behavior where autoMigrateFrom is set in PanelModel.restoreModel
originalType := panel["type"].(string)
panel["autoMigrateFrom"] = panel["type"]
panel["type"] = targetType
// Use cached stat panel version
if m.statPanelVersion == "" {
return NewMigrationError("stat panel plugin not found when migrating dashboard to schema version 28", 28, LATEST_VERSION)
}
panel["pluginVersion"] = m.statPanelVersion
// Migrate panel options and field config
m.migrateSinglestatOptions(panel, originalType)
return nil
}
// normalizeStatPanel ensures existing stat panels have all current default options
func (m *v28Migrator) normalizeStatPanel(panel map[string]interface{}) {
if panel["options"] == nil {
panel["options"] = map[string]interface{}{}
}
options := panel["options"].(map[string]interface{})
// Apply missing default options that might not be present in older stat panels
if _, exists := options["percentChangeColorMode"]; !exists {
options["percentChangeColorMode"] = "standard"
}
// Ensure other critical defaults are present
if _, exists := options["justifyMode"]; !exists {
options["justifyMode"] = "auto"
}
if _, exists := options["textMode"]; !exists {
options["textMode"] = "auto"
}
if _, exists := options["wideLayout"]; !exists {
options["wideLayout"] = true
}
if _, exists := options["showPercentChange"]; !exists {
options["showPercentChange"] = false
}
}
// migrateSinglestatOptions handles the complete migration of singlestat panel options and field config
func (m *v28Migrator) migrateSinglestatOptions(panel map[string]interface{}, originalType string) {
// Initialize field config if not present
if panel["fieldConfig"] == nil {
panel["fieldConfig"] = map[string]interface{}{
"defaults": map[string]interface{}{},
"overrides": []interface{}{},
}
}
fieldConfig := panel["fieldConfig"].(map[string]interface{})
defaults := fieldConfig["defaults"].(map[string]interface{})
// Migrate from angular singlestat configuration using appropriate strategy
if originalType == "grafana-singlestat-panel" {
m.migrateGrafanaSinglestatPanel(panel, defaults)
} else {
m.migratetSinglestat(panel, defaults)
}
// Apply shared migration logic
m.applySharedSinglestatMigration(defaults)
// Clean up old angular properties after migration
m.cleanupAngularProperties(panel)
}
// getDefaultStatOptions returns the default options structure for stat panels
func (m *v28Migrator) getDefaultStatOptions() map[string]interface{} {
return map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"mean"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
}
}
// migratetSinglestat handles explicit migration from 'singlestat' panels
// Based on explicit migration logic in DashboardMigrator.ts
func (m *v28Migrator) migratetSinglestat(panel map[string]interface{}, defaults map[string]interface{}) {
angularOpts := m.extractAngularOptions(panel)
// Explicit migration uses standard stat panel defaults
options := m.getDefaultStatOptions()
// Explicit migration: always set a reducer with fallback
var valueName string
if vn, ok := angularOpts["valueName"].(string); ok {
valueName = vn
}
if reducer := m.getReducerForValueName(valueName); reducer != "" {
options["reduceOptions"].(map[string]interface{})["calcs"] = []string{reducer}
} else {
// Explicit migration fallback: use mean for invalid reducers
options["reduceOptions"].(map[string]interface{})["calcs"] = []string{"mean"}
}
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
m.migrateThresholds(angularOpts, defaults)
// Apply common angular option migrations (value mappings can now use threshold colors)
m.applyCommonAngularMigration(panel, defaults, options, angularOpts)
panel["options"] = options
}
// migrateGrafanaSinglestatPanel handles auto-migration from 'grafana-singlestat-panel'
// Based on frontend changePlugin() and sharedSingleStatPanelChangedHandler logic
func (m *v28Migrator) migrateGrafanaSinglestatPanel(panel map[string]interface{}, defaults map[string]interface{}) {
angularOpts := m.extractAngularOptions(panel)
// Auto-migration uses different defaults (matches frontend changePlugin behavior)
options := map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"lastNotNull"}, // Auto-migration default
"fields": "",
"values": false,
},
"orientation": "auto", // Auto-migration uses auto
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
}
// Auto-migration: only override if valid, otherwise keep default "lastNotNull"
var valueName string
if vn, ok := angularOpts["valueName"].(string); ok {
valueName = vn
}
if reducer := m.getReducerForValueName(valueName); reducer != "" {
options["reduceOptions"].(map[string]interface{})["calcs"] = []string{reducer}
}
// No fallback - keeps the auto-migration default "lastNotNull"
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
m.migrateThresholds(angularOpts, defaults)
// Apply common angular option migrations (value mappings can now use threshold colors)
m.applyCommonAngularMigration(panel, defaults, options, angularOpts)
panel["options"] = options
}
// migrateThresholds handles threshold migration for both singlestat panel types
// Both panel types now create DEFAULT_THRESHOLDS when threshold string is empty (consolidated behavior)
func (m *v28Migrator) migrateThresholds(angularOpts map[string]interface{}, defaults map[string]interface{}) {
if thresholds, ok := angularOpts["thresholds"].(string); ok {
if colors, ok := angularOpts["colors"].([]interface{}); ok {
if thresholds != "" {
// Non-empty thresholds: use normal migration
m.migrateThresholdsAndColors(defaults, thresholds, colors)
} else {
// Empty thresholds: use frontend DEFAULT_THRESHOLDS fallback (both panel types)
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "red",
"value": 80,
},
},
}
}
}
}
}
// applyCommonAngularMigration applies migrations common to both singlestat types
func (m *v28Migrator) applyCommonAngularMigration(panel map[string]interface{}, defaults map[string]interface{}, options map[string]interface{}, angularOpts map[string]interface{}) {
// Migrate table column
// Based on sharedSingleStatPanelChangedHandler line ~125: options.reduceOptions.fields = `/^${prevPanel.tableColumn}$/`
if tableColumn, ok := angularOpts["tableColumn"].(string); ok && tableColumn != "" {
options["reduceOptions"].(map[string]interface{})["fields"] = "/^" + tableColumn + "$/"
}
// Migrate format to unit
// Based on sharedSingleStatPanelChangedHandler line ~130: defaults.unit = prevPanel.format
if format, ok := angularOpts["format"].(string); ok {
defaults["unit"] = format
}
// Migrate decimals
if decimals, ok := angularOpts["decimals"]; ok {
defaults["decimals"] = decimals
}
// Migrate null point mode
if nullPointMode, ok := angularOpts["nullPointMode"]; ok {
defaults["nullValueMode"] = nullPointMode
}
// Migrate null text
if nullText, ok := angularOpts["nullText"].(string); ok {
defaults["noValue"] = nullText
}
// Migrate value mappings (thresholds should already be migrated)
valueMaps, _ := angularOpts["valueMaps"].([]interface{})
m.migrateValueMappings(angularOpts, defaults, valueMaps)
// Migrate sparkline configuration
// Based on statPanelChangedHandler lines ~25-35: sparkline migration logic
if sparkline, ok := angularOpts["sparkline"].(map[string]interface{}); ok {
if show, ok := sparkline["show"].(bool); ok && show {
options["graphMode"] = "area"
// Handle sparkline color
// Based on statPanelChangedHandler lines ~30-35: sparkline lineColor handling
if lineColor, ok := sparkline["lineColor"].(string); ok {
defaults["color"] = map[string]interface{}{
"mode": "fixed",
"fixedColor": lineColor,
}
}
} else {
options["graphMode"] = "none"
}
} else {
// Default to no graph mode if no sparkline configuration
options["graphMode"] = "none"
}
// Migrate color configuration
// Based on statPanelChangedHandler lines ~35-45: colorBackground and colorValue migration
if colorBackground, ok := angularOpts["colorBackground"].(bool); ok && colorBackground {
options["colorMode"] = "background"
} else if colorValue, ok := angularOpts["colorValue"].(bool); ok && colorValue {
options["colorMode"] = "value"
} else {
options["colorMode"] = "none"
}
// Migrate text mode
// Based on statPanelChangedHandler lines ~45-47: valueName === 'name' migration
if valueName, ok := angularOpts["valueName"].(string); ok && valueName == "name" {
options["textMode"] = "name"
}
if angularOpts["gauge"] != nil && angularOpts["gauge"].(map[string]interface{})["show"] == true {
defaults["min"] = angularOpts["gauge"].(map[string]interface{})["minValue"]
defaults["max"] = angularOpts["gauge"].(map[string]interface{})["maxValue"]
}
}
// applySharedSinglestatMigration applies shared migration logic for all singlestat panels
// Based on sharedSingleStatMigrationHandler in packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts
func (m *v28Migrator) applySharedSinglestatMigration(defaults map[string]interface{}) {
// Ensure thresholds have proper structure
if thresholds, ok := defaults["thresholds"].(map[string]interface{}); ok {
if steps, ok := thresholds["steps"].([]interface{}); ok {
// Ensure first threshold is -Infinity (represented as null in JSON)
if len(steps) > 0 {
if firstStep, ok := steps[0].(map[string]interface{}); ok {
if firstStep["value"] == nil {
firstStep["value"] = nil // Use null instead of -math.Inf(1)
}
}
}
}
}
// Handle percent/percentunit units
// Based on sharedSingleStatMigrationHandler lines ~280-300: percent/percentunit min/max handling
if unit, ok := defaults["unit"].(string); ok {
switch unit {
case "percent":
if defaults["min"] == nil {
defaults["min"] = 0
}
if defaults["max"] == nil {
defaults["max"] = 100
}
case "percentunit":
if defaults["min"] == nil {
defaults["min"] = 0
}
if defaults["max"] == nil {
defaults["max"] = 1
}
}
}
}
// Helper functions
func (m *v28Migrator) extractAngularOptions(panel map[string]interface{}) map[string]interface{} {
// Some panels might have angular options directly in the root
// Check for common angular properties
angularProps := []string{
"valueName", "tableColumn", "format", "decimals", "nullPointMode", "nullText",
"thresholds", "colors", "valueMaps", "gauge", "sparkline", "colorBackground", "colorValue",
}
for _, prop := range angularProps {
if _, exists := panel[prop]; exists {
return panel
}
}
return map[string]interface{}{}
}
// getReducerForValueName returns the mapped reducer or empty string for invalid values
func (m *v28Migrator) getReducerForValueName(valueName string) string {
reducerMap := map[string]string{
"min": "min",
"max": "max",
"mean": "mean",
"median": "median",
"sum": "sum",
"count": "count",
"first": "firstNotNull",
"last": "lastNotNull",
"name": "lastNotNull",
"current": "lastNotNull",
"total": "sum",
}
if reducer, ok := reducerMap[valueName]; ok {
return reducer
}
return ""
}
func (m *v28Migrator) migrateThresholdsAndColors(defaults map[string]interface{}, thresholdsStr string, colors []interface{}) {
// Parse thresholds string (e.g., "10,20,30")
// Based on sharedSingleStatPanelChangedHandler lines ~145-165: Convert thresholds and color values
thresholds := []interface{}{}
thresholdValues := strings.Split(thresholdsStr, ",")
// Create threshold steps
for i, color := range colors {
step := map[string]interface{}{
"color": color,
}
if i == 0 {
step["value"] = nil
} else if i-1 < len(thresholdValues) {
if val, err := strconv.ParseFloat(strings.TrimSpace(thresholdValues[i-1]), 64); err == nil {
step["value"] = val
}
}
thresholds = append(thresholds, step)
}
defaults["thresholds"] = map[string]interface{}{
"mode": "absolute",
"steps": thresholds,
}
}
func (m *v28Migrator) migrateValueMappings(panel map[string]interface{}, defaults map[string]interface{}, valueMappings []interface{}) {
mappings := []interface{}{}
mappingType := panel["mappingType"]
if mappingType == nil {
if panel["valueMaps"] != nil && len(panel["valueMaps"].([]interface{})) > 0 {
mappingType = 1
} else if panel["rangeMaps"] != nil && len(panel["rangeMaps"].([]interface{})) > 0 {
mappingType = 2
}
}
switch mappingType {
case 1:
for _, valueMap := range valueMappings {
valueMapping := valueMap.(map[string]interface{})
upgradedMapping := m.upgradeOldAngularValueMapping(valueMapping, defaults["thresholds"])
if upgradedMapping != nil {
mappings = append(mappings, upgradedMapping)
}
}
case 2:
// Handle range mappings
if rangeMaps, ok := panel["rangeMaps"].([]interface{}); ok {
for _, rangeMap := range rangeMaps {
rangeMapping := rangeMap.(map[string]interface{})
upgradedMapping := m.upgradeOldAngularValueMapping(rangeMapping, defaults["thresholds"])
if upgradedMapping != nil {
mappings = append(mappings, upgradedMapping)
}
}
}
}
defaults["mappings"] = mappings
}
// upgradeOldAngularValueMapping converts old angular value mappings to new format
// Based on upgradeOldAngularValueMapping in packages/grafana-data/src/utils/valueMappings.ts
func (m *v28Migrator) upgradeOldAngularValueMapping(old map[string]interface{}, thresholds interface{}) map[string]interface{} {
valueMaps := map[string]interface{}{
"type": "value",
"options": map[string]interface{}{},
}
newMappings := []interface{}{}
// Use the color we would have picked from thresholds
var color interface{}
if value, ok := old["value"]; ok {
if numeric, err := m.parseNumericValue(value); err == nil {
if thresholdsMap, ok := thresholds.(map[string]interface{}); ok {
if steps, ok := thresholdsMap["steps"].([]interface{}); ok {
level := m.getActiveThreshold(numeric, steps)
if level != nil {
if levelColor, ok := level["color"]; ok {
color = levelColor
}
}
}
}
}
}
// Determine mapping type
mappingType := old["type"]
if mappingType == nil {
// Try to guess from available properties
if old["value"] != nil {
mappingType = 1 // ValueToText
} else if old["from"] != nil || old["to"] != nil {
mappingType = 2 // RangeToText
}
}
switch mappingType {
case 1: // ValueToText
if value, ok := old["value"]; ok && value != nil {
if valueStr, ok := value.(string); ok && valueStr == "null" {
newMappings = append(newMappings, map[string]interface{}{
"type": "special",
"options": map[string]interface{}{
"match": "null",
"result": map[string]interface{}{"text": old["text"], "color": color},
},
})
} else {
valueMaps["options"].(map[string]interface{})[fmt.Sprintf("%v", value)] = map[string]interface{}{
"text": old["text"],
"color": color,
}
}
}
case 2: // RangeToText
from := old["from"]
to := old["to"]
if (from != nil && fmt.Sprintf("%v", from) == "null") || (to != nil && fmt.Sprintf("%v", to) == "null") {
newMappings = append(newMappings, map[string]interface{}{
"type": "special",
"options": map[string]interface{}{
"match": "null",
"result": map[string]interface{}{"text": old["text"], "color": color},
},
})
} else {
var fromVal, toVal interface{}
if from != nil {
if fromStr, ok := from.(string); ok {
if fromFloat, err := strconv.ParseFloat(fromStr, 64); err == nil {
fromVal = fromFloat
}
} else {
fromVal = from
}
}
if to != nil {
if toStr, ok := to.(string); ok {
if toFloat, err := strconv.ParseFloat(toStr, 64); err == nil {
toVal = toFloat
}
} else {
toVal = to
}
}
newMappings = append(newMappings, map[string]interface{}{
"type": "range",
"options": map[string]interface{}{
"from": fromVal,
"to": toVal,
"result": map[string]interface{}{"text": old["text"], "color": color},
},
})
}
}
// Add valueMaps if it has options
if len(valueMaps["options"].(map[string]interface{})) > 0 {
newMappings = append([]interface{}{valueMaps}, newMappings...)
}
if len(newMappings) > 0 {
return newMappings[0].(map[string]interface{})
}
return nil
}
// getActiveThreshold finds the active threshold for a given value
// Based on getActiveThreshold in packages/grafana-data/src/field/thresholds.ts
func (m *v28Migrator) getActiveThreshold(value float64, steps []interface{}) map[string]interface{} {
for i := len(steps) - 1; i >= 0; i-- {
if step, ok := steps[i].(map[string]interface{}); ok {
if stepValue, ok := step["value"]; ok {
if stepValue == nil {
// First step with null value (represents -Infinity)
return step
}
if stepFloat, ok := stepValue.(float64); ok && value >= stepFloat {
return step
}
}
}
}
return nil
}
// parseNumericValue converts various types to float64 for threshold calculations
func (m *v28Migrator) parseNumericValue(value interface{}) (float64, error) {
switch v := value.(type) {
case string:
return strconv.ParseFloat(v, 64)
case float64:
return v, nil
case float32:
return float64(v), nil
case int:
return float64(v), nil
case int32:
return float64(v), nil
case int64:
return float64(v), nil
default:
return 0, fmt.Errorf("cannot convert %T to numeric value", value)
}
}
// cleanupAngularProperties removes old angular properties after migration
// Based on PanelModel.clearPropertiesBeforePluginChange in public/app/features/dashboard/state/PanelModel.ts
func (m *v28Migrator) cleanupAngularProperties(panel map[string]interface{}) {
// Remove PanelModel's autoMigrateFrom property
delete(panel, "autoMigrateFrom")
// Remove angular singlestat properties
delete(panel, "valueName")
delete(panel, "format")
delete(panel, "decimals")
delete(panel, "thresholds")
delete(panel, "colors")
delete(panel, "gauge")
delete(panel, "sparkline")
delete(panel, "colorBackground")
delete(panel, "colorValue")
delete(panel, "nullPointMode")
delete(panel, "nullText")
delete(panel, "valueMaps")
delete(panel, "tableColumn")
delete(panel, "angular")
// Remove legacy options properties
if options, ok := panel["options"].(map[string]interface{}); ok {
delete(options, "valueOptions")
delete(options, "thresholds")
delete(options, "valueMaps")
delete(options, "minValue")
delete(options, "maxValue")
}
}
// removeDeprecatedVariableProperties removes deprecated properties from variables
// Based on DashboardMigrator.ts v28 migration: variable property cleanup
func removeDeprecatedVariableProperties(variable map[string]interface{}) {
// Remove deprecated properties
delete(variable, "tags")
delete(variable, "tagsQuery")
delete(variable, "tagValuesQuery")
delete(variable, "useTags")
}
@@ -0,0 +1,726 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil"
)
func TestV28(t *testing.T) {
tests := []migrationTestCase{
{
name: "migrate angular singlestat to stat panel",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "singlestat",
"valueName": "avg",
"format": "ms",
"decimals": 2,
"thresholds": "10,20,30",
"colors": []interface{}{"green", "yellow", "red"},
"gauge": map[string]interface{}{
"show": false,
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "var1",
"tags": []interface{}{"tag1"},
"tagsQuery": "query",
"tagValuesQuery": "values",
"useTags": true,
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"mean"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "ms",
"decimals": 2,
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "yellow",
"value": 10.0,
},
map[string]interface{}{
"color": "red",
"value": 20.0,
},
},
},
"mappings": []interface{}{},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "var1",
},
},
},
},
},
{
name: "migrate angular singlestat to stat panel with gauge options",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "singlestat",
"valueName": "current",
"format": "percent",
"gauge": map[string]interface{}{
"show": true,
"thresholdMarkers": true,
"thresholdLabels": false,
},
"sparkline": map[string]interface{}{
"show": true,
"lineColor": "#ff0000",
},
"colorBackground": true,
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"lastNotNull"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "percent",
"min": 0,
"max": 100,
"color": map[string]interface{}{
"mode": "fixed",
"fixedColor": "#ff0000",
},
"mappings": []interface{}{},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "migrate grafana-singlestat-panel to stat panel",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "grafana-singlestat-panel",
"valueName": "current",
"format": "percent",
"gauge": map[string]interface{}{
"show": true,
"thresholdMarkers": true,
"thresholdLabels": false,
},
"sparkline": map[string]interface{}{
"show": true,
"lineColor": "#ff0000",
},
"colorBackground": true,
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"lastNotNull"},
"fields": "",
"values": false,
},
"orientation": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "percent",
"min": 0,
"max": 100,
"color": map[string]interface{}{
"mode": "fixed",
"fixedColor": "#ff0000",
},
"mappings": []interface{}{},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "migrate singlestat with empty thresholds to stat panel",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "singlestat",
"valueName": "min",
"format": "bytes",
"thresholds": "",
"colors": []interface{}{"green", "red"},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"min"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"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{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "migrate grafana-singlestat-panel with empty thresholds to stat panel",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "grafana-singlestat-panel",
"valueName": "max",
"format": "short",
"thresholds": "",
"colors": []interface{}{"green", "red"},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"max"},
"fields": "",
"values": false,
},
"orientation": "auto",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"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{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "migrate singlestat with value mappings and threshold colors",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "singlestat",
"valueName": "current",
"format": "short",
"thresholds": "50,80",
"colors": []interface{}{"green", "orange", "red"},
"valueMaps": []interface{}{
map[string]interface{}{
"value": "40",
"text": "Warning",
},
map[string]interface{}{
"value": "90",
"text": "Critical",
},
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"lastNotNull"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"thresholds": map[string]interface{}{
"mode": "absolute",
"steps": []interface{}{
map[string]interface{}{
"color": "green",
"value": nil,
},
map[string]interface{}{
"color": "orange",
"value": 50.0,
},
map[string]interface{}{
"color": "red",
"value": 80.0,
},
},
},
"mappings": []interface{}{
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"40": map[string]interface{}{
"text": "Warning",
"color": "green",
},
},
},
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"90": map[string]interface{}{
"text": "Critical",
"color": "red",
},
},
},
},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "migrate singlestat with invalid valueName fallback to mean",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "singlestat",
"valueName": "invalid_reducer",
"format": "short",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"mean"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"mappings": []interface{}{},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "migrate grafana-singlestat-panel with invalid valueName keeps lastNotNull",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "grafana-singlestat-panel",
"valueName": "invalid_reducer",
"format": "short",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"lastNotNull"},
"fields": "",
"values": false,
},
"orientation": "auto",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "short",
"mappings": []interface{}{},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
{
name: "handle nested panels in rows",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "row",
"panels": []interface{}{
map[string]interface{}{
"id": 2,
"type": "singlestat",
"valueName": "sum",
"format": "bytes",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "row",
"panels": []interface{}{
map[string]interface{}{
"id": 2,
"type": "stat",
"options": map[string]interface{}{
"reduceOptions": map[string]interface{}{
"calcs": []string{"sum"},
"fields": "",
"values": false,
},
"orientation": "horizontal",
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"percentChangeColorMode": "standard",
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true,
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"unit": "bytes",
"mappings": []interface{}{},
},
"overrides": []interface{}{},
},
"pluginVersion": "1.0.0",
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
},
},
},
},
{
name: "remove deprecated variable properties",
input: map[string]interface{}{
"schemaVersion": 27,
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "var1",
"type": "query",
"tags": []interface{}{"tag1", "tag2"},
"tagsQuery": "SELECT * FROM tags",
"tagValuesQuery": "SELECT value FROM tag_values",
"useTags": true,
},
map[string]interface{}{
"name": "var2",
"type": "custom",
// No deprecated properties
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 28,
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "var1",
"type": "query",
},
map[string]interface{}{
"name": "var2",
"type": "custom",
},
},
},
},
},
}
errorTests := []migrationTestCase{
{
name: "throw an error if stat panel plugin not found",
input: map[string]interface{}{
"schemaVersion": 27,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "singlestat",
"valueName": "avg",
"format": "ms",
"decimals": 2,
"thresholds": "10,20,30",
"colors": []interface{}{"green", "yellow", "red"},
"gauge": map[string]interface{}{
"show": false,
},
"targets": []interface{}{
map[string]interface{}{"refId": "A"},
},
},
},
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "var1",
"tags": []interface{}{"tag1"},
"tagsQuery": "query",
"tagValuesQuery": "values",
"useTags": true,
},
},
},
},
expectedError: "schema migration from version 28 to 41 failed: stat panel plugin not found when migrating dashboard to schema version 28",
},
}
runMigrationTests(t, tests, schemaversion.V28(testutil.GetTestPanelProvider()))
runMigrationTests(t, errorTests, schemaversion.V28(testutil.GetTestPanelProviderWithCustomPanels([]schemaversion.PanelPluginInfo{
{ID: "fake-plugin", Version: "1.0.0"},
})))
}
+170 -95
View File
@@ -186,108 +186,21 @@ func upgradeValueMappings(oldMappings []interface{}, thresholds map[string]inter
return oldMappings
}
valueMaps := map[string]interface{}{
"type": "value",
"options": map[string]interface{}{},
// Check if all mappings are already in the new format
if areAllMappingsNewFormat(oldMappings) {
return oldMappings
}
valueMaps := createValueMaps()
var newMappings []interface{}
hasValueMappings := false
for _, mapping := range oldMappings {
if mappingMap, ok := mapping.(map[string]interface{}); ok {
// Check if this is already the new format
if mappingType, ok := mappingMap["type"].(string); ok && mappingType != "" {
if mappingType == "value" {
// Consolidate existing value mappings
if options, ok := mappingMap["options"].(map[string]interface{}); ok {
valueMapsOptions := valueMaps["options"].(map[string]interface{})
for k, v := range options {
valueMapsOptions[k] = v
}
hasValueMappings = true
}
} else {
newMappings = append(newMappings, mappingMap)
}
continue
}
// Handle legacy format
var color interface{}
if thresholds != nil {
// Try to get color from threshold based on the mapping value
if value, ok := mappingMap["value"]; ok {
if valueStr, ok := value.(string); ok {
if numeric, err := strconv.ParseFloat(valueStr, 64); err == nil {
color = getActiveThresholdColor(numeric, thresholds)
}
}
}
// For range mappings, use the 'from' value to determine color
if fromVal, ok := mappingMap["from"]; ok {
if fromStr, ok := fromVal.(string); ok {
if numeric, err := strconv.ParseFloat(fromStr, 64); err == nil {
color = getActiveThresholdColor(numeric, thresholds)
}
}
}
}
// Convert legacy type numbers to new format
if mappingType, ok := mappingMap["type"].(float64); ok {
switch int(mappingType) {
case 1: // ValueToText
if value, ok := mappingMap["value"]; ok {
if valueStr, ok := value.(string); ok && valueStr == "null" {
// Handle null values as special value mapping
// For null values, use the base threshold color (lowest step)
if thresholds != nil {
color = getBaseThresholdColor(thresholds)
}
newMappings = append(newMappings, map[string]interface{}{
"type": "special",
"options": map[string]interface{}{
"match": "null",
"result": map[string]interface{}{
"text": mappingMap["text"],
"color": color,
},
},
})
} else {
// Regular value mapping
valueMapsOptions := valueMaps["options"].(map[string]interface{})
result := map[string]interface{}{
"text": mappingMap["text"],
}
if color != nil {
result["color"] = color
}
valueMapsOptions[stringifyValue(value)] = result
hasValueMappings = true
}
}
case 2: // RangeToText
result := map[string]interface{}{
"text": mappingMap["text"],
}
if color != nil {
result["color"] = color
}
from, _ := strconv.ParseFloat(stringifyValue(mappingMap["from"]), 64)
to, _ := strconv.ParseFloat(stringifyValue(mappingMap["to"]), 64)
newMappings = append(newMappings, map[string]interface{}{
"type": "range",
"options": map[string]interface{}{
"from": from,
"to": to,
"result": result,
},
})
}
if isNewFormatMapping(mappingMap) {
hasValueMappings = processNewFormatMapping(mappingMap, valueMaps, &newMappings, hasValueMappings)
} else {
hasValueMappings = processLegacyMapping(mappingMap, thresholds, valueMaps, &newMappings, hasValueMappings)
}
}
}
@@ -300,6 +213,168 @@ func upgradeValueMappings(oldMappings []interface{}, thresholds map[string]inter
return newMappings
}
// areAllMappingsNewFormat checks if all mappings are already in the new format
func areAllMappingsNewFormat(oldMappings []interface{}) bool {
for _, mapping := range oldMappings {
if mappingMap, ok := mapping.(map[string]interface{}); ok {
if mappingType, ok := mappingMap["type"].(string); ok && mappingType != "" {
// This is already in new format, keep it as-is
continue
} else {
// Found a legacy format mapping, need to process
return false
}
}
}
return true
}
// createValueMaps creates the base value maps structure
func createValueMaps() map[string]interface{} {
return map[string]interface{}{
"type": "value",
"options": map[string]interface{}{},
}
}
// isNewFormatMapping checks if a mapping is already in the new format
func isNewFormatMapping(mappingMap map[string]interface{}) bool {
if mappingType, ok := mappingMap["type"].(string); ok && mappingType != "" {
return true
}
return false
}
// processNewFormatMapping handles mappings that are already in the new format
func processNewFormatMapping(mappingMap map[string]interface{}, valueMaps map[string]interface{}, newMappings *[]interface{}, hasValueMappings bool) bool {
mappingType := mappingMap["type"].(string)
if mappingType == "value" {
// Consolidate existing value mappings
if options, ok := mappingMap["options"].(map[string]interface{}); ok {
valueMapsOptions := valueMaps["options"].(map[string]interface{})
for k, v := range options {
valueMapsOptions[k] = v
}
hasValueMappings = true
}
} else {
*newMappings = append(*newMappings, mappingMap)
}
return hasValueMappings
}
// processLegacyMapping handles legacy format mappings
func processLegacyMapping(mappingMap map[string]interface{}, thresholds map[string]interface{}, valueMaps map[string]interface{}, newMappings *[]interface{}, hasValueMappings bool) bool {
color := getColorFromThresholds(mappingMap, thresholds)
// Convert legacy type numbers to new format
if mappingType, ok := mappingMap["type"].(float64); ok {
switch int(mappingType) {
case 1: // ValueToText
hasValueMappings = processValueToTextMapping(mappingMap, color, thresholds, valueMaps, newMappings, hasValueMappings)
case 2: // RangeToText
processRangeToTextMapping(mappingMap, color, newMappings)
}
}
return hasValueMappings
}
// getColorFromThresholds extracts color from thresholds based on mapping values
func getColorFromThresholds(mappingMap map[string]interface{}, thresholds map[string]interface{}) interface{} {
if thresholds == nil {
return nil
}
// Try to get color from threshold based on the mapping value
if value, ok := mappingMap["value"]; ok {
if valueStr, ok := value.(string); ok {
if numeric, err := strconv.ParseFloat(valueStr, 64); err == nil {
return getActiveThresholdColor(numeric, thresholds)
}
}
}
// For range mappings, use the 'from' value to determine color
if fromVal, ok := mappingMap["from"]; ok {
if fromStr, ok := fromVal.(string); ok {
if numeric, err := strconv.ParseFloat(fromStr, 64); err == nil {
return getActiveThresholdColor(numeric, thresholds)
}
}
}
return nil
}
// processValueToTextMapping handles ValueToText legacy mappings
func processValueToTextMapping(mappingMap map[string]interface{}, color interface{}, thresholds map[string]interface{}, valueMaps map[string]interface{}, newMappings *[]interface{}, hasValueMappings bool) bool {
if value, ok := mappingMap["value"]; ok {
if valueStr, ok := value.(string); ok && valueStr == "null" {
// Handle null values as special value mapping
processNullValueMapping(mappingMap, color, thresholds, newMappings)
} else {
// Regular value mapping
processRegularValueMapping(mappingMap, value, color, valueMaps)
hasValueMappings = true
}
}
return hasValueMappings
}
// processNullValueMapping creates a special value mapping for null values
func processNullValueMapping(mappingMap map[string]interface{}, color interface{}, thresholds map[string]interface{}, newMappings *[]interface{}) {
// For null values, use the base threshold color (lowest step)
if thresholds != nil && color == nil {
color = getBaseThresholdColor(thresholds)
}
*newMappings = append(*newMappings, map[string]interface{}{
"type": "special",
"options": map[string]interface{}{
"match": "null",
"result": map[string]interface{}{
"text": mappingMap["text"],
"color": color,
},
},
})
}
// processRegularValueMapping creates a regular value mapping
func processRegularValueMapping(mappingMap map[string]interface{}, value interface{}, color interface{}, valueMaps map[string]interface{}) {
valueMapsOptions := valueMaps["options"].(map[string]interface{})
result := map[string]interface{}{
"text": mappingMap["text"],
}
if color != nil {
result["color"] = color
}
valueMapsOptions[stringifyValue(value)] = result
}
// processRangeToTextMapping handles RangeToText legacy mappings
func processRangeToTextMapping(mappingMap map[string]interface{}, color interface{}, newMappings *[]interface{}) {
result := map[string]interface{}{
"text": mappingMap["text"],
}
if color != nil {
result["color"] = color
}
from, _ := strconv.ParseFloat(stringifyValue(mappingMap["from"]), 64)
to, _ := strconv.ParseFloat(stringifyValue(mappingMap["to"]), 64)
*newMappings = append(*newMappings, map[string]interface{}{
"type": "range",
"options": map[string]interface{}{
"from": from,
"to": to,
"result": result,
},
})
}
// getActiveThresholdColor returns the color for a value based on thresholds
func getActiveThresholdColor(value float64, thresholds map[string]interface{}) interface{} {
if steps, ok := thresholds["steps"].([]interface{}); ok {
@@ -414,6 +414,97 @@ func TestV30(t *testing.T) {
},
},
},
{
name: "already migrated value mappings are preserved correctly",
input: map[string]interface{}{
"title": "V30 Already Migrated Value Mappings Test Dashboard",
"schemaVersion": 29,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with already migrated value mappings",
"id": 1,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"mappings": []interface{}{
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"20": map[string]interface{}{
"color": nil,
"text": "test",
},
},
},
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"30": map[string]interface{}{
"color": nil,
"text": "test1",
},
},
},
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"40": map[string]interface{}{
"color": "orange",
"text": "50",
},
},
},
},
},
},
},
},
},
expected: map[string]interface{}{
"title": "V30 Already Migrated Value Mappings Test Dashboard",
"schemaVersion": 30,
"panels": []interface{}{
map[string]interface{}{
"type": "timeseries",
"title": "Panel with already migrated value mappings",
"id": 1,
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"mappings": []interface{}{
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"20": map[string]interface{}{
"color": nil,
"text": "test",
},
},
},
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"30": map[string]interface{}{
"color": nil,
"text": "test1",
},
},
},
map[string]interface{}{
"type": "value",
"options": map[string]interface{}{
"40": map[string]interface{}{
"color": "orange",
"text": "50",
},
},
},
},
},
},
},
},
},
},
{
name: "graph panels with different configurations remain unchanged in V30",
input: map[string]interface{}{
@@ -9,7 +9,7 @@ import (
func TestV33(t *testing.T) {
// Pass the mock provider to V33
migration := schemaversion.V33(testutil.GetTestProvider())
migration := schemaversion.V33(testutil.GetTestDataSourceProvider())
tests := []migrationTestCase{
{
@@ -9,7 +9,7 @@ import (
func TestV36(t *testing.T) {
// Pass the mock provider to V36
migration := schemaversion.V36(testutil.GetTestProvider())
migration := schemaversion.V36(testutil.GetTestDataSourceProvider())
tests := []migrationTestCase{
{