From 8f2d27044d783254ef8e95269c6c4901cf918337 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri, 8 Aug 2025 02:55:32 -0600 Subject: [PATCH] Dashboard Migrations: V21 data links (#108950) --------- Co-authored-by: Ivan Ortega --- .../pkg/migration/schemaversion/migrations.go | 3 +- .../pkg/migration/schemaversion/v21.go | 120 +++++++++ .../pkg/migration/schemaversion/v21_test.go | 232 ++++++++++++++++++ .../input/v21.data_links_series_to_field.json | 85 +++++++ .../v21.data_links_series_to_field.json | 166 +++++++++++++ 5 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/pkg/migration/schemaversion/v21.go create mode 100644 apps/dashboard/pkg/migration/schemaversion/v21_test.go create mode 100644 apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json create mode 100644 apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json diff --git a/apps/dashboard/pkg/migration/schemaversion/migrations.go b/apps/dashboard/pkg/migration/schemaversion/migrations.go index cccd27dbc21..627f15d4916 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 = 21 + MIN_VERSION = 20 LATEST_VERSION = 41 ) @@ -38,6 +38,7 @@ type PanelPluginInfoProvider interface { func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc { return map[int]SchemaVersionMigrationFunc{ + 21: V21, 22: V22, 23: V23, 24: V24(panelProvider), diff --git a/apps/dashboard/pkg/migration/schemaversion/v21.go b/apps/dashboard/pkg/migration/schemaversion/v21.go new file mode 100644 index 00000000000..a9361f8871b --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v21.go @@ -0,0 +1,120 @@ +package schemaversion + +import ( + "strings" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/utils" +) + +// V21 migrates data links to replace __series.labels with __field.labels. +// This migration updates the variable syntax used in data links from the old series-based +// syntax to the new field-based syntax. +// +// Example before migration: +// +// "panels": [ +// { +// "options": { +// "dataLinks": [ +// { +// "url": "http://example.com?series=${__series.labels}&${__series.labels.a}" +// } +// ], +// "fieldOptions": { +// "defaults": { +// "links": [ +// { +// "url": "http://example.com?series=${__series.labels}&${__series.labels.x}" +// } +// ] +// } +// } +// } +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "options": { +// "dataLinks": [ +// { +// "url": "http://example.com?series=${__field.labels}&${__field.labels.a}" +// } +// ], +// "fieldOptions": { +// "defaults": { +// "links": [ +// { +// "url": "http://example.com?series=${__field.labels}&${__field.labels.x}" +// } +// ] +// } +// } +// } +// } +// ] +func V21(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 21 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + // Update data links in panel options + if options, ok := panel["options"].(map[string]interface{}); ok { + updateDataLinks(options) + updateFieldOptionsLinks(options) + } + } + + return nil +} + +func updateDataLinks(options map[string]interface{}) { + dataLinks, ok := options["dataLinks"].([]interface{}) + if !ok || !utils.IsArray(dataLinks) { + return + } + + for _, link := range dataLinks { + if linkMap, ok := link.(map[string]interface{}); ok { + if url, ok := linkMap["url"].(string); ok { + linkMap["url"] = strings.ReplaceAll(url, "__series.labels", "__field.labels") + } + } + } +} + +func updateFieldOptionsLinks(options map[string]interface{}) { + fieldOptions, ok := options["fieldOptions"].(map[string]interface{}) + if !ok { + return + } + + defaults, ok := fieldOptions["defaults"].(map[string]interface{}) + if !ok { + return + } + + links, ok := defaults["links"].([]interface{}) + if !ok { + return + } + + for _, link := range links { + if linkMap, ok := link.(map[string]interface{}); ok { + if url, ok := linkMap["url"].(string); ok { + linkMap["url"] = strings.ReplaceAll(url, "__series.labels", "__field.labels") + } + } + } +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v21_test.go b/apps/dashboard/pkg/migration/schemaversion/v21_test.go new file mode 100644 index 00000000000..e621c794ced --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v21_test.go @@ -0,0 +1,232 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV21(t *testing.T) { + tests := []migrationTestCase{ + { + name: "panel with data links gets migrated", + input: map[string]interface{}{ + "title": "V21 Data Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.a}", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 Data Links Migration Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__field.labels}&${__field.labels.a}", + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with field options links gets migrated", + input: map[string]interface{}{ + "title": "V21 Field Options Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "stat", + "title": "Panel with field options links", + "id": 2, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.x}", + }, + }, + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 Field Options Links Migration Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "stat", + "title": "Panel with field options links", + "id": 2, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__field.labels}&${__field.labels.x}", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with both data links and field options links gets migrated", + input: map[string]interface{}{ + "title": "V21 Both Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel with both link types", + "id": 3, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.labels}", + }, + }, + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?field=${__series.labels}", + }, + }, + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 Both Links Migration Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel with both link types", + "id": 3, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__field.labels}", + }, + }, + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?field=${__field.labels}", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "panel without __series.labels is unchanged", + input: map[string]interface{}{ + "title": "V21 No Series Labels Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without series labels", + "id": 4, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?other=${__field.labels}", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 No Series Labels Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without series labels", + "id": 4, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?other=${__field.labels}", + }, + }, + }, + }, + }, + }, + }, + { + name: "panel without options is unchanged", + input: map[string]interface{}{ + "title": "V21 No Options Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without options", + "id": 5, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 No Options Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without options", + "id": 5, + }, + }, + }, + }, + { + name: "dashboard without panels is unchanged", + input: map[string]interface{}{ + "title": "V21 No Panels Test Dashboard", + "schemaVersion": 20, + }, + expected: map[string]interface{}{ + "title": "V21 No Panels Test Dashboard", + "schemaVersion": 21, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V21) +} diff --git a/apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json b/apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json new file mode 100644 index 00000000000..6a9c046137d --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json @@ -0,0 +1,85 @@ +{ + "title": "V21 Data Links Series to Field Migration Test Dashboard", + "schemaVersion": 20, + "panels": [ + { + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": { + "dataLinks": [ + { + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.a}", + "title": "Data Link 1" + }, + { + "url": "http://anotherlink.com?param=${__series.labels}", + "title": "Data Link 2" + } + ] + } + }, + { + "type": "stat", + "title": "Panel with field options links", + "id": 2, + "options": { + "fieldOptions": { + "defaults": { + "links": [ + { + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.x}", + "title": "Field Link 1" + }, + { + "url": "http://fieldlink.com?field=${__series.labels}", + "title": "Field Link 2" + } + ] + } + } + } + }, + { + "type": "graph", + "title": "Panel with both link types", + "id": 3, + "options": { + "dataLinks": [ + { + "url": "http://mylink.com?series=${__series.labels}", + "title": "Graph Data Link" + } + ], + "fieldOptions": { + "defaults": { + "links": [ + { + "url": "http://mylink.com?field=${__series.labels}", + "title": "Graph Field Link" + } + ] + } + } + } + }, + { + "type": "timeseries", + "title": "Panel without series labels", + "id": 4, + "options": { + "dataLinks": [ + { + "url": "http://mylink.com?other=${__field.labels}", + "title": "No Series Labels Link" + } + ] + } + }, + { + "type": "timeseries", + "title": "Panel without options", + "id": 5 + } + ] +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json b/apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json new file mode 100644 index 00000000000..722fe7e8e0e --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json @@ -0,0 +1,166 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 1, + "options": { + "dataLinks": [ + { + "title": "Data Link 1", + "url": "http://mylink.com?series=${__field.labels}\u0026${__field.labels.a}" + }, + { + "title": "Data Link 2", + "url": "http://anotherlink.com?param=${__field.labels}" + } + ] + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with data links", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 2, + "options": { + "fieldOptions": { + "defaults": { + "links": [ + { + "title": "Field Link 1", + "url": "http://mylink.com?series=${__field.labels}\u0026${__field.labels.x}" + }, + { + "title": "Field Link 2", + "url": "http://fieldlink.com?field=${__field.labels}" + } + ] + } + }, + "justifyMode": "auto", + "percentChangeColorMode": "standard", + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with field options links", + "type": "stat" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 3, + "options": { + "dataLinks": [ + { + "title": "Graph Data Link", + "url": "http://mylink.com?series=${__field.labels}" + } + ], + "fieldOptions": { + "defaults": { + "links": [ + { + "title": "Graph Field Link", + "url": "http://mylink.com?field=${__field.labels}" + } + ] + } + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with both link types", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 4, + "options": { + "dataLinks": [ + { + "title": "No Series Labels Link", + "url": "http://mylink.com?other=${__field.labels}" + } + ] + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel without series labels", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 5, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel without options", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 41, + "title": "V21 Data Links Series to Field Migration Test Dashboard" +} \ No newline at end of file