diff --git a/apps/dashboard/pkg/migration/schemaversion/migrations.go b/apps/dashboard/pkg/migration/schemaversion/migrations.go index c1d1fec64b5..569e5adafb0 100644 --- a/apps/dashboard/pkg/migration/schemaversion/migrations.go +++ b/apps/dashboard/pkg/migration/schemaversion/migrations.go @@ -5,7 +5,7 @@ import ( ) const ( - MIN_VERSION = 27 + MIN_VERSION = 26 LATEST_VERSION = 41 ) @@ -38,6 +38,7 @@ type PanelPluginInfoProvider interface { func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc { return map[int]SchemaVersionMigrationFunc{ + 27: V27, 28: V28(panelProvider), 29: V29, 30: V30, diff --git a/apps/dashboard/pkg/migration/schemaversion/v27.go b/apps/dashboard/pkg/migration/schemaversion/v27.go new file mode 100644 index 00000000000..cdbe98a5907 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v27.go @@ -0,0 +1,167 @@ +package schemaversion + +// V27 migrates repeated panels and constant variables. +// +// The migration performs two main tasks: +// 1. Removes repeated panel leftovers by filtering out panels with repeatPanelId or repeatByRow +// 2. Migrates constant variables to textbox variables with proper current/options structure +// +// The migration includes comprehensive logic from the frontend: +// - Panel filtering to remove repeated panels +// - Constant variable migration with proper current/options structure +// - Support for both constant and textbox variable types +// +// Example before migration: +// +// "panels": [ +// { +// "id": 1, +// "type": "graph", +// "repeatPanelId": "panel1" +// }, +// { +// "id": 2, +// "type": "row", +// "panels": [ +// { +// "id": 3, +// "type": "graph", +// "repeatPanelId": "panel2" +// } +// ] +// } +// ], +// "templating": { +// "list": [ +// { +// "name": "constant_var", +// "type": "constant", +// "query": "default_value", +// "hide": 0 +// } +// ] +// } +// +// Example after migration: +// +// "panels": [ +// { +// "id": 2, +// "type": "row", +// "panels": [] +// } +// ], +// "templating": { +// "list": [ +// { +// "name": "constant_var", +// "type": "textbox", +// "query": "default_value", +// "current": { +// "selected": true, +// "text": "default_value", +// "value": "default_value" +// }, +// "options": [ +// { +// "selected": true, +// "text": "default_value", +// "value": "default_value" +// } +// ], +// "hide": 0 +// } +// ] +// } +func V27(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 27 + + // Remove repeated panels + if panels, ok := dashboard["panels"].([]interface{}); ok { + dashboard["panels"] = removeRepeatedPanels(panels) + } + + // Migrate constant variables + 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 { + migrateConstantVariable(variable) + } + } + } + } + + return nil +} + +// removeRepeatedPanels filters out panels with repeatPanelId or repeatByRow properties +// and cleans up repeated panels in collapsed rows +func removeRepeatedPanels(panels []interface{}) []interface{} { + newPanels := []interface{}{} + + for _, panel := range panels { + p, ok := panel.(map[string]interface{}) + if !ok { + continue + } + + // Skip panels with repeatPanelId or repeatByRow + if _, hasRepeatPanelId := p["repeatPanelId"]; hasRepeatPanelId { + continue + } + if _, hasRepeatByRow := p["repeatByRow"]; hasRepeatByRow { + continue + } + + // Filter out repeats in collapsed rows + if p["type"] == "row" { + if nestedPanels, ok := p["panels"].([]interface{}); ok { + var filteredNestedPanels []interface{} + for _, nestedPanel := range nestedPanels { + if np, ok := nestedPanel.(map[string]interface{}); ok { + if _, hasRepeatPanelId := np["repeatPanelId"]; !hasRepeatPanelId { + filteredNestedPanels = append(filteredNestedPanels, nestedPanel) + } + } + } + p["panels"] = filteredNestedPanels + } + } + + newPanels = append(newPanels, panel) + } + + return newPanels +} + +// migrateConstantVariable converts constant variables to textbox variables with proper current/options structure +func migrateConstantVariable(variable map[string]interface{}) { + if variableType, ok := variable["type"].(string); !ok || variableType != "constant" { + return + } + + query := "" + if queryVal, ok := variable["query"].(string); ok { + query = queryVal + } + variable["query"] = query + + current := map[string]interface{}{ + "selected": true, + "text": query, + "value": query, + } + + options := []interface{}{current} + + variable["current"] = current + variable["options"] = options + + // Convert to textbox if hide is 0 (dontHide) or 1 (hideLabel) + if hide, ok := variable["hide"].(float64); ok { + if hide == 0 || hide == 1 { + variable["type"] = "textbox" + } + } +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v27_test.go b/apps/dashboard/pkg/migration/schemaversion/v27_test.go new file mode 100644 index 00000000000..2f79856f412 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v27_test.go @@ -0,0 +1,172 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV27(t *testing.T) { + tests := []migrationTestCase{ + { + name: "remove repeated panels with repeatPanelId and repeatByRow", + input: map[string]interface{}{ + "schemaVersion": 26, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Repeated Panel 1", + "repeatPanelId": "panel1", + }, + map[string]interface{}{ + "id": 2, + "type": "graph", + "title": "Repeated Panel 2", + "repeatByRow": true, + }, + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Normal Panel", + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 27, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Normal Panel", + }, + }, + }, + }, + { + name: "filter repeated panels in row panels", + input: map[string]interface{}{ + "schemaVersion": 26, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "row", + "title": "Row with repeated panels", + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "graph", + "title": "Repeated nested panel", + "repeatPanelId": "nested_panel1", + }, + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Normal nested panel", + }, + }, + }, + map[string]interface{}{ + "id": 4, + "type": "graph", + "title": "Normal panel outside row", + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 27, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "row", + "title": "Row with repeated panels", + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Normal nested panel", + }, + }, + }, + map[string]interface{}{ + "id": 4, + "type": "graph", + "title": "Normal panel outside row", + }, + }, + }, + }, + { + name: "migrate constant variable to textbox with hide=0", + input: map[string]interface{}{ + "schemaVersion": 26, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "name": "constant_var", + "type": "constant", + "query": "default_value", + "hide": 0.0, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 27, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "name": "constant_var", + "type": "textbox", + "query": "default_value", + "hide": 0.0, + "current": map[string]interface{}{ + "selected": true, + "text": "default_value", + "value": "default_value", + }, + "options": []interface{}{ + map[string]interface{}{ + "selected": true, + "text": "default_value", + "value": "default_value", + }, + }, + }, + }, + }, + }, + }, + { + name: "do not migrate non-constant variable", + input: map[string]interface{}{ + "schemaVersion": 26, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "name": "query_var", + "type": "query", + "query": "some_query", + "hide": 0.0, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 27, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "name": "query_var", + "type": "query", + "query": "some_query", + "hide": 0.0, + }, + }, + }, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V27) +} diff --git a/apps/dashboard/pkg/migration/testdata/input/v27.repeated_panels_and_constant_variable.json b/apps/dashboard/pkg/migration/testdata/input/v27.repeated_panels_and_constant_variable.json new file mode 100644 index 00000000000..cc9b178b05a --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v27.repeated_panels_and_constant_variable.json @@ -0,0 +1,43 @@ +{ + "title": "V27 Repeated Panels and Constant Variable Migration Test Dashboard", + "schemaVersion": 26, + "panels": [ + { + "id": 1, + "type": "graph", + "repeatPanelId": "panel1" + }, + { + "id": 2, + "type": "graph", + "title": "Normal Panel" + }, + { + "id": 4, + "type": "row", + "title": "Row with repeated panels", + "panels": [ + { + "id": 5, + "type": "graph", + "repeatPanelId": "panel2" + }, + { + "id": 6, + "type": "graph", + "title": "Normal nested panel" + } + ] + } + ], + "templating": { + "list": [ + { + "name": "constant_var", + "type": "constant", + "query": "default_value", + "hide": 0 + } + ] + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/input/v28.singlestat_and_variable_properties.json b/apps/dashboard/pkg/migration/testdata/input/v28.singlestat_and_variable_properties.json new file mode 100644 index 00000000000..4ebd39ffd3d --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v28.singlestat_and_variable_properties.json @@ -0,0 +1,105 @@ +{ + "title": "V28 Singlestat and Variable Properties Migration Test Dashboard", + "schemaVersion": 27, + "panels": [ + { + "type": "singlestat", + "legend": true, + "thresholds": "10,20,30", + "colors": ["#FF0000", "green", "orange"], + "grid": { "min": 1, "max": 10 }, + "targets": [{ "refId": "A" }, {}] + }, + { + "type": "singlestat", + "thresholds": "10,20,30", + "colors": ["#FF0000", "green", "orange"], + "gauge": { + "show": true, + "thresholdMarkers": true, + "thresholdLabels": false + }, + "grid": { "min": 1, "max": 10 } + }, + { + "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" + } + ] + }, + { + "type": "timeseries", + "targets": [ + { + "refId": "A", + "expr": "rate(http_requests_total[5m])" + } + ] + } + ], + "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"] + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v27.repeated_panels_and_constant_variable.json b/apps/dashboard/pkg/migration/testdata/output/v27.repeated_panels_and_constant_variable.json new file mode 100644 index 00000000000..2f1587a61de --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v27.repeated_panels_and_constant_variable.json @@ -0,0 +1,91 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 2, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Normal Panel", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 4, + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 6, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Normal nested panel", + "type": "graph" + } + ], + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Row with repeated panels", + "type": "row" + } + ], + "refresh": "", + "schemaVersion": 41, + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": "default_value", + "value": "default_value" + }, + "hide": 0, + "name": "constant_var", + "options": [ + { + "selected": true, + "text": "default_value", + "value": "default_value" + } + ], + "query": "default_value", + "type": "textbox" + } + ] + }, + "title": "V27 Repeated Panels and Constant Variable Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v28.singlestat_and_variable_properties.json b/apps/dashboard/pkg/migration/testdata/output/v28.singlestat_and_variable_properties.json new file mode 100644 index 00000000000..a355e37c7f7 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v28.singlestat_and_variable_properties.json @@ -0,0 +1,322 @@ +{ + "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 + }, + "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 + }, + "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": null, + "text": "test" + } + }, + "type": "value" + }, + { + "options": { + "30": { + "color": null, + "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 + }, + "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" + }, + "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": [ + { + "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" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testutil/utils.go b/apps/dashboard/pkg/migration/testutil/utils.go new file mode 100644 index 00000000000..7800093ecfc --- /dev/null +++ b/apps/dashboard/pkg/migration/testutil/utils.go @@ -0,0 +1,15 @@ +package testutil + +import ( + "encoding/json" + "fmt" +) + +func PrettyPrint(label string, i interface{}) { + b, err := json.MarshalIndent(i, "", " ") + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Println(label, string(b)) +}