Dashboard Migrations: V28 singlestat panel and deprecated variable properties (#108416)
Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
co-authored by
Ivan Ortega
parent
3dcda77462
commit
5ad751ea28
@@ -25,7 +25,7 @@ import (
|
||||
|
||||
func TestConversionMatrixExist(t *testing.T) {
|
||||
// Initialize the migrator with a test data source provider
|
||||
migration.Initialize(testutil.GetTestProvider())
|
||||
migration.Initialize(testutil.GetTestDataSourceProvider(), testutil.GetTestPanelProvider())
|
||||
|
||||
versions := []v1.Object{
|
||||
&dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
|
||||
@@ -76,7 +76,7 @@ func TestDeepCopyValid(t *testing.T) {
|
||||
|
||||
func TestDashboardConversionToAllVersions(t *testing.T) {
|
||||
// Initialize the migrator with a test data source provider
|
||||
migration.Initialize(testutil.GetTestProvider())
|
||||
migration.Initialize(testutil.GetTestDataSourceProvider(), testutil.GetTestPanelProvider())
|
||||
|
||||
// Set up conversion scheme
|
||||
scheme := runtime.NewScheme()
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
)
|
||||
|
||||
// Initialize provides the migrator singleton with required dependencies and builds the map of migrations.
|
||||
func Initialize(dsInfoProvider schemaversion.DataSourceInfoProvider) {
|
||||
migratorInstance.init(dsInfoProvider)
|
||||
func Initialize(dsInfoProvider schemaversion.DataSourceInfoProvider, panelProvider schemaversion.PanelPluginInfoProvider) {
|
||||
migratorInstance.init(dsInfoProvider, panelProvider)
|
||||
}
|
||||
|
||||
// Migrate migrates the given dashboard to the target version.
|
||||
@@ -30,9 +30,9 @@ type migrator struct {
|
||||
migrations map[int]schemaversion.SchemaVersionMigrationFunc
|
||||
}
|
||||
|
||||
func (m *migrator) init(dsInfoProvider schemaversion.DataSourceInfoProvider) {
|
||||
func (m *migrator) init(dsInfoProvider schemaversion.DataSourceInfoProvider, panelProvider schemaversion.PanelPluginInfoProvider) {
|
||||
initOnce.Do(func() {
|
||||
m.migrations = schemaversion.GetMigrations(dsInfoProvider)
|
||||
m.migrations = schemaversion.GetMigrations(dsInfoProvider, panelProvider)
|
||||
close(m.ready)
|
||||
})
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func (m *migrator) migrate(dash map[string]interface{}, targetVersion int) error
|
||||
for nextVersion := inputVersion + 1; nextVersion <= targetVersion; nextVersion++ {
|
||||
if migration, ok := m.migrations[nextVersion]; ok {
|
||||
if err := migration(dash); err != nil {
|
||||
return schemaversion.NewMigrationError("migration failed", inputVersion, nextVersion)
|
||||
return schemaversion.NewMigrationError("migration failed: "+err.Error(), inputVersion, nextVersion)
|
||||
}
|
||||
dash["schemaVersion"] = nextVersion
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestMigrate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use the same datasource provider as the frontend test to ensure consistency
|
||||
migration.Initialize(testutil.GetTestProvider())
|
||||
migration.Initialize(testutil.GetTestDataSourceProvider(), testutil.GetTestPanelProvider())
|
||||
|
||||
t.Run("minimum version check", func(t *testing.T) {
|
||||
err := migration.Migrate(map[string]interface{}{
|
||||
@@ -61,12 +61,12 @@ func TestMigrate(t *testing.T) {
|
||||
|
||||
testName := fmt.Sprintf("%s v%d to v%d", f.Name(), inputVersion, schemaversion.LATEST_VERSION)
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
testMigration(t, inputDash, f.Name(), inputVersion, schemaversion.LATEST_VERSION)
|
||||
testMigration(t, inputDash, f.Name(), schemaversion.LATEST_VERSION)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testMigration(t *testing.T, dash map[string]interface{}, inputFileName string, inputVersion, targetVersion int) {
|
||||
func testMigration(t *testing.T, dash map[string]interface{}, inputFileName string, targetVersion int) {
|
||||
t.Helper()
|
||||
require.NoError(t, migration.Migrate(dash, targetVersion), "%d migration failed", targetVersion)
|
||||
|
||||
@@ -74,11 +74,9 @@ func testMigration(t *testing.T, dash map[string]interface{}, inputFileName stri
|
||||
outBytes, err := json.MarshalIndent(dash, "", " ")
|
||||
require.NoError(t, err, "failed to marshal migrated dashboard")
|
||||
|
||||
if _, err := os.Stat(outPath); os.IsNotExist(err) {
|
||||
err = os.WriteFile(outPath, outBytes, 0644)
|
||||
require.NoError(t, err, "failed to write new output file", outPath)
|
||||
return
|
||||
}
|
||||
// Overwrite the output file with the new output
|
||||
err = os.WriteFile(outPath, outBytes, 0644)
|
||||
require.NoError(t, err, "failed to write output file", outPath)
|
||||
|
||||
// We can ignore gosec G304 here since it's a test
|
||||
// nolint:gosec
|
||||
|
||||
@@ -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"},
|
||||
})))
|
||||
}
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"title": "V28 Singlestat and Variable Properties Migration Test Dashboard",
|
||||
"schemaVersion": 27,
|
||||
"panels": [
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "query_variable_with_tags",
|
||||
"type": "query",
|
||||
"datasource": "prometheus",
|
||||
"query": "label_values(up, instance)",
|
||||
"tags": ["instance", "job"],
|
||||
"tagsQuery": "label_values(up, job)",
|
||||
"tagValuesQuery": "label_values(up{job=\"$job\"}, instance)",
|
||||
"useTags": true,
|
||||
"refresh": 1
|
||||
},
|
||||
{
|
||||
"name": "custom_variable_with_tags",
|
||||
"type": "custom",
|
||||
"options": [
|
||||
{"text": "Option 1", "value": "opt1"},
|
||||
{"text": "Option 2", "value": "opt2"}
|
||||
],
|
||||
"tags": ["custom_tag"],
|
||||
"tagsQuery": "custom query",
|
||||
"useTags": false
|
||||
},
|
||||
{
|
||||
"name": "clean_variable",
|
||||
"type": "textbox",
|
||||
"options": [
|
||||
{"text": "Hello", "value": "World"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"title": "V28 Singlestat and Variable Properties Migration Test Dashboard",
|
||||
"schemaVersion": 27,
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "singlestat",
|
||||
"legend": true,
|
||||
"thresholds": "10,20,30",
|
||||
"colors": ["#FF0000", "green", "orange"],
|
||||
"grid": { "min": 1, "max": 10 },
|
||||
"targets": [{ "refId": "A" }, {}]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "singlestat",
|
||||
"legend": true,
|
||||
"thresholds": "",
|
||||
"colors": ["#FF0000", "green", "orange"],
|
||||
"grid": { "min": 1, "max": 10 },
|
||||
"targets": [{ "refId": "A" }, {}]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "singlestat",
|
||||
"thresholds": "10,20,30",
|
||||
"colors": ["#FF0000", "green", "orange"],
|
||||
"gauge": {
|
||||
"show": true,
|
||||
"thresholdMarkers": true,
|
||||
"thresholdLabels": false
|
||||
},
|
||||
"grid": { "min": 1, "max": 10 }
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "singlestat",
|
||||
"legend": true,
|
||||
"thresholds": "10,20,30",
|
||||
"colors": ["#FF0000", "green", "orange"],
|
||||
"grid": { "min": 1, "max": 10 },
|
||||
"targets": [{ "refId": "A" }, {}],
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "test",
|
||||
"value": "20"
|
||||
},
|
||||
{
|
||||
"op": "=",
|
||||
"text": "test1",
|
||||
"value": "30"
|
||||
},
|
||||
{
|
||||
"op": "=",
|
||||
"text": "50",
|
||||
"value": "40"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"colorBackground": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#299c46",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#d44a3a"
|
||||
],
|
||||
"datasource": { "type": "prometheus" },
|
||||
"format": "areaF2",
|
||||
"gauge": {
|
||||
"maxValue": 100,
|
||||
"minValue": 0,
|
||||
"show": false,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 43
|
||||
},
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"postfix": "b",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "a",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": false,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": true
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": "",
|
||||
"title": "grafana-singlestat-panel",
|
||||
"type": "grafana-singlestat-panel",
|
||||
"valueFontSize": "80%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "avg"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"datasource": { "type": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"match": "null",
|
||||
"result": {
|
||||
"text": "N/A"
|
||||
}
|
||||
},
|
||||
"type": "special"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 43
|
||||
},
|
||||
"maxDataPoints": 100,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"mean"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "singlestat (old, internal. Migrated if schema < 28)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"datasource": { "type": "prometheus" },
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 43
|
||||
},
|
||||
"options": {
|
||||
"code": {
|
||||
"language": "plaintext",
|
||||
"showLineNumbers": false,
|
||||
"showMiniMap": false
|
||||
},
|
||||
"content": "# Singlestat >> Stat\n\nKnown issues:\n* limited options",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Status + Notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "rate(http_requests_total[5m])"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"panels": [],
|
||||
"refresh": "",
|
||||
"schemaVersion": 41,
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"datasource": {
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"name": "query_variable_with_tags",
|
||||
"query": "label_values(up, instance)",
|
||||
"refresh": 1,
|
||||
"type": "query"
|
||||
},
|
||||
{
|
||||
"name": "custom_variable_with_tags",
|
||||
"options": [
|
||||
{
|
||||
"text": "Option 1",
|
||||
"value": "opt1"
|
||||
},
|
||||
{
|
||||
"text": "Option 2",
|
||||
"value": "opt2"
|
||||
}
|
||||
],
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"name": "clean_variable",
|
||||
"options": [
|
||||
{
|
||||
"text": "Hello",
|
||||
"value": "World"
|
||||
}
|
||||
],
|
||||
"type": "textbox"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
]
|
||||
},
|
||||
"title": "V28 Singlestat and Variable Properties Migration Test Dashboard"
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#FF0000",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"grid": {
|
||||
"max": 10,
|
||||
"min": 1
|
||||
},
|
||||
"id": 1,
|
||||
"legend": true,
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"mean"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"grid": {
|
||||
"max": 10,
|
||||
"min": 1
|
||||
},
|
||||
"id": 2,
|
||||
"legend": true,
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"mean"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [],
|
||||
"max": null,
|
||||
"min": null,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#FF0000",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"grid": {
|
||||
"max": 10,
|
||||
"min": 1
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"mean"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"20": {
|
||||
"color": "orange",
|
||||
"text": "test"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
},
|
||||
{
|
||||
"options": {
|
||||
"30": {
|
||||
"color": "orange",
|
||||
"text": "test1"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
},
|
||||
{
|
||||
"options": {
|
||||
"40": {
|
||||
"color": "orange",
|
||||
"text": "50"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#FF0000",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"grid": {
|
||||
"max": 10,
|
||||
"min": 1
|
||||
},
|
||||
"id": 4,
|
||||
"legend": true,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"mean"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "rgb(31, 120, 193)",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"nullValueMode": "connected",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "areaF2"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 43
|
||||
},
|
||||
"id": 5,
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"postfix": "b",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "a",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "grafana-singlestat-panel",
|
||||
"type": "stat",
|
||||
"valueFontSize": "80%"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"match": "null",
|
||||
"result": {
|
||||
"text": "N/A"
|
||||
}
|
||||
},
|
||||
"type": "special"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 43
|
||||
},
|
||||
"id": 6,
|
||||
"maxDataPoints": 100,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"mean"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "singlestat (old, internal. Migrated if schema \u003c 28)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 43
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"code": {
|
||||
"language": "plaintext",
|
||||
"showLineNumbers": false,
|
||||
"showMiniMap": false
|
||||
},
|
||||
"content": "# Singlestat \u003e\u003e Stat\n\nKnown issues:\n* limited options",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"pluginVersion": "1.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Status + Notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"id": 8,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"apiVersion": "v1",
|
||||
"type": "prometheus",
|
||||
"uid": "default-ds-uid"
|
||||
},
|
||||
"expr": "rate(http_requests_total[5m])",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "",
|
||||
"schemaVersion": 41,
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
]
|
||||
},
|
||||
"title": "V28 Singlestat and Variable Properties Migration Test Dashboard"
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package testutil
|
||||
|
||||
import "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
import (
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
)
|
||||
|
||||
type TestDataSourceProvider struct{}
|
||||
|
||||
type TestPanelProvider struct {
|
||||
customPanels []schemaversion.PanelPluginInfo
|
||||
}
|
||||
|
||||
func (m *TestDataSourceProvider) GetDataSourceInfo() []schemaversion.DataSourceInfo {
|
||||
return []schemaversion.DataSourceInfo{
|
||||
{
|
||||
@@ -57,7 +63,50 @@ func (m *TestDataSourceProvider) GetDataSourceInfo() []schemaversion.DataSourceI
|
||||
}
|
||||
}
|
||||
|
||||
// GetTestProvider returns a singleton instance of the test provider
|
||||
func GetTestProvider() *TestDataSourceProvider {
|
||||
func (m *TestPanelProvider) GetPanels() []schemaversion.PanelPluginInfo {
|
||||
if len(m.customPanels) > 0 {
|
||||
return m.customPanels
|
||||
}
|
||||
|
||||
// Default panels
|
||||
return []schemaversion.PanelPluginInfo{
|
||||
{
|
||||
ID: "gauge",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
{
|
||||
ID: "stat",
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TestPanelProvider) GetPanelPlugin(id string) schemaversion.PanelPluginInfo {
|
||||
// check if it exists in the list of mocked panels
|
||||
for _, panel := range m.GetPanels() {
|
||||
if panel.ID == id {
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
return schemaversion.PanelPluginInfo{}
|
||||
}
|
||||
|
||||
// GetTestDataSourceProvider returns a singleton instance of the test provider
|
||||
func GetTestDataSourceProvider() *TestDataSourceProvider {
|
||||
return &TestDataSourceProvider{}
|
||||
}
|
||||
|
||||
// GetTestPanelProvider returns a singleton instance of the test panel provider
|
||||
func GetTestPanelProvider() *TestPanelProvider {
|
||||
return &TestPanelProvider{}
|
||||
}
|
||||
|
||||
// GetTestPanelProviderWithCustomPanels returns a test panel provider with custom panels
|
||||
func GetTestPanelProviderWithCustomPanels(customPanels []schemaversion.PanelPluginInfo) *TestPanelProvider {
|
||||
return &TestPanelProvider{
|
||||
customPanels: customPanels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
||||
migration.Initialize(testutil.GetTestProvider())
|
||||
migration.Initialize(testutil.GetTestDataSourceProvider(), testutil.GetTestPanelProvider())
|
||||
tests := []struct {
|
||||
name string
|
||||
inputObj runtime.Object
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
|
||||
type PluginStorePanelProvider struct {
|
||||
pluginStore pluginstore.Store
|
||||
buildVersion string
|
||||
}
|
||||
|
||||
func (p *PluginStorePanelProvider) GetPanels() []schemaversion.PanelPluginInfo {
|
||||
plugins := p.pluginStore.Plugins(context.Background(), plugins.TypePanel)
|
||||
|
||||
panels := make([]schemaversion.PanelPluginInfo, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
version := plugin.Info.Version
|
||||
if version == "" {
|
||||
version = p.buildVersion
|
||||
}
|
||||
panels[i] = schemaversion.PanelPluginInfo{
|
||||
ID: plugin.ID,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
return panels
|
||||
}
|
||||
|
||||
func (p *PluginStorePanelProvider) GetPanelPlugin(id string) schemaversion.PanelPluginInfo {
|
||||
for _, plugin := range p.GetPanels() {
|
||||
if plugin.ID == id {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
|
||||
return schemaversion.PanelPluginInfo{}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPluginStorePanelProvider_GetPanels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
plugins []pluginstore.Plugin
|
||||
buildVersion string
|
||||
expectedPanels []schemaversion.PanelPluginInfo
|
||||
}{
|
||||
{
|
||||
name: "should return all panel plugins with their versions",
|
||||
plugins: []pluginstore.Plugin{
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "gauge", Info: plugins.Info{Version: "1.0.0"}},
|
||||
},
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "stat", Info: plugins.Info{Version: "2.0.0"}},
|
||||
},
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "timeseries", Info: plugins.Info{Version: "3.0.0"}},
|
||||
},
|
||||
},
|
||||
buildVersion: "10.0.0",
|
||||
expectedPanels: []schemaversion.PanelPluginInfo{
|
||||
{ID: "gauge", Version: "1.0.0"},
|
||||
{ID: "stat", Version: "2.0.0"},
|
||||
{ID: "timeseries", Version: "3.0.0"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should use build version when plugin version is empty",
|
||||
plugins: []pluginstore.Plugin{
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "gauge", Info: plugins.Info{Version: ""}},
|
||||
},
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "stat", Info: plugins.Info{Version: "2.0.0"}},
|
||||
},
|
||||
},
|
||||
buildVersion: "10.0.0",
|
||||
expectedPanels: []schemaversion.PanelPluginInfo{
|
||||
{ID: "gauge", Version: "10.0.0"},
|
||||
{ID: "stat", Version: "2.0.0"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return empty slice when no plugins",
|
||||
plugins: []pluginstore.Plugin{},
|
||||
buildVersion: "10.0.0",
|
||||
expectedPanels: []schemaversion.PanelPluginInfo{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock plugin store
|
||||
mockStore := &mockPluginStore{
|
||||
plugins: tt.plugins,
|
||||
}
|
||||
|
||||
// Create mock setting
|
||||
mockSetting := &setting.Cfg{
|
||||
BuildVersion: tt.buildVersion,
|
||||
}
|
||||
|
||||
// Create provider
|
||||
provider := &PluginStorePanelProvider{
|
||||
pluginStore: mockStore,
|
||||
buildVersion: mockSetting.BuildVersion,
|
||||
}
|
||||
|
||||
// Call the function
|
||||
result := provider.GetPanels()
|
||||
|
||||
// Assert results
|
||||
assert.Len(t, result, len(tt.expectedPanels))
|
||||
for i, expected := range tt.expectedPanels {
|
||||
assert.Equal(t, expected.ID, result[i].ID)
|
||||
assert.Equal(t, expected.Version, result[i].Version)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStorePanelProvider_GetPanelPlugin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
plugins []pluginstore.Plugin
|
||||
buildVersion string
|
||||
searchID string
|
||||
expectedPanel schemaversion.PanelPluginInfo
|
||||
}{
|
||||
{
|
||||
name: "should return panel plugin when found",
|
||||
plugins: []pluginstore.Plugin{
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "gauge", Info: plugins.Info{Version: "1.0.0"}},
|
||||
},
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "stat", Info: plugins.Info{Version: "2.0.0"}},
|
||||
},
|
||||
},
|
||||
buildVersion: "10.0.0",
|
||||
searchID: "stat",
|
||||
expectedPanel: schemaversion.PanelPluginInfo{
|
||||
ID: "stat",
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return panel plugin with build version when plugin version is empty",
|
||||
plugins: []pluginstore.Plugin{
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "gauge", Info: plugins.Info{Version: ""}},
|
||||
},
|
||||
},
|
||||
buildVersion: "10.0.0",
|
||||
searchID: "gauge",
|
||||
expectedPanel: schemaversion.PanelPluginInfo{
|
||||
ID: "gauge",
|
||||
Version: "10.0.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return empty panel plugin when not found",
|
||||
plugins: []pluginstore.Plugin{
|
||||
{
|
||||
JSONData: plugins.JSONData{ID: "gauge", Info: plugins.Info{Version: "1.0.0"}},
|
||||
},
|
||||
},
|
||||
buildVersion: "10.0.0",
|
||||
searchID: "nonexistent",
|
||||
expectedPanel: schemaversion.PanelPluginInfo{},
|
||||
},
|
||||
{
|
||||
name: "should return empty panel plugin when no plugins exist",
|
||||
plugins: []pluginstore.Plugin{},
|
||||
buildVersion: "10.0.0",
|
||||
searchID: "gauge",
|
||||
expectedPanel: schemaversion.PanelPluginInfo{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockStore := &mockPluginStore{
|
||||
plugins: tt.plugins,
|
||||
}
|
||||
|
||||
mockSetting := &setting.Cfg{
|
||||
BuildVersion: tt.buildVersion,
|
||||
}
|
||||
|
||||
provider := &PluginStorePanelProvider{
|
||||
pluginStore: mockStore,
|
||||
buildVersion: mockSetting.BuildVersion,
|
||||
}
|
||||
|
||||
result := provider.GetPanelPlugin(tt.searchID)
|
||||
|
||||
assert.Equal(t, tt.expectedPanel.ID, result.ID)
|
||||
assert.Equal(t, tt.expectedPanel.Version, result.Version)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockPluginStore struct {
|
||||
plugins []pluginstore.Plugin
|
||||
}
|
||||
|
||||
func (m *mockPluginStore) Plugin(ctx context.Context, pluginID string) (pluginstore.Plugin, bool) {
|
||||
for _, p := range m.plugins {
|
||||
if p.ID == pluginID {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return pluginstore.Plugin{}, false
|
||||
}
|
||||
|
||||
func (m *mockPluginStore) Plugins(ctx context.Context, pluginTypes ...plugins.Type) []pluginstore.Plugin {
|
||||
return m.plugins
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/librarypanels"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
@@ -101,6 +102,7 @@ func RegisterAPIService(
|
||||
apiregistration builder.APIRegistrar,
|
||||
dashboardService dashboards.DashboardService,
|
||||
provisioningDashboardService dashboards.DashboardProvisioningService,
|
||||
pluginStore pluginstore.Store,
|
||||
datasourceService datasources.DataSourceService,
|
||||
dashboardPermissions dashboards.PermissionsRegistrationService,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
@@ -150,6 +152,9 @@ func RegisterAPIService(
|
||||
}
|
||||
migration.Initialize(&datasourceInfoProvider{
|
||||
datasourceService: datasourceService,
|
||||
}, &PluginStorePanelProvider{
|
||||
pluginStore: pluginStore,
|
||||
buildVersion: cfg.BuildVersion,
|
||||
})
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
|
||||
@@ -732,7 +732,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
|
||||
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
|
||||
ldapImpl := service10.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService)
|
||||
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
|
||||
featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer)
|
||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer)
|
||||
@@ -1297,7 +1297,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
|
||||
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
|
||||
ldapImpl := service10.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService)
|
||||
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
|
||||
featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer)
|
||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
|
||||
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 { DASHBOARD_SCHEMA_VERSION } from './DashboardMigrator';
|
||||
import { DashboardModel } from './DashboardModel';
|
||||
@@ -72,9 +74,12 @@ const dataSources = {
|
||||
}),
|
||||
};
|
||||
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
|
||||
describe('Backend / Frontend result comparison', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
});
|
||||
|
||||
const inputDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
@@ -109,17 +114,69 @@ describe('Backend / Frontend result comparison', () => {
|
||||
jsonInputs.forEach((inputFile) => {
|
||||
it(`should migrate ${inputFile} correctly`, async () => {
|
||||
const jsonInput = JSON.parse(readFileSync(path.join(inputDir, inputFile), 'utf8'));
|
||||
|
||||
const backendOutput = JSON.parse(readFileSync(path.join(outputDir, inputFile), 'utf8'));
|
||||
|
||||
// Make sure the backend output always migrates to the latest version
|
||||
expect(backendOutput.schemaVersion).toEqual(DASHBOARD_SCHEMA_VERSION);
|
||||
|
||||
// Compare both migrations, when mounted in dashboard model, after serializing to JSON are the same.
|
||||
// This avoid issues with the default values in the frontend, wheter they were set in the input JSON or not.
|
||||
const frontendMigrationResult = new DashboardModel(jsonInput).getSaveModelClone();
|
||||
const backendMigrationResult = new DashboardModel(backendOutput).getSaveModelClone();
|
||||
expect(backendMigrationResult).toMatchObject(frontendMigrationResult);
|
||||
// Create dashboard models
|
||||
const frontendModel = new DashboardModel(jsonInput);
|
||||
const backendModel = new DashboardModel(backendOutput);
|
||||
|
||||
/*
|
||||
Migration from schema V27 involves migrating angular singlestat panels to stat panels
|
||||
These panels are auto migrated where PanelModel.restoreModel() is called in the constructor,
|
||||
and the autoMigrateFrom is set and type is set to "stat". So this logic will not run.
|
||||
if (oldVersion < 28) {
|
||||
panelUpgrades.push((panel: PanelModel) => {
|
||||
if (panel.type === 'singlestat') {
|
||||
return migrateSinglestat(panel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Furthermore, the PanelModel.pluginLoaded is run in the old architecture through a redux action so it will not run in this test.
|
||||
In the scenes architecture the angular migration logic runs through a migration handler inside transformSaveModelToScene.ts
|
||||
_UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel),
|
||||
We need to manually run the pluginLoaded logic to ensure the panels are migrated correctly.
|
||||
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) {
|
||||
for (const panel of frontendModel.panels) {
|
||||
if (panel.type === 'stat') {
|
||||
// Set the plugin version if it doesn't exist
|
||||
if (!statPanelPlugin.meta.info) {
|
||||
statPanelPlugin.meta.info = {
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
url: 'url/to/GrafanaLabs',
|
||||
},
|
||||
description: 'stat plugin',
|
||||
links: [{ name: 'project', url: 'one link' }],
|
||||
logos: { small: 'small/logo', large: 'large/logo' },
|
||||
screenshots: [],
|
||||
updated: '2024-01-01',
|
||||
version: '1.0.0',
|
||||
};
|
||||
}
|
||||
if (!statPanelPlugin.meta.info.version) {
|
||||
statPanelPlugin.meta.info.version = '1.0.0';
|
||||
}
|
||||
|
||||
await panel.pluginLoaded(statPanelPlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const frontendMigrationResult = frontendModel.getSaveModelClone();
|
||||
const backendMigrationResult = backendModel.getSaveModelClone();
|
||||
|
||||
// Although getSaveModelClone() runs sortedDeepCloneWithoutNulls() internally,
|
||||
// we run it again to ensure consistent handling of null values (like threshold -Infinity values)
|
||||
// Because Go and TS handle -Infinity differently.
|
||||
const cleanedFrontendResult = sortedDeepCloneWithoutNulls(frontendMigrationResult);
|
||||
|
||||
expect(backendMigrationResult).toMatchObject(cleanedFrontendResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user