**Highlights**
* **Single-version migrations**: add `targetVersion` to migrator & model, separate outputs, enforce exact version.
* **Datasource fixes**: include `apiVersion` in tests, empty-string → `{}`, preserve `{}` refs, drop unwanted defaults.
* **Panel defaults & nesting**: only top-level panels get defaults; preserve empty `transformations` context-aware; filter repeated panels.
* **Migration parity**
* V16: collapsed rows, grid height parsing (`px`).
* V17: omit `maxPerRow` when `minSpan=1`.
* V19–V20: cleanup defaults (`targetBlank`, style).
* V23–V24: template vars + table panel consistency.
* V28: full singlestat/stat parity, mappings & color.
* V30–V36: threshold logic, empty refs, nested targets.
* **Save-model cleanup**: replicate frontend defaults/filtering, drop null IDs, metadata, unused props.
* **Testing**: unified suites, dev dashboards (v42), full unit coverage for major migrations.
Co-authored-by: Ivan Ortega [ivanortegaalba@gmail.com](mailto:ivanortegaalba@gmail.com)
Co-authored-by: Dominik Prokop [dominik.prokop@grafana.com](mailto:dominik.prokop@grafana.com)
888 lines
29 KiB
Go
888 lines
29 KiB
Go
package schemaversion
|
|
|
|
import (
|
|
"context"
|
|
"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" }
|
|
// ]
|
|
// }
|
|
func V28(_ context.Context, dashboard map[string]interface{}) error {
|
|
dashboard["schemaVersion"] = 28
|
|
|
|
// Migrate singlestat panels
|
|
if panels, ok := dashboard["panels"].([]interface{}); ok {
|
|
if err := 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 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 := processPanels(nestedPanels); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Migrate singlestat panels (including those already auto-migrated to stat)
|
|
if p["type"] == "singlestat" || p["type"] == "grafana-singlestat-panel" ||
|
|
p["autoMigrateFrom"] == "singlestat" || p["autoMigrateFrom"] == "grafana-singlestat-panel" {
|
|
if err := migrateSinglestatPanel(p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Note: Panel defaults (including options object) are already applied
|
|
// by applyPanelDefaults() in the main migration flow for ALL panels
|
|
// No need for stat-specific normalization
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func migrateSinglestatPanel(panel map[string]interface{}) error {
|
|
targetType := "stat"
|
|
|
|
// NOTE: The legacy types "singlestat" and "gauge" are both angular only
|
|
// This are not supported by any version that could run this migration, so there is
|
|
// no need to maintain a distinction or fallback to the non-stat version
|
|
|
|
// 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)
|
|
// Set autoMigrateFrom to track the original type for proper migration logic
|
|
originalType := panel["type"].(string)
|
|
// Only set autoMigrateFrom if it doesn't already exist (preserve frontend defaults)
|
|
if _, exists := panel["autoMigrateFrom"]; !exists {
|
|
panel["autoMigrateFrom"] = originalType
|
|
}
|
|
panel["type"] = targetType
|
|
panel["pluginVersion"] = pluginVersionForAutoMigrate
|
|
|
|
// Migrate panel options and field config
|
|
migrateSinglestatOptions(panel, originalType)
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateSinglestatOptions handles the complete migration of singlestat panel options and field config
|
|
func migrateSinglestatOptions(panel map[string]interface{}, originalType string) {
|
|
// Preserve important panel-level properties that should not be removed
|
|
// These properties are preserved by the frontend's getSaveModel() method
|
|
var maxDataPoints interface{}
|
|
if mdp, exists := panel["maxDataPoints"]; exists {
|
|
maxDataPoints = mdp
|
|
}
|
|
|
|
// 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
|
|
// Use autoMigrateFrom if available, otherwise use originalType
|
|
migrationType := originalType
|
|
if autoMigrateFrom, exists := panel["autoMigrateFrom"].(string); exists {
|
|
migrationType = autoMigrateFrom
|
|
}
|
|
|
|
if migrationType == "grafana-singlestat-panel" {
|
|
migrateGrafanaSinglestatPanel(panel, defaults)
|
|
} else {
|
|
migratetSinglestat(panel, defaults)
|
|
}
|
|
|
|
// Apply shared migration logic
|
|
applySharedSinglestatMigration(defaults)
|
|
|
|
// Apply complete stat panel defaults (matches frontend getPanelOptionsWithDefaults)
|
|
// The frontend applies these defaults after migration via applyPluginOptionDefaults
|
|
applyCompleteStatPanelDefaults(panel)
|
|
|
|
// Create proper fieldConfig structure from defaults
|
|
createFieldConfigFromDefaults(panel, defaults)
|
|
|
|
// Restore preserved panel-level properties
|
|
if maxDataPoints != nil {
|
|
panel["maxDataPoints"] = maxDataPoints
|
|
}
|
|
|
|
// Clean up old angular properties after migration
|
|
cleanupAngularProperties(panel)
|
|
}
|
|
|
|
// getDefaultStatOptions returns the default options structure for stat panels
|
|
// This matches the frontend's stat panel defaultOptions exactly
|
|
func getDefaultStatOptions() map[string]interface{} {
|
|
// For now, return the explicit defaults until we integrate the centralized system
|
|
return map[string]interface{}{
|
|
"colorMode": "value",
|
|
"graphMode": "area",
|
|
"justifyMode": "auto",
|
|
"percentChangeColorMode": "standard",
|
|
"showPercentChange": false,
|
|
"textMode": "auto",
|
|
"wideLayout": true,
|
|
"reduceOptions": map[string]interface{}{
|
|
"calcs": []string{"lastNotNull"}, // Matches frontend: ReducerID.lastNotNull
|
|
"fields": "",
|
|
"values": false,
|
|
},
|
|
"orientation": "auto",
|
|
}
|
|
}
|
|
|
|
// migratetSinglestat handles explicit migration from 'singlestat' panels
|
|
// Based on frontend migrateFromAngularSinglestat function
|
|
func migratetSinglestat(panel map[string]interface{}, defaults map[string]interface{}) {
|
|
angularOpts := extractAngularOptions(panel)
|
|
|
|
// Extract valueName for reducer mapping (matches frontend migrateFromAngularSinglestat)
|
|
var valueName string
|
|
if vn, ok := angularOpts["valueName"].(string); ok {
|
|
valueName = vn
|
|
}
|
|
|
|
// Set calcs based on valueName (matches frontend: calcs: [reducer ? reducer.id : ReducerID.mean])
|
|
var calcs []string
|
|
if reducer := getReducerForValueName(valueName); reducer != "" {
|
|
calcs = []string{reducer}
|
|
} else {
|
|
// Use mean as fallback (matches frontend migrateFromAngularSinglestat: ReducerID.mean)
|
|
calcs = []string{"mean"}
|
|
}
|
|
|
|
// Create options exactly like frontend migrateFromAngularSinglestat
|
|
options := map[string]interface{}{
|
|
"reduceOptions": map[string]interface{}{
|
|
"calcs": calcs,
|
|
"fields": "",
|
|
"values": false,
|
|
},
|
|
"orientation": "horizontal", // Matches frontend migrateFromAngularSinglestat: VizOrientation.Horizontal
|
|
}
|
|
|
|
// Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings)
|
|
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": (*float64)(nil),
|
|
},
|
|
map[string]interface{}{
|
|
"color": "red",
|
|
"value": 80,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Apply common angular option migrations (value mappings can now use threshold colors)
|
|
applyCommonAngularMigration(panel, defaults, options, angularOpts)
|
|
|
|
// Merge new options with existing panel options to preserve properties like maxDataPoints
|
|
if existingOptions, exists := panel["options"].(map[string]interface{}); exists {
|
|
for key, value := range options {
|
|
existingOptions[key] = value
|
|
}
|
|
} else {
|
|
panel["options"] = options
|
|
}
|
|
}
|
|
|
|
// migrateGrafanaSinglestatPanel handles auto-migration from 'grafana-singlestat-panel'
|
|
// Uses the same migration logic as singlestat panels since the frontend applies
|
|
// migrateFromAngularSinglestat to both panel types.
|
|
func migrateGrafanaSinglestatPanel(panel map[string]interface{}, defaults map[string]interface{}) {
|
|
migratetSinglestat(panel, defaults)
|
|
}
|
|
|
|
// migrateThresholds handles threshold migration for both singlestat panel types
|
|
// Both panel types now create DEFAULT_THRESHOLDS when threshold string is empty (consolidated behavior)
|
|
func 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
|
|
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": (*float64)(nil), // Use pointer to ensure field is present in JSON
|
|
},
|
|
map[string]interface{}{
|
|
"color": "red",
|
|
"value": 80,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyCommonAngularMigration applies migrations common to both singlestat types
|
|
func 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 unit from format property (matches frontend sharedSingleStatPanelChangedHandler)
|
|
if format, ok := angularOpts["format"].(string); ok {
|
|
defaults["unit"] = format
|
|
}
|
|
|
|
// Migrate decimals
|
|
if decimals, ok := angularOpts["decimals"]; ok {
|
|
defaults["decimals"] = decimals
|
|
}
|
|
|
|
// Note: Frontend migrateFromAngularSinglestat does migrate nullPointMode to nullValueMode
|
|
// but the frontend's getSaveModel() method removes it, so we don't add it here
|
|
// 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{})
|
|
migrateValueMappings(angularOpts, defaults, valueMaps)
|
|
|
|
// Migrate sparkline configuration
|
|
// Based on statPanelChangedHandler lines ~20-23: sparkline migration logic
|
|
if sparkline, ok := angularOpts["sparkline"].(map[string]interface{}); ok {
|
|
if show, ok := sparkline["show"].(bool); ok && show {
|
|
options["graphMode"] = "area"
|
|
} else {
|
|
options["graphMode"] = "none"
|
|
}
|
|
} else {
|
|
// Default to no graph mode if no sparkline configuration
|
|
options["graphMode"] = "none"
|
|
}
|
|
|
|
// Migrate color configuration
|
|
// Based on statPanelChangedHandler lines ~25-38: colorBackground and colorValue migration
|
|
colorMode := determineColorMode(angularOpts)
|
|
options["colorMode"] = colorMode
|
|
|
|
// Sparkline color migration only happens when colorMode is "none"
|
|
if colorMode == "none" {
|
|
migrateSparklineColor(angularOpts, defaults, options)
|
|
}
|
|
|
|
// 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"]
|
|
}
|
|
}
|
|
|
|
// applyCompleteStatPanelDefaults applies the complete stat panel defaults
|
|
// This matches the frontend's getPanelOptionsWithDefaults behavior after migration
|
|
func applyCompleteStatPanelDefaults(panel map[string]interface{}) {
|
|
// Get or create options object
|
|
options, exists := panel["options"].(map[string]interface{})
|
|
if !exists {
|
|
options = map[string]interface{}{}
|
|
panel["options"] = options
|
|
}
|
|
|
|
defaultOptions := getDefaultStatOptions()
|
|
|
|
// Merge defaults with existing options, but don't override existing values
|
|
// This matches the frontend's getPanelOptionsWithDefaults behavior
|
|
for key, defaultValue := range defaultOptions {
|
|
if _, exists := options[key]; !exists {
|
|
options[key] = defaultValue
|
|
}
|
|
}
|
|
}
|
|
|
|
// applySharedSinglestatMigration applies shared migration logic for all singlestat panels
|
|
// Based on sharedSingleStatMigrationHandler in packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts
|
|
func 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 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 getReducerForValueName(valueName string) string {
|
|
reducerMap := map[string]string{
|
|
"min": "min",
|
|
"max": "max",
|
|
"mean": "mean",
|
|
"avg": "mean", // avg maps to 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 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 {
|
|
// Frontend expects explicit null value for first step, not omitted field
|
|
// Use a pointer to ensure the field is present in JSON with null value
|
|
var nullValue *float64
|
|
step["value"] = nullValue
|
|
} 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 migrateValueMappings(panel map[string]interface{}, defaults map[string]interface{}, valueMappings []interface{}) {
|
|
mappings := []interface{}{}
|
|
mappingType := panel["mappingType"]
|
|
|
|
// Check for inconsistent mapping configuration
|
|
// If panel has rangeMaps but mappingType is 1, or vice versa, fix it
|
|
hasValueMaps := panel["valueMaps"] != nil && IsArray(panel["valueMaps"]) && len(panel["valueMaps"].([]interface{})) > 0
|
|
hasRangeMaps := panel["rangeMaps"] != nil && IsArray(panel["rangeMaps"]) && len(panel["rangeMaps"].([]interface{})) > 0
|
|
|
|
if hasRangeMaps && mappingType == float64(1) {
|
|
mappingType = 2
|
|
} else if hasValueMaps && mappingType == float64(2) {
|
|
mappingType = 1
|
|
} else if mappingType == nil {
|
|
if hasValueMaps {
|
|
mappingType = 1
|
|
} else if hasRangeMaps {
|
|
mappingType = 2
|
|
}
|
|
}
|
|
|
|
switch mappingType {
|
|
case 1:
|
|
for _, valueMap := range valueMappings {
|
|
valueMapping := valueMap.(map[string]interface{})
|
|
upgradedMapping := 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 := 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 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
|
|
// Frontend uses old.text to determine color, not old.value
|
|
var color interface{}
|
|
if text, ok := old["text"].(string); ok {
|
|
if numeric, err := parseNumericValue(text); err == nil {
|
|
if thresholdsMap, ok := thresholds.(map[string]interface{}); ok {
|
|
if steps, ok := thresholdsMap["steps"].([]interface{}); ok {
|
|
level := 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 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 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)
|
|
}
|
|
}
|
|
|
|
// createFieldConfigFromDefaults creates the proper fieldConfig structure from defaults
|
|
// and removes all legacy properties from the panel
|
|
func createFieldConfigFromDefaults(panel map[string]interface{}, defaults map[string]interface{}) {
|
|
// Ensure fieldConfig exists
|
|
if panel["fieldConfig"] == nil {
|
|
panel["fieldConfig"] = map[string]interface{}{
|
|
"defaults": map[string]interface{}{},
|
|
"overrides": []interface{}{},
|
|
}
|
|
}
|
|
|
|
fieldConfig := panel["fieldConfig"].(map[string]interface{})
|
|
fieldDefaults := fieldConfig["defaults"].(map[string]interface{})
|
|
|
|
// Copy all defaults to fieldConfig.defaults
|
|
for key, value := range defaults {
|
|
fieldDefaults[key] = value
|
|
}
|
|
|
|
// Note: Frontend doesn't add these extra fieldConfig defaults
|
|
// Color is handled in sparkline migration logic
|
|
// nullValueMode and unit are not added by frontend
|
|
|
|
// Remove all legacy properties from the panel
|
|
legacyProperties := []string{
|
|
"colors", "thresholds", "valueMaps", "grid", "legend", "mappingTypes", "gauge",
|
|
"autoMigrateFrom", "colorBackground", "colorValue", "format", "mappingType",
|
|
"nullPointMode", "postfix", "postfixFontSize", "prefix",
|
|
"prefixFontSize", "rangeMaps", "sparkline", "tableColumn", "valueFontSize",
|
|
"valueName", "aliasYAxis", "bars", "dashLength", "dashes", "fill", "fillGradient",
|
|
"lineInterpolation", "lineWidth", "pointRadius", "points", "spaceLength",
|
|
"stack", "steppedLine", "xAxis", "yAxes", "yAxis", "zIndex",
|
|
}
|
|
|
|
for _, prop := range legacyProperties {
|
|
delete(panel, prop)
|
|
}
|
|
}
|
|
|
|
// cleanupAngularProperties removes old angular properties after migration
|
|
// Based on PanelModel.clearPropertiesBeforePluginChange in public/app/features/dashboard/state/PanelModel.ts
|
|
// This function removes ALL properties except those in mustKeepProps to match frontend behavior exactly
|
|
func cleanupAngularProperties(panel map[string]interface{}) {
|
|
// Properties that must be kept (matching frontend mustKeepProps)
|
|
mustKeepProps := map[string]bool{
|
|
"id": true, "gridPos": true, "type": true, "title": true, "scopedVars": true,
|
|
"repeat": true, "repeatPanelId": true, "repeatDirection": true, "repeatedByRow": true,
|
|
"minSpan": true, "collapsed": true, "panels": true, "targets": true, "datasource": true,
|
|
"timeFrom": true, "timeShift": true, "hideTimeOverride": true, "description": true,
|
|
"links": true, "fullscreen": true, "isEditing": true, "isViewing": true,
|
|
"hasRefreshed": true, "events": true, "cacheTimeout": true, "queryCachingTTL": true,
|
|
"cachedPluginOptions": true, "transparent": true, "pluginVersion": true,
|
|
"fieldConfig": true, "options": true, // These are set by migration
|
|
"maxDataPoints": true, "interval": true, // Panel-level properties preserved by frontend
|
|
"autoMigrateFrom": true, // Preserve autoMigrateFrom for proper migration logic
|
|
}
|
|
|
|
// Remove ALL properties except those in mustKeepProps (matching frontend behavior)
|
|
for key := range panel {
|
|
if !mustKeepProps[key] {
|
|
delete(panel, key)
|
|
}
|
|
}
|
|
|
|
// Ensure all targets have refIds (matching frontend ensureQueryIds behavior)
|
|
ensureTargetRefIds(panel)
|
|
}
|
|
|
|
// ensureTargetRefIds assigns refIds to targets that don't have them
|
|
// This matches the frontend PanelModel.ensureQueryIds() behavior
|
|
func ensureTargetRefIds(panel map[string]interface{}) {
|
|
targets, ok := panel["targets"].([]interface{})
|
|
if !ok || len(targets) == 0 {
|
|
return
|
|
}
|
|
|
|
// Find existing refIds
|
|
existingRefIds := make(map[string]bool)
|
|
for _, targetInterface := range targets {
|
|
if target, ok := targetInterface.(map[string]interface{}); ok {
|
|
if refId, ok := target["refId"].(string); ok {
|
|
existingRefIds[refId] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assign refIds to targets that don't have them
|
|
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
letterIndex := 0
|
|
|
|
for _, targetInterface := range targets {
|
|
if target, ok := targetInterface.(map[string]interface{}); ok {
|
|
refId, hasRefId := target["refId"].(string)
|
|
if !hasRefId || refId == "" {
|
|
// Find next available refId
|
|
for letterIndex < len(letters) {
|
|
refId := string(letters[letterIndex])
|
|
if !existingRefIds[refId] {
|
|
target["refId"] = refId
|
|
existingRefIds[refId] = true
|
|
break
|
|
}
|
|
letterIndex++
|
|
}
|
|
letterIndex++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
|
|
// Only remove tagsQuery if it's a non-empty string (matches frontend behavior)
|
|
if tagsQuery, exists := variable["tagsQuery"]; exists {
|
|
if str, ok := tagsQuery.(string); ok && str != "" {
|
|
delete(variable, "tagsQuery")
|
|
}
|
|
}
|
|
|
|
// Only remove tagValuesQuery if it's a non-empty string (matches frontend behavior)
|
|
if tagValuesQuery, exists := variable["tagValuesQuery"]; exists {
|
|
if str, ok := tagValuesQuery.(string); ok && str != "" {
|
|
delete(variable, "tagValuesQuery")
|
|
}
|
|
}
|
|
|
|
// Only remove useTags if it's a truthy boolean (matches frontend behavior)
|
|
if useTags, exists := variable["useTags"]; exists {
|
|
if val, ok := useTags.(bool); ok && val {
|
|
delete(variable, "useTags")
|
|
}
|
|
}
|
|
}
|
|
|
|
// determineColorMode determines the color mode based on angular options
|
|
func determineColorMode(angularOpts map[string]interface{}) string {
|
|
if colorBackground, ok := angularOpts["colorBackground"].(bool); ok && colorBackground {
|
|
return "background"
|
|
}
|
|
|
|
if colorValue, ok := angularOpts["colorValue"].(bool); ok && colorValue {
|
|
return "value"
|
|
}
|
|
|
|
return "none"
|
|
}
|
|
|
|
// migrateSparklineColor migrates sparkline color configuration when colorMode is "none"
|
|
// Based on statPanelChangedHandler lines 31-38
|
|
func migrateSparklineColor(angularOpts map[string]interface{}, defaults map[string]interface{}, options map[string]interface{}) {
|
|
sparkline, ok := angularOpts["sparkline"].(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
show, ok := sparkline["show"].(bool)
|
|
if !ok || !show {
|
|
return
|
|
}
|
|
|
|
graphMode, ok := options["graphMode"].(string)
|
|
if !ok || graphMode != "area" {
|
|
return
|
|
}
|
|
|
|
lineColor, ok := sparkline["lineColor"].(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
defaults["color"] = map[string]interface{}{
|
|
"mode": "fixed",
|
|
"fixedColor": lineColor,
|
|
}
|
|
}
|