SchemaV2: Add library panel repeat options to v2 schema during conversion (#114109)

* Add library panel repeat options to v2 schema during conversion

* use any instead of interface{}

* change to common.Unstructured instead of byte[] for model field

* Fix the tests and let the library panel behavior fetch repeat options in public and scripted dashboards

* fix library panel differences between backend and frontend conversion
This commit is contained in:
Oscar Kilhed
2025-11-21 11:41:03 +01:00
committed by GitHub
parent 3d2f702ae5
commit e09905df35
19 changed files with 962 additions and 73 deletions
@@ -11,11 +11,13 @@ import (
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, _ schemaversion.LibraryElementIndexProvider) error {
func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
// Wrap the provider once with 10s caching for all conversions.
// This prevents repeated DB queries across multiple conversion calls while allowing
// the cache to refresh periodically, making it suitable for long-lived singleton usage.
dsIndexProvider = schemaversion.WrapIndexProviderWithCache(dsIndexProvider)
// Wrap library element provider with caching as well
leIndexProvider = schemaversion.WrapLibraryElementProviderWithCache(leIndexProvider)
// v0 conversions
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil),
@@ -26,13 +28,13 @@ func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSo
}
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil),
withConversionMetrics(dashv0.APIVERSION, dashv2alpha1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2alpha1(a.(*dashv0.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider)
return Convert_V0_to_V2alpha1(a.(*dashv0.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil),
withConversionMetrics(dashv0.APIVERSION, dashv2beta1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider)
return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
@@ -46,13 +48,13 @@ func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSo
}
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil),
withConversionMetrics(dashv1.APIVERSION, dashv2alpha1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1beta1_to_V2alpha1(a.(*dashv1.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider)
return Convert_V1beta1_to_V2alpha1(a.(*dashv1.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil),
withConversionMetrics(dashv1.APIVERSION, dashv2beta1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1beta1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider)
return Convert_V1beta1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
@@ -33,7 +33,8 @@ import (
func TestConversionMatrixExist(t *testing.T) {
// Initialize the migrator with a test data source provider
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
versions := []metav1.Object{
@@ -86,7 +87,8 @@ func TestDeepCopyValid(t *testing.T) {
func TestDashboardConversionToAllVersions(t *testing.T) {
// Initialize the migrator with a test data source provider
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Set up conversion scheme
@@ -246,7 +248,8 @@ func TestDashboardConversionToAllVersions(t *testing.T) {
func TestMigratedDashboardsConversion(t *testing.T) {
// Initialize the migrator with a test data source provider
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Set up conversion scheme
@@ -381,7 +384,8 @@ func testConversion(t *testing.T, convertedDash metav1.Object, filename, outputD
func TestConversionMetrics(t *testing.T) {
// Initialize migration with test providers
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -509,7 +513,8 @@ func TestConversionMetrics(t *testing.T) {
// TestConversionMetricsWrapper tests the withConversionMetrics wrapper function
func TestConversionMetricsWrapper(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -678,7 +683,8 @@ func TestSchemaVersionExtraction(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Test the schema version extraction logic by creating a wrapper and checking the metrics labels
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -723,7 +729,8 @@ func TestSchemaVersionExtraction(t *testing.T) {
// TestConversionLogging tests that conversion-level logging works correctly
func TestConversionLogging(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -815,7 +822,8 @@ func TestConversionLogging(t *testing.T) {
// TestConversionLogLevels tests that appropriate log levels are used
func TestConversionLogLevels(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
t.Run("log levels and structured fields verification", func(t *testing.T) {
@@ -887,7 +895,8 @@ func TestConversionLogLevels(t *testing.T) {
// TestConversionLoggingFields tests that all expected fields are included in log messages
func TestConversionLoggingFields(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
t.Run("verify all log fields are present", func(t *testing.T) {
@@ -0,0 +1,93 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"title": "Library Panel Repeat Options Test Dashboard",
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"tags": ["test", "library-panels", "repeat"],
"schemaVersion": 38,
"panels": [
{
"id": 1,
"title": "Library Panel with Horizontal Repeat",
"type": "library-panel-ref",
"gridPos": {
"x": 0,
"y": 0,
"w": 12,
"h": 8
},
"libraryPanel": {
"uid": "lib-panel-repeat-h",
"name": "Library Panel with Horizontal Repeat"
}
},
{
"id": 2,
"title": "Library Panel with Vertical Repeat",
"type": "library-panel-ref",
"gridPos": {
"x": 0,
"y": 8,
"w": 6,
"h": 4
},
"libraryPanel": {
"uid": "lib-panel-repeat-v",
"name": "Library Panel with Vertical Repeat"
}
},
{
"id": 3,
"title": "Library Panel Instance Override",
"type": "library-panel-ref",
"gridPos": {
"x": 6,
"y": 8,
"w": 12,
"h": 8
},
"libraryPanel": {
"uid": "lib-panel-repeat-h",
"name": "Library Panel with Horizontal Repeat"
},
"repeat": "instance-var",
"repeatDirection": "v",
"maxPerRow": 5
},
{
"id": 4,
"title": "Library Panel without Repeat",
"type": "library-panel-ref",
"gridPos": {
"x": 0,
"y": 12,
"w": 6,
"h": 3
},
"libraryPanel": {
"uid": "lib-panel-no-repeat",
"name": "Library Panel without Repeat"
}
}
],
"time": {
"from": "now-1h",
"to": "now"
},
"templating": {
"list": []
},
"annotations": {
"list": []
},
"links": []
}
}
@@ -0,0 +1,102 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"annotations": {
"list": []
},
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"links": [],
"panels": [
{
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
},
"title": "Library Panel with Horizontal Repeat",
"type": "library-panel-ref"
},
{
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 8
},
"id": 2,
"libraryPanel": {
"name": "Library Panel with Vertical Repeat",
"uid": "lib-panel-repeat-v"
},
"title": "Library Panel with Vertical Repeat",
"type": "library-panel-ref"
},
{
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 8
},
"id": 3,
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
},
"maxPerRow": 5,
"repeat": "instance-var",
"repeatDirection": "v",
"title": "Library Panel Instance Override",
"type": "library-panel-ref"
},
{
"gridPos": {
"h": 3,
"w": 6,
"x": 0,
"y": 12
},
"id": 4,
"libraryPanel": {
"name": "Library Panel without Repeat",
"uid": "lib-panel-no-repeat"
},
"title": "Library Panel without Repeat",
"type": "library-panel-ref"
}
],
"schemaVersion": 38,
"tags": [
"test",
"library-panels",
"repeat"
],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"title": "Library Panel Repeat Options Test Dashboard"
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}
@@ -0,0 +1,169 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"editable": true,
"elements": {
"panel-1": {
"kind": "LibraryPanel",
"spec": {
"id": 1,
"title": "Library Panel with Horizontal Repeat",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-2": {
"kind": "LibraryPanel",
"spec": {
"id": 2,
"title": "Library Panel with Vertical Repeat",
"libraryPanel": {
"name": "Library Panel with Vertical Repeat",
"uid": "lib-panel-repeat-v"
}
}
},
"panel-3": {
"kind": "LibraryPanel",
"spec": {
"id": 3,
"title": "Library Panel Instance Override",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-4": {
"kind": "LibraryPanel",
"spec": {
"id": 4,
"title": "Library Panel without Repeat",
"libraryPanel": {
"name": "Library Panel without Repeat",
"uid": "lib-panel-no-repeat"
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"repeat": {
"mode": "variable",
"value": "server",
"direction": "h",
"maxPerRow": 3
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 6,
"height": 4,
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"repeat": {
"mode": "variable",
"value": "datacenter",
"direction": "v"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 6,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
},
"repeat": {
"mode": "variable",
"value": "instance-var",
"direction": "v",
"maxPerRow": 5
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 12,
"width": 6,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [
"test",
"library-panels",
"repeat"
],
"timeSettings": {
"timezone": "browser",
"from": "now-1h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Library Panel Repeat Options Test Dashboard",
"variables": []
},
"status": {}
}
@@ -0,0 +1,169 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"editable": true,
"elements": {
"panel-1": {
"kind": "LibraryPanel",
"spec": {
"id": 1,
"title": "Library Panel with Horizontal Repeat",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-2": {
"kind": "LibraryPanel",
"spec": {
"id": 2,
"title": "Library Panel with Vertical Repeat",
"libraryPanel": {
"name": "Library Panel with Vertical Repeat",
"uid": "lib-panel-repeat-v"
}
}
},
"panel-3": {
"kind": "LibraryPanel",
"spec": {
"id": 3,
"title": "Library Panel Instance Override",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-4": {
"kind": "LibraryPanel",
"spec": {
"id": 4,
"title": "Library Panel without Repeat",
"libraryPanel": {
"name": "Library Panel without Repeat",
"uid": "lib-panel-no-repeat"
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"repeat": {
"mode": "variable",
"value": "server",
"direction": "h",
"maxPerRow": 3
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 6,
"height": 4,
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"repeat": {
"mode": "variable",
"value": "datacenter",
"direction": "v"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 6,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
},
"repeat": {
"mode": "variable",
"value": "instance-var",
"direction": "v",
"maxPerRow": 5
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 12,
"width": 6,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [
"test",
"library-panels",
"repeat"
],
"timeSettings": {
"timezone": "browser",
"from": "now-1h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Library Panel Repeat Options Test Dashboard",
"variables": []
},
"status": {}
}
@@ -25,7 +25,7 @@ func Convert_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope co
return nil
}
func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
v1beta1 := &dashv1.Dashboard{}
if err := ConvertDashboard_V0_to_V1beta1(in, v1beta1, scope); err != nil {
out.Status = dashv2alpha1.DashboardStatus{
@@ -48,7 +48,7 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s
return nil
}
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, out, scope, dsIndexProvider); err != nil {
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, out, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2alpha1.DashboardStatus{
Conversion: &dashv2alpha1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv0.VERSION),
@@ -72,7 +72,7 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s
return nil
}
func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
v1beta1 := &dashv1.Dashboard{}
if err := ConvertDashboard_V0_to_V1beta1(in, v1beta1, scope); err != nil {
out.Status = dashv2beta1.DashboardStatus{
@@ -86,7 +86,7 @@ func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, sco
}
v2alpha1 := &dashv2alpha1.Dashboard{}
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, v2alpha1, scope, dsIndexProvider); err != nil {
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, v2alpha1, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2beta1.DashboardStatus{
Conversion: &dashv2beta1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv0.VERSION),
@@ -109,9 +109,9 @@ func TestV0ConversionErrorHandling(t *testing.T) {
case *dashv1.Dashboard:
err = Convert_V0_to_V1beta1(tt.source, target, nil)
case *dashv2alpha1.Dashboard:
err = Convert_V0_to_V2alpha1(tt.source, target, nil, dsProvider)
err = Convert_V0_to_V2alpha1(tt.source, target, nil, dsProvider, leProvider)
case *dashv2beta1.Dashboard:
err = Convert_V0_to_V2beta1(tt.source, target, nil, dsProvider)
err = Convert_V0_to_V2beta1(tt.source, target, nil, dsProvider, leProvider)
default:
t.Fatalf("unexpected target type: %T", target)
}
@@ -192,7 +192,7 @@ func TestV0ConversionErrorPropagation(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
require.Error(t, err, "expected error to be returned on first step failure")
require.NotNil(t, target.Status.Conversion)
@@ -243,7 +243,7 @@ func TestV0ConversionSuccessPaths(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
require.NoError(t, err, "expected successful conversion")
// Layout should be set even on success
@@ -264,7 +264,7 @@ func TestV0ConversionSuccessPaths(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
require.NoError(t, err, "expected successful conversion")
})
@@ -293,7 +293,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
// Convert_V0_to_V2alpha1 doesn't return error, just sets status
require.NoError(t, err, "Convert_V0_to_V2alpha1 doesn't return error")
@@ -327,7 +327,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
// Convert_V0_to_V2alpha1 doesn't return error, just sets status
require.NoError(t, err, "Convert_V0_to_V2alpha1 doesn't return error")
@@ -357,7 +357,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on second step, status should be set
@@ -383,7 +383,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on third step, status should be set
@@ -25,8 +25,8 @@ func Convert_V1beta1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope co
return nil
}
func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, out, scope, dsIndexProvider); err != nil {
func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, out, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2alpha1.DashboardStatus{
Conversion: &dashv2alpha1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv1.VERSION),
@@ -60,9 +60,9 @@ func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboa
return nil
}
func Convert_V1beta1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func Convert_V1beta1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
v2alpha1 := &dashv2alpha1.Dashboard{}
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, v2alpha1, scope, dsIndexProvider); err != nil {
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, v2alpha1, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2beta1.DashboardStatus{
Conversion: &dashv2beta1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv1.VERSION),
@@ -37,7 +37,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V1beta1_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2alpha1(source, target, nil, dsProvider, leProvider)
// Convert_V1beta1_to_V2alpha1 doesn't return error, just sets status
require.NoError(t, err, "Convert_V1beta1_to_V2alpha1 doesn't return error")
@@ -64,7 +64,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on first step, status should be set with correct StoredVersion
@@ -91,7 +91,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on second step, status should be set with correct StoredVersion
@@ -117,7 +117,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
// Should succeed if dashboard is valid
if err == nil {
@@ -80,7 +80,7 @@ func prepareV1beta1ConversionContext(in *dashv1.Dashboard, dsIndexProvider schem
return ctx, &nsInfo, nil
}
func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
out.ObjectMeta = in.ObjectMeta
out.APIVersion = dashv2alpha1.APIVERSION
out.Kind = in.Kind
@@ -94,10 +94,10 @@ func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha
return fmt.Errorf("failed to prepare conversion context: %w", err)
}
return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider)
return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider, leIndexProvider)
}
func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
// Parse the unstructured spec into a dashboard JSON structure
dashboardJSON, ok := in.Object["dashboard"]
if !ok {
@@ -161,7 +161,7 @@ func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *das
out.Links = transformLinks(dashboard)
// Transform panels to elements and layout
elements, layout, err := transformPanelsToElementsAndLayout(ctx, dashboard, dsIndexProvider)
elements, layout, err := transformPanelsToElementsAndLayout(ctx, dashboard, dsIndexProvider, leIndexProvider)
if err != nil {
return fmt.Errorf("failed to transform panels: %w", err)
}
@@ -387,7 +387,7 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa
// Panel transformation constants
const GRID_ROW_HEIGHT = 1
func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
panels, ok := dashboard["panels"].([]interface{})
if !ok {
// Return empty elements and default grid layout
@@ -415,13 +415,13 @@ func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[strin
}
if hasRowPanels {
return convertToRowsLayout(ctx, panels, dsIndexProvider)
return convertToRowsLayout(ctx, panels, dsIndexProvider, leIndexProvider)
}
return convertToGridLayout(ctx, panels, dsIndexProvider)
return convertToGridLayout(ctx, panels, dsIndexProvider, leIndexProvider)
}
func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
elements := make(map[string]dashv2alpha1.DashboardElement)
items := make([]dashv2alpha1.DashboardGridLayoutItemKind, 0, len(panels))
@@ -437,7 +437,7 @@ func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvi
}
elements[elementName] = element
items = append(items, buildGridItemKind(panelMap, elementName, nil))
items = append(items, buildGridItemKind(ctx, panelMap, elementName, nil, leIndexProvider))
}
layout := dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
@@ -452,7 +452,7 @@ func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvi
return elements, layout, nil
}
func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
elements := make(map[string]dashv2alpha1.DashboardElement)
rows := make([]dashv2alpha1.DashboardRowsLayoutRowKind, 0)
@@ -491,7 +491,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
element, name, err := buildElement(ctx, collapsedPanelMap, dsIndexProvider)
if err == nil {
elements[name] = element
rowElements = append(rowElements, buildGridItemKind(collapsedPanelMap, name, int64Ptr(yOffsetInRows(collapsedPanelMap, legacyRowY))))
rowElements = append(rowElements, buildGridItemKind(ctx, collapsedPanelMap, name, int64Ptr(yOffsetInRows(collapsedPanelMap, legacyRowY)), leIndexProvider))
}
}
}
@@ -512,7 +512,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
if currentRow.Spec.Layout.GridLayoutKind != nil {
currentRow.Spec.Layout.GridLayoutKind.Spec.Items = append(
currentRow.Spec.Layout.GridLayoutKind.Spec.Items,
buildGridItemKind(panelMap, elementName, int64Ptr(yOffsetInRows(panelMap, legacyRowY))),
buildGridItemKind(ctx, panelMap, elementName, int64Ptr(yOffsetInRows(panelMap, legacyRowY)), leIndexProvider),
)
}
} else {
@@ -521,7 +521,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
// The Y position does not matter for the rows layout, but it's used to calculate the position of the panels in the grid layout in the row.
legacyRowY = -1
gridItems := []dashv2alpha1.DashboardGridLayoutItemKind{
buildGridItemKind(panelMap, elementName, int64Ptr(0)),
buildGridItemKind(ctx, panelMap, elementName, int64Ptr(0), leIndexProvider),
}
hideHeader := true
@@ -645,7 +645,7 @@ func buildPanelKind(ctx context.Context, panelMap map[string]interface{}, dsInde
return panelKind, nil
}
func buildGridItemKind(panelMap map[string]interface{}, elementName string, yOverride *int64) dashv2alpha1.DashboardGridLayoutItemKind {
func buildGridItemKind(ctx context.Context, panelMap map[string]interface{}, elementName string, yOverride *int64, leIndexProvider schemaversion.LibraryElementIndexProvider) dashv2alpha1.DashboardGridLayoutItemKind {
// Default grid position (matches frontend PanelModel defaults: w=6, h=3)
x, y, width, height := int64(0), int64(0), int64(6), int64(3)
@@ -677,34 +677,78 @@ func buildGridItemKind(panelMap map[string]interface{}, elementName string, yOve
}
// Handle repeat options
if repeat := schemaversion.GetStringValue(panelMap, "repeat"); repeat != "" {
repeatOptions := &dashv2alpha1.DashboardRepeatOptions{
Mode: "variable",
Value: repeat,
}
// First check if repeat options are set on the panel itself (dashboard instance level)
repeatOptions := getRepeatOptionsFromPanel(panelMap)
if repeatDirection := schemaversion.GetStringValue(panelMap, "repeatDirection"); repeatDirection != "" {
switch repeatDirection {
case "h":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionH
repeatOptions.Direction = &direction
case "v":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionV
repeatOptions.Direction = &direction
// If no repeat options on the panel and it's a library panel, try to get them from the library panel definition
if repeatOptions == nil {
if libraryPanel, ok := panelMap["libraryPanel"].(map[string]interface{}); ok {
libraryPanelUID := schemaversion.GetStringValue(libraryPanel, "uid")
if libraryPanelUID != "" && leIndexProvider != nil {
repeatOptions = getRepeatOptionsFromLibraryPanel(ctx, libraryPanelUID, leIndexProvider)
}
}
}
if maxPerRow := getIntField(panelMap, "maxPerRow", 0); maxPerRow > 0 {
maxPerRowInt64 := int64(maxPerRow)
repeatOptions.MaxPerRow = &maxPerRowInt64
}
if repeatOptions != nil {
item.Spec.Repeat = repeatOptions
}
return item
}
// getRepeatOptionsFromPanel extracts repeat options from a panel map (dashboard instance level)
func getRepeatOptionsFromPanel(panelMap map[string]any) *dashv2alpha1.DashboardRepeatOptions {
repeat := schemaversion.GetStringValue(panelMap, "repeat")
if repeat == "" {
return nil
}
repeatOptions := &dashv2alpha1.DashboardRepeatOptions{
Mode: "variable",
Value: repeat,
}
if repeatDirection := schemaversion.GetStringValue(panelMap, "repeatDirection"); repeatDirection != "" {
switch repeatDirection {
case "h":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionH
repeatOptions.Direction = &direction
case "v":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionV
repeatOptions.Direction = &direction
}
}
if maxPerRow := getIntField(panelMap, "maxPerRow", 0); maxPerRow > 0 {
maxPerRowInt64 := int64(maxPerRow)
repeatOptions.MaxPerRow = &maxPerRowInt64
}
return repeatOptions
}
// getRepeatOptionsFromLibraryPanel retrieves repeat options from a library panel by UID
func getRepeatOptionsFromLibraryPanel(ctx context.Context, libraryPanelUID string, leIndexProvider schemaversion.LibraryElementIndexProvider) *dashv2alpha1.DashboardRepeatOptions {
libraryElements := leIndexProvider.GetLibraryElementInfo(ctx)
// Find the library panel by UID
var libraryPanelModel map[string]any
for _, elem := range libraryElements {
if elem.UID == libraryPanelUID {
libraryPanelModel = elem.Model.Object
break
}
}
if libraryPanelModel == nil {
return nil
}
// Extract repeat options from the library panel model
return getRepeatOptionsFromPanel(libraryPanelModel)
}
func buildRowKind(rowPanelMap map[string]interface{}, elements []dashv2alpha1.DashboardGridLayoutItemKind) *dashv2alpha1.DashboardRowsLayoutRowKind {
collapsed := getBoolField(rowPanelMap, "collapsed", false)
title := schemaversion.GetStringValue(rowPanelMap, "title")
@@ -216,3 +216,60 @@ func MigrateDatasourceNameToRef(nameOrRef interface{}, options map[string]bool,
return nil
}
// cachedLibraryElementProvider wraps a LibraryElementIndexProvider with time-based caching.
// This prevents multiple DB queries during operations that may call GetLibraryElementInfo()
// multiple times (e.g., dashboard conversions with many library panel lookups).
// The cache expires after 10 seconds, allowing it to be used as a long-lived singleton
// while still refreshing periodically.
//
// Thread-safe: Uses sync.RWMutex to guarantee safe concurrent access.
type cachedLibraryElementProvider struct {
provider LibraryElementIndexProvider
mu sync.RWMutex
elements []LibraryElementInfo
cachedAt time.Time
cacheTTL time.Duration
}
// GetLibraryElementInfo returns the cached library elements if they're still valid (< 10s old), otherwise rebuilds the cache.
// Uses RWMutex for efficient concurrent reads when cache is valid.
func (p *cachedLibraryElementProvider) GetLibraryElementInfo(ctx context.Context) []LibraryElementInfo {
// Fast path: check if cache is still valid using read lock
p.mu.RLock()
if p.elements != nil && time.Since(p.cachedAt) < p.cacheTTL {
elements := p.elements
p.mu.RUnlock()
return elements
}
p.mu.RUnlock()
// Slow path: cache expired or not yet built, acquire write lock
p.mu.Lock()
defer p.mu.Unlock()
// Double-check: another goroutine might have refreshed the cache
// while we were waiting for the write lock
if p.elements != nil && time.Since(p.cachedAt) < p.cacheTTL {
return p.elements
}
// Rebuild the cache
p.elements = p.provider.GetLibraryElementInfo(ctx)
p.cachedAt = time.Now()
return p.elements
}
// WrapLibraryElementProviderWithCache wraps a provider to cache library elements with a 10-second TTL.
// Useful for conversions or migrations that may call GetLibraryElementInfo() multiple times.
// The cache expires after 10 seconds, making it suitable for use as a long-lived singleton
// at the top level of dependency injection while still refreshing periodically.
func WrapLibraryElementProviderWithCache(provider LibraryElementIndexProvider) LibraryElementIndexProvider {
if provider == nil {
return nil
}
return &cachedLibraryElementProvider{
provider: provider,
cacheTTL: 10 * time.Second,
}
}
@@ -3,6 +3,8 @@ package schemaversion
import (
"context"
"strconv"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
const (
@@ -34,6 +36,7 @@ type LibraryElementInfo struct {
Type string
Description string
FolderUID string
Model common.Unstructured // JSON model of the library element, used to extract repeat options during migration
}
type LibraryElementIndexProvider interface {
@@ -4,6 +4,7 @@ import (
"context"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// EmptyLibraryElementProvider provides an empty library element list for tests
@@ -187,3 +188,101 @@ func (p *ConfigurableDataSourceProvider) getDevDashboardDataSources() []schemave
},
}
}
// TestLibraryElementProvider provides library elements with models for testing repeat options migration
type TestLibraryElementProvider struct {
elements []schemaversion.LibraryElementInfo
}
// NewTestLibraryElementProvider creates a new test library element provider with sample library panels
func NewTestLibraryElementProvider() *TestLibraryElementProvider {
// Create library panel models with repeat options
libPanelWithRepeatH := map[string]any{
"id": 1,
"type": "timeseries",
"title": "Library Panel with Horizontal Repeat",
"repeat": "server",
"repeatDirection": "h",
"maxPerRow": 3,
"gridPos": map[string]any{
"x": 0,
"y": 0,
"w": 12,
"h": 8,
},
"targets": []any{},
"options": map[string]any{},
}
libPanelWithRepeatV := map[string]any{
"id": 2,
"type": "stat",
"title": "Library Panel with Vertical Repeat",
"repeat": "datacenter",
"repeatDirection": "v",
"gridPos": map[string]any{
"x": 0,
"y": 0,
"w": 6,
"h": 4,
},
"targets": []any{},
"options": map[string]any{},
}
libPanelWithoutRepeat := map[string]any{
"id": 3,
"type": "text",
"title": "Library Panel without Repeat",
"gridPos": map[string]any{
"x": 0,
"y": 0,
"w": 6,
"h": 3,
},
"targets": []any{},
"options": map[string]any{},
}
// Convert models to Unstructured
modelWithRepeatH := v0alpha1.Unstructured{Object: libPanelWithRepeatH}
modelWithRepeatV := v0alpha1.Unstructured{Object: libPanelWithRepeatV}
modelWithoutRepeat := v0alpha1.Unstructured{Object: libPanelWithoutRepeat}
return &TestLibraryElementProvider{
elements: []schemaversion.LibraryElementInfo{
{
UID: "lib-panel-repeat-h",
Name: "Library Panel with Horizontal Repeat",
Kind: 1, // Panel element
Type: "timeseries",
Description: "A library panel with horizontal repeat options",
FolderUID: "",
Model: modelWithRepeatH,
},
{
UID: "lib-panel-repeat-v",
Name: "Library Panel with Vertical Repeat",
Kind: 1, // Panel element
Type: "stat",
Description: "A library panel with vertical repeat options",
FolderUID: "",
Model: modelWithRepeatV,
},
{
UID: "lib-panel-no-repeat",
Name: "Library Panel without Repeat",
Kind: 1, // Panel element
Type: "text",
Description: "A library panel without repeat options",
FolderUID: "",
Model: modelWithoutRepeat,
},
},
}
}
// GetLibraryElementInfo returns the test library elements
func (p *TestLibraryElementProvider) GetLibraryElementInfo(_ context.Context) []schemaversion.LibraryElementInfo {
return p.elements
}