Dashboard Migrations: V2 - legacy services.filter and graphite panel type (#112282)

* migrate to v2

* Fix tests

* graphite panels should be auto-migrated

* lint

* Provisioning: Fix dashboard export to preserve original API version

* Add error handling for the edge case where conversion fails but no
storedVersion is available.
This commit is contained in:
Haris Rozajac
2025-10-15 10:34:12 -06:00
committed by GitHub
parent 5b7e6ef2e1
commit d9067b27eb
13 changed files with 734 additions and 78 deletions
@@ -946,32 +946,13 @@ func applyPanelAutoMigration(panel map[string]interface{}) {
var newType string
// Graph needs special logic as it can be migrated to multiple panels
if panelType == "graph" {
// Including graphite which was previously migrated to graph in the schema version 2 migration in DashboardMigrator.ts
// but this was a bug because in there graphite was set to graph, but since those migrations run
// after PanelModel.restoreModel where autoMigrateFrom is set, this caused the graph migration to be skipped.
// And this resulted in a dashboard with invalid panels.
if panelType == "graph" || panelType == "graphite" {
// Check xaxis mode for special cases
if xaxis, ok := panel["xaxis"].(map[string]interface{}); ok {
if mode, ok := xaxis["mode"].(string); ok {
switch mode {
case "series":
// Check legend values for bargauge
if legend, ok := panel["legend"].(map[string]interface{}); ok {
if values, ok := legend["values"].(bool); ok && values {
newType = "bargauge"
} else {
newType = "barchart"
}
} else {
newType = "barchart"
}
case "histogram":
newType = "histogram"
}
}
}
// Default graph migration to timeseries
if newType == "" {
newType = "timeseries"
}
newType = getGraphAutoMigration(panel)
} else {
// Check autoMigrateAngular mapping
autoMigrateAngular := map[string]string{
@@ -995,6 +976,36 @@ func applyPanelAutoMigration(panel map[string]interface{}) {
}
}
func getGraphAutoMigration(panel map[string]interface{}) string {
newType := ""
if xaxis, ok := panel["xaxis"].(map[string]interface{}); ok {
if mode, ok := xaxis["mode"].(string); ok {
switch mode {
case "series":
// Check legend values for bargauge
if legend, ok := panel["legend"].(map[string]interface{}); ok {
if values, ok := legend["values"].(bool); ok && values {
newType = "bargauge"
} else {
newType = "barchart"
}
} else {
newType = "barchart"
}
case "histogram":
newType = "histogram"
}
}
}
// Default graph migration to timeseries
if newType == "" {
newType = "timeseries"
}
return newType
}
// removeNullValuesRecursively removes null values from nested objects and arrays
// This matches the frontend's JSON.stringify/parse behavior
func removeNullValuesRecursively(data interface{}) {
@@ -7,7 +7,7 @@ import (
)
const (
MIN_VERSION = 2
MIN_VERSION = 0
LATEST_VERSION = 42
)
@@ -35,6 +35,7 @@ type PanelPluginInfo struct {
func GetMigrations(dsInfoProvider DataSourceInfoProvider) map[int]SchemaVersionMigrationFunc {
return map[int]SchemaVersionMigrationFunc{
2: V2,
3: V3,
4: V4,
5: V5,
@@ -0,0 +1,71 @@
package schemaversion
import "context"
// V2 migrates dashboard from schema version 0 or 1 to 2.
// This migration handles the legacy services.filter structure.
// It matches the frontend DashboardMigrator.ts logic for oldVersion < 2 && finalTargetVersion >= 2.
//
// Key migrations:
// 1. Services filter migration: old.services.filter.time -> dashboard.time
// 2. Services filter migration: old.services.filter.list -> dashboard.templating.list
//
// Example before migration:
//
// {
// "schemaVersion": 1,
// "services": {
// "filter": {
// "time": {"from": "now-1h", "to": "now"},
// "list": [{"name": "var1", "type": "query"}]
// }
// }
// }
//
// Example after migration:
//
// {
// "schemaVersion": 2,
// "time": {"from": "now-1h", "to": "now"},
// "templating": {
// "list": [{"name": "var1", "type": "query"}]
// }
// }
func V2(_ context.Context, dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = 2
// Migrate services.filter structure
migrateServicesFilter(dashboard)
return nil
}
// migrateServicesFilter migrates the legacy services.filter structure
func migrateServicesFilter(dashboard map[string]interface{}) {
services, ok := dashboard["services"].(map[string]interface{})
if !ok {
return
}
filter, ok := services["filter"].(map[string]interface{})
if !ok {
return
}
// Migrate time property
if time, ok := filter["time"]; ok {
dashboard["time"] = time
}
// Migrate templating list
if list, ok := filter["list"]; ok {
if _, exists := dashboard["templating"]; !exists {
dashboard["templating"] = map[string]interface{}{}
}
templating := dashboard["templating"].(map[string]interface{})
templating["list"] = list
}
// Remove the services property after migration
delete(dashboard, "services")
}
@@ -0,0 +1,216 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV2(t *testing.T) {
tests := []migrationTestCase{
{
name: "services filter migration moves time and templating list",
input: map[string]interface{}{
"title": "V2 Services Filter Migration Test",
"schemaVersion": 1,
"services": map[string]interface{}{
"filter": map[string]interface{}{
"time": map[string]interface{}{
"from": "now-1h",
"to": "now",
},
"list": []interface{}{
map[string]interface{}{
"name": "var1",
"type": "query",
},
},
},
},
},
expected: map[string]interface{}{
"title": "V2 Services Filter Migration Test",
"schemaVersion": 2,
"time": map[string]interface{}{
"from": "now-1h",
"to": "now",
},
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "var1",
"type": "query",
},
},
},
},
},
{
name: "comprehensive services filter migration",
input: map[string]interface{}{
"title": "V2 Comprehensive Services Migration Test",
"schemaVersion": 1,
"services": map[string]interface{}{
"filter": map[string]interface{}{
"time": map[string]interface{}{
"from": "now-6h",
"to": "now",
},
"list": []interface{}{
map[string]interface{}{
"name": "server",
"type": "query",
},
},
},
},
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graphite",
"title": "CPU Usage",
},
},
},
expected: map[string]interface{}{
"title": "V2 Comprehensive Services Migration Test",
"schemaVersion": 2,
"time": map[string]interface{}{
"from": "now-6h",
"to": "now",
},
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "server",
"type": "query",
},
},
},
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graphite", // Panel types are not migrated by V2 anymore
"title": "CPU Usage",
},
},
},
},
{
name: "dashboard with no services or panels",
input: map[string]interface{}{
"title": "V2 Minimal Migration Test",
"schemaVersion": 1,
},
expected: map[string]interface{}{
"title": "V2 Minimal Migration Test",
"schemaVersion": 2,
},
},
{
name: "services filter with only time (no list)",
input: map[string]interface{}{
"title": "V2 Services Time Only Test",
"schemaVersion": 1,
"services": map[string]interface{}{
"filter": map[string]interface{}{
"time": map[string]interface{}{
"from": "now-2h",
"to": "now",
},
},
},
},
expected: map[string]interface{}{
"title": "V2 Services Time Only Test",
"schemaVersion": 2,
"time": map[string]interface{}{
"from": "now-2h",
"to": "now",
},
},
},
{
name: "services filter with only list (no time)",
input: map[string]interface{}{
"title": "V2 Services List Only Test",
"schemaVersion": 1,
"services": map[string]interface{}{
"filter": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "env",
"type": "custom",
},
},
},
},
},
expected: map[string]interface{}{
"title": "V2 Services List Only Test",
"schemaVersion": 2,
"templating": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"name": "env",
"type": "custom",
},
},
},
},
},
{
name: "panels remain unchanged when no services filter",
input: map[string]interface{}{
"title": "V2 Panels Unchanged Test",
"schemaVersion": 1,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graphite",
"legend": true,
"y_format": "short",
"y2_format": "bytes",
"grid": map[string]interface{}{
"min": 0,
"max": 100,
},
},
map[string]interface{}{
"id": 2,
"type": "graph",
"legend": map[string]interface{}{
"show": false,
},
},
},
},
expected: map[string]interface{}{
"title": "V2 Panels Unchanged Test",
"schemaVersion": 2,
"panels": []interface{}{
map[string]interface{}{
"id": 1,
"type": "graphite", // Panel migrations handled by auto-migration
"legend": true,
"y_format": "short",
"y2_format": "bytes",
"grid": map[string]interface{}{
"min": 0,
"max": 100,
},
},
map[string]interface{}{
"id": 2,
"type": "graph",
"legend": map[string]interface{}{
"show": false,
},
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V2)
}
@@ -0,0 +1,89 @@
{
"schemaVersion": 1,
"title": "V2 Comprehensive Migration Test Dashboard",
"services": {
"filter": {
"time": {
"from": "now-6h",
"to": "now"
},
"list": [
{
"name": "server",
"type": "query",
"datasource": "prometheus",
"query": "label_values(server)"
},
{
"name": "env",
"type": "custom",
"options": [
{"text": "Production", "value": "prod"},
{"text": "Staging", "value": "stage"}
]
}
]
}
},
"panels": [
{
"id": 1,
"type": "graphite",
"title": "CPU Usage",
"legend": true,
"grid": {
"min": 0,
"max": 100
},
"y_format": "percent",
"y2_format": "short",
"targets": [
{
"refId": "A",
"target": "cpu.usage"
}
]
},
{
"id": 2,
"type": "graph",
"title": "Memory Usage",
"legend": false,
"grid": {
"min": 0
},
"y_format": "bytes",
"targets": [
{
"refId": "A",
"target": "memory.usage"
}
]
},
{
"id": 3,
"type": "table",
"title": "Server Stats",
"legend": true,
"grid": {
"min": 0,
"max": 100
},
"y_format": "short",
"y2_format": "bytes"
},
{
"id": 4,
"type": "graphite",
"title": "Disk I/O",
"legend": true,
"y2_format": "Bps",
"targets": [
{
"refId": "A",
"target": "disk.io"
}
]
}
]
}
@@ -0,0 +1,168 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"autoMigrateFrom": "graphite",
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"grid": {
"max": 100,
"min": 0
},
"id": 1,
"legend": true,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"target": "cpu.usage"
}
],
"title": "CPU Usage",
"type": "timeseries",
"y2_format": "short",
"y_format": "percent"
},
{
"autoMigrateFrom": "graph",
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"grid": {
"min": 0
},
"id": 2,
"legend": false,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"target": "memory.usage"
}
],
"title": "Memory Usage",
"type": "timeseries",
"y_format": "bytes"
},
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"grid": {
"max": 100,
"min": 0
},
"id": 3,
"legend": true,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A"
}
],
"title": "Server Stats",
"type": "table",
"y2_format": "bytes",
"y_format": "short"
},
{
"autoMigrateFrom": "graphite",
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"id": 4,
"legend": true,
"targets": [
{
"datasource": {
"apiVersion": "v1",
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"target": "disk.io"
}
],
"title": "Disk I/O",
"type": "timeseries",
"y2_format": "Bps"
}
],
"refresh": "",
"schemaVersion": 42,
"tags": [],
"templating": {
"list": [
{
"datasource": "prometheus",
"name": "server",
"options": [],
"query": "label_values(server)",
"refresh": 1,
"type": "query"
},
{
"name": "env",
"options": [
{
"text": "Production",
"value": "prod"
},
{
"text": "Staging",
"value": "stage"
}
],
"type": "custom"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "V2 Comprehensive Migration Test Dashboard",
"weekStart": ""
}
@@ -0,0 +1,121 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"autoMigrateFrom": "graphite",
"grid": {
"max": 100,
"min": 0
},
"id": 1,
"legend": true,
"targets": [
{
"refId": "A",
"target": "cpu.usage"
}
],
"title": "CPU Usage",
"type": "timeseries",
"y2_format": "short",
"y_format": "percent"
},
{
"autoMigrateFrom": "graph",
"grid": {
"min": 0
},
"id": 2,
"legend": false,
"targets": [
{
"refId": "A",
"target": "memory.usage"
}
],
"title": "Memory Usage",
"type": "timeseries",
"y_format": "bytes"
},
{
"grid": {
"max": 100,
"min": 0
},
"id": 3,
"legend": true,
"title": "Server Stats",
"type": "table",
"y2_format": "bytes",
"y_format": "short"
},
{
"autoMigrateFrom": "graphite",
"id": 4,
"legend": true,
"targets": [
{
"refId": "A",
"target": "disk.io"
}
],
"title": "Disk I/O",
"type": "timeseries",
"y2_format": "Bps"
}
],
"schemaVersion": 2,
"tags": [],
"templating": {
"list": [
{
"datasource": "prometheus",
"name": "server",
"options": [],
"query": "label_values(server)",
"type": "query"
},
{
"name": "env",
"options": [
{
"text": "Production",
"value": "prod"
},
{
"text": "Staging",
"value": "stage"
}
],
"type": "custom"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "V2 Comprehensive Migration Test Dashboard",
"weekStart": ""
}
+1 -1
View File
@@ -2970,7 +2970,7 @@
"count": 2
},
"@typescript-eslint/no-explicit-any": {
"count": 17
"count": 16
}
},
"public/app/features/dashboard/state/DashboardModel.repeat.test.ts": {
@@ -41,9 +41,11 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
if kind.GroupResource() == resources.DashboardResource.GroupResource() {
var v2clientAlphaV1, v2clientAlphaV2 dynamic.ResourceInterface
shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
// Check if there's a stored version in the conversion status.
// This indicates the original API version the dashboard was created with,
// which should be preserved during export regardless of whether conversion succeeded or failed.
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
if storedVersion != "" {
// For v2 we need to request the original version
if strings.HasPrefix(storedVersion, "v2alpha1") {
if v2clientAlphaV1 == nil {
@@ -73,6 +75,13 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
return nil, fmt.Errorf("unsupported dashboard version: %s", storedVersion)
}
// If conversion failed but there's no storedVersion, this is an error condition
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
return nil, fmt.Errorf("conversion failed but no storedVersion available")
}
return item, nil
}
}
@@ -1,4 +1,4 @@
import { each, find, findIndex, flattenDeep, isArray, isBoolean, isString, map, max, some } from 'lodash';
import { each, find, findIndex, flattenDeep, isArray, isString, map, max, some } from 'lodash';
import {
AnnotationQuery,
@@ -119,50 +119,8 @@ export class DashboardMigrator {
}
}
panelUpgrades.push((panel: any) => {
// rename panel type
if (panel.type === 'graphite') {
panel.type = 'graph';
}
if (panel.type !== 'graph') {
return panel;
}
if (isBoolean(panel.legend)) {
panel.legend = { show: panel.legend };
}
if (panel.grid) {
if (panel.grid.min) {
panel.grid.leftMin = panel.grid.min;
delete panel.grid.min;
}
if (panel.grid.max) {
panel.grid.leftMax = panel.grid.max;
delete panel.grid.max;
}
}
if (panel.y_format) {
if (!panel.y_formats) {
panel.y_formats = [];
}
panel.y_formats[0] = panel.y_format;
delete panel.y_format;
}
if (panel.y2_format) {
if (!panel.y_formats) {
panel.y_formats = [];
}
panel.y_formats[1] = panel.y2_format;
delete panel.y2_format;
}
return panel;
});
// we used to have graphite panel type migration logic here
// but this is handled by auto migration, see public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts
}
// schema version 3 changes
@@ -103,10 +103,14 @@ describe('Backend / Frontend single version migration result comparison', () =>
expect(backendMigrationResult.schemaVersion).toEqual(targetVersion);
// Migrate dashboard in Frontend.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let migratedTemplatingList: any[] = jsonInput?.templating?.list ?? [];
const frontendModel = new DashboardModel(jsonInput, undefined, {
targetSchemaVersion: targetVersion,
getVariablesFromState: () => jsonInput?.templating?.list ?? [],
getVariablesFromState: () => migratedTemplatingList,
});
// Update the templating list reference after migration
migratedTemplatingList = frontendModel.templating?.list ?? [];
const frontendMigrationResult = frontendModel.getSaveModelClone();
@@ -71,9 +71,13 @@ describe('Backend / Frontend result comparison', () => {
expect(backendMigrationResult.schemaVersion).toEqual(DASHBOARD_SCHEMA_VERSION);
// Migrate dashboard in Frontend.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let migratedTemplatingList: any[] = jsonInput?.templating?.list ?? [];
const frontendModel = new DashboardModel(jsonInput, undefined, {
getVariablesFromState: () => jsonInput?.templating?.list ?? [],
getVariablesFromState: () => migratedTemplatingList,
});
// Update the templating list reference after migration
migratedTemplatingList = frontendModel.templating?.list ?? [];
const frontendMigrationResult = frontendModel.getSaveModelClone();
@@ -2,7 +2,11 @@ import { autoMigrateAngular } from './PanelModel';
export function getPanelPluginToMigrateTo(panel: any): string | undefined {
// Graph needs special logic as it can be migrated to multiple panels
if (panel.type === 'graph') {
// Also, graphite was previously migrated to graph in the schema version 2 migration in DashboardMigrator.ts
// but this was a bug because in there graphite was set to graph, but since those migrations run
// after PanelModel.restoreModel where autoMigrateFrom is set, this caused the graph migration to be skipped.
// And this resulted in a dashboard with invalid panels.
if (panel.type === 'graph' || panel.type === 'graphite') {
if (panel.xaxis?.mode === 'series') {
if (panel.legend?.values) {
return 'bargauge';