diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index 8d20dcd3837..7b614bc2656 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -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 } diff --git a/apps/dashboard/pkg/migration/conversion/conversion_test.go b/apps/dashboard/pkg/migration/conversion/conversion_test.go index e7cbe2f70ef..5f334b98354 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_test.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_test.go @@ -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) { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.library-panel-repeat-options.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.library-panel-repeat-options.json new file mode 100644 index 00000000000..ec440d2894b --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.library-panel-repeat-options.json @@ -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": [] + } +} + diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v0alpha1.json new file mode 100644 index 00000000000..4c9f4fc2eaf --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v0alpha1.json @@ -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" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v2alpha1.json new file mode 100644 index 00000000000..a614f32dda6 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v2alpha1.json @@ -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": {} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v2beta1.json new file mode 100644 index 00000000000..d06d12848e6 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.library-panel-repeat-options.v2beta1.json @@ -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": {} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v0.go b/apps/dashboard/pkg/migration/conversion/v0.go index e182a71a52a..826293d9564 100644 --- a/apps/dashboard/pkg/migration/conversion/v0.go +++ b/apps/dashboard/pkg/migration/conversion/v0.go @@ -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), diff --git a/apps/dashboard/pkg/migration/conversion/v0_test.go b/apps/dashboard/pkg/migration/conversion/v0_test.go index 08bf1c550c9..ca2210f01e8 100644 --- a/apps/dashboard/pkg/migration/conversion/v0_test.go +++ b/apps/dashboard/pkg/migration/conversion/v0_test.go @@ -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 diff --git a/apps/dashboard/pkg/migration/conversion/v1.go b/apps/dashboard/pkg/migration/conversion/v1.go index 48fdbf93b36..040a9a78741 100644 --- a/apps/dashboard/pkg/migration/conversion/v1.go +++ b/apps/dashboard/pkg/migration/conversion/v1.go @@ -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), diff --git a/apps/dashboard/pkg/migration/conversion/v1_test.go b/apps/dashboard/pkg/migration/conversion/v1_test.go index c0a9d2e2e6b..4525e974e01 100644 --- a/apps/dashboard/pkg/migration/conversion/v1_test.go +++ b/apps/dashboard/pkg/migration/conversion/v1_test.go @@ -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 { diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 21877cb6361..d20855a9509 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -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") diff --git a/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go b/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go index cc4e388bca3..18bc2fc17c4 100644 --- a/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go +++ b/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go @@ -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, + } +} diff --git a/apps/dashboard/pkg/migration/schemaversion/migrations.go b/apps/dashboard/pkg/migration/schemaversion/migrations.go index 3e55b25b55c..ce786fa0dc0 100644 --- a/apps/dashboard/pkg/migration/schemaversion/migrations.go +++ b/apps/dashboard/pkg/migration/schemaversion/migrations.go @@ -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 { diff --git a/apps/dashboard/pkg/migration/testutil/mocks.go b/apps/dashboard/pkg/migration/testutil/mocks.go index 6c8b4709137..6781dcecfa1 100644 --- a/apps/dashboard/pkg/migration/testutil/mocks.go +++ b/apps/dashboard/pkg/migration/testutil/mocks.go @@ -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 +} diff --git a/apps/secret/kinds/v1beta1/securevalue.cue b/apps/secret/kinds/v1beta1/securevalue.cue index 6305bb563ff..04d85c1757f 100644 --- a/apps/secret/kinds/v1beta1/securevalue.cue +++ b/apps/secret/kinds/v1beta1/securevalue.cue @@ -29,13 +29,6 @@ SecureValueSpec: { // +optional ref?: string & strings.MinRunes(1) & strings.MaxRunes(1024) - // Name of the keeper, being the actual storage of the secure value. - // If not specified, the default keeper for the namespace will be used. - // +k8s:validation:minLength=1 - // +k8s:validation:maxLength=253 - // +optional - keeper?: string & strings.MinRunes(1) & strings.MaxRunes(253) - // The Decrypters that are allowed to decrypt this secret. // An empty list means no service can decrypt it. // +k8s:validation:maxItems=64 @@ -53,4 +46,7 @@ SecureValueStatus: { // External ID where the secret is stored. Cannot be set. // +optional externalID: string + + // The name of the keeper used to create the secure value. Cannot be set. + keeper: string } diff --git a/apps/secret/pkg/apis/secret/v1beta1/register.go b/apps/secret/pkg/apis/secret/v1beta1/register.go index 32d89abf7d5..a2fac8dd59f 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/register.go +++ b/apps/secret/pkg/apis/secret/v1beta1/register.go @@ -28,7 +28,7 @@ var SecureValuesResourceInfo = utils.NewResourceInfo( }, Reader: func(obj any) ([]any, error) { if r, ok := obj.(*SecureValue); ok { - return []any{r.Name, r.Spec.Description, r.Spec.Keeper, r.Spec.Ref}, nil + return []any{r.Name, r.Spec.Description, r.Status.Keeper, r.Spec.Ref}, nil } return nil, fmt.Errorf("expected SecureValue but got %T", obj) diff --git a/apps/secret/pkg/apis/secret/v1beta1/securevalue_spec_gen.go b/apps/secret/pkg/apis/secret/v1beta1/securevalue_spec_gen.go index 1b5d0956893..8ac05275ca3 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/securevalue_spec_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/securevalue_spec_gen.go @@ -25,12 +25,6 @@ type SecureValueSpec struct { // +k8s:validation:maxLength=1024 // +optional Ref *string `json:"ref,omitempty"` - // Name of the keeper, being the actual storage of the secure value. - // If not specified, the default keeper for the namespace will be used. - // +k8s:validation:minLength=1 - // +k8s:validation:maxLength=253 - // +optional - Keeper *string `json:"keeper,omitempty"` // The Decrypters that are allowed to decrypt this secret. // An empty list means no service can decrypt it. // +k8s:validation:maxItems=64 diff --git a/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go b/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go index a2534ab464d..a952ef13ef0 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go @@ -25,12 +25,14 @@ type SecureValueStatus struct { // Version of the secure value. Cannot be set. // +optional Version int64 `json:"version"` - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]SecureValuestatusOperatorState `json:"operatorStates,omitempty"` // External ID where the secret is stored. Cannot be set. // +optional ExternalID string `json:"externalID"` + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + OperatorStates map[string]SecureValuestatusOperatorState `json:"operatorStates,omitempty"` + // The name of the keeper used to create the secure value. Cannot be set. + Keeper string `json:"keeper"` // additionalFields is reserved for future use AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` } diff --git a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go index 7a3b6d090c0..101e4bb55b3 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go @@ -587,15 +587,6 @@ func schema_pkg_apis_secret_v1beta1_SecureValueSpec(ref common.ReferenceCallback Format: "", }, }, - "keeper": { - SchemaProps: spec.SchemaProps{ - Description: "Name of the keeper, being the actual storage of the secure value. If not specified, the default keeper for the namespace will be used.", - MinLength: ptr.To[int64](1), - MaxLength: ptr.To[int64](253), - Type: []string{"string"}, - Format: "", - }, - }, "decrypters": { VendorExtensible: spec.VendorExtensible{ Extensions: spec.Extensions{ @@ -639,6 +630,14 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba Format: "int64", }, }, + "externalID": { + SchemaProps: spec.SchemaProps{ + Description: "External ID where the secret is stored. Cannot be set.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, "operatorStates": { SchemaProps: spec.SchemaProps{ Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", @@ -654,9 +653,9 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba }, }, }, - "externalID": { + "keeper": { SchemaProps: spec.SchemaProps{ - Description: "External ID where the secret is stored. Cannot be set.", + Description: "The name of the keeper used to create the secure value. Cannot be set.", Default: "", Type: []string{"string"}, Format: "", @@ -678,6 +677,7 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba }, }, }, + Required: []string{"keeper"}, }, }, Dependencies: []string{ diff --git a/conf/defaults.ini b/conf/defaults.ini index 125c748326c..a72b0183290 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2179,6 +2179,8 @@ enabled = true ###################################### Cloud Migration ###################################### [cloud_migration] +# Set to false to disable the Cloud Migration feature +enabled = true # Set to true to enable target-side migration UI is_target = false # Token used to send requests to grafana com diff --git a/conf/sample.ini b/conf/sample.ini index 3944ea20940..d6397f894e4 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -2071,6 +2071,8 @@ default_datasource_uid = ###################################### Cloud Migration ###################################### [cloud_migration] +# Set to false to disable the Cloud Migration feature +;enabled = true # Set to true to enable target-side migration UI ;is_target = false # Token used to send requests to grafana com diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md index de442b3d5d6..15cf895a5e8 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md @@ -29,7 +29,7 @@ refs: - pattern: /docs/grafana/ destination: docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions + destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#create-access-policies rbac-role-definitions: - pattern: /docs/grafana/ destination: /docs/grafana//administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/ @@ -66,16 +66,17 @@ Please refer to plugin documentation to see what RBAC permissions the plugin has The following list contains app plugins that have fine-grained RBAC support. -| App plugin | App plugin ID | App plugin permission documentation | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | -| [Adaptive metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | -| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | -| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | -| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) | -| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a | -| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) | -| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) | +| App plugin | App plugin ID | App plugin permission documentation | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | +| [Adaptive Metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | +| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) | +| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | +| [Kubernetes Monitoring](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/) | `grafana-k8s-app` | [Kubernetes Monitoring role-based access control](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/configuration/control-access/#precision-access-with-rbac-custom-plugin-roles) | +| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | +| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) | +| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a | +| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) | ### Revoke fine-grained access from app plugins diff --git a/docs/sources/alerting/monitor-status/view-alert-state-history.md b/docs/sources/alerting/monitor-status/view-alert-state-history.md index 553ef0a8fac..156fd6c9f09 100644 --- a/docs/sources/alerting/monitor-status/view-alert-state-history.md +++ b/docs/sources/alerting/monitor-status/view-alert-state-history.md @@ -73,6 +73,37 @@ To access the History page, complete the following steps. {{< figure src="/media/docs/alerting/alerting-alert-history-tab.png" max-width="750px" alt="Alert History tab in Grafana Alerting" >}} +## Use Grafana Assistant to analyze alert state history + +{{< admonition type="note" >}} +This feature is available in Grafana Cloud when Grafana Assistant is enabled. +{{< /admonition >}} + +The **Analyze with Assistant** button provides AI-powered analysis of your alert history to help you understand and troubleshoot alert patterns. Located in the top-right corner of the History page event list, this button uses Grafana Assistant to analyze the events displayed in your current view. + +When you click the AI Triage button, the Grafana Assistant analyzes: + +- Alert state transitions over the selected time range +- Alert instance patterns and frequency +- Common labels and characteristics of firing alerts +- Temporal patterns in alert behavior + +The AI assistant can help you: + +- Identify root causes of alert storms +- Detect patterns in alert firing behavior +- Understand correlations between different alert instances +- Get suggestions for improving alert configurations + +To use the Analyze with Assistant feature: + +1. Navigate to the History page as described above. +2. Filter the events to focus on the alerts you want to analyze using labels, states, or time range. +3. Click the **Analyze with Assistant** button in the top-right corner of the event list. +4. Review the AI-generated analysis and recommendations. + +The AI analysis is based on the currently displayed events, so filtering your view to specific alerts or time periods will result in more focused insights. + ## View from the State history view Use the State history view to get insight into how your individual alert instances behave over time. diff --git a/docs/sources/datasources/aws-cloudwatch/configure/index.md b/docs/sources/datasources/aws-cloudwatch/configure/index.md index 9663010505c..7e80433756b 100644 --- a/docs/sources/datasources/aws-cloudwatch/configure/index.md +++ b/docs/sources/datasources/aws-cloudwatch/configure/index.md @@ -62,9 +62,9 @@ refs: destination: /docs/grafana//datasources/aws-CloudWatch/aws-authentication/ private-data-source-connect: - pattern: /docs/grafana/ - destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ - pattern: /docs/grafana-cloud/ - destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ configure-pdc: - pattern: /docs/grafana/ destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 6b76c32fd28..c05ecd45bd9 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -41,9 +41,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes | | `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes | | `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes | -| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes | | `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | | -| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes | | `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes | | `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes | @@ -90,6 +88,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | | `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. | | `queryLibrary` | Enables Saved queries (query library) feature | +| `dashboardTemplates` | Enables a flow to get started with a new dashboard from a template | | `enableSCIM` | Enables SCIM support for user and group management | | `alertRuleRestore` | Enables the alert rule restore feature | | `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source | diff --git a/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md b/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md index 7cb91aaeb7f..ded072448ce 100644 --- a/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md +++ b/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md @@ -1,5 +1,6 @@ --- aliases: + - ../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/ labels: products: - cloud diff --git a/e2e-playwright/smoke-tests-suite/panels.spec.ts b/e2e-playwright/smoke-tests-suite/panels.spec.ts index 08a4189f735..ded102294ce 100644 --- a/e2e-playwright/smoke-tests-suite/panels.spec.ts +++ b/e2e-playwright/smoke-tests-suite/panels.spec.ts @@ -7,13 +7,13 @@ test.describe( tag: ['@acceptance'], }, () => { - test.skip('Tests each panel type in the panel edit view to ensure no crash', async ({ + test('Tests each panel type in the panel edit view to ensure no crash', async ({ gotoDashboardPage, selectors, page, }) => { // this test can absolutely take longer than the default 30s timeout - test.setTimeout(60000); + test.setTimeout(120000); // Create new dashboard const dashboardPage = await gotoDashboardPage({}); @@ -29,22 +29,32 @@ test.describe( return win.grafanaBootData?.settings?.panels ?? {}; }); + const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); + // Loop through every panel type and ensure no crash for (const [_, panel] of Object.entries(panelTypes)) { if (panel.hideFromList || panel.state === 'deprecated') { continue; // Skip hidden and deprecated panels } - // Select the panel type in the viz picker - const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); - await vizPicker.click(); - await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click(); + try { + // Select the panel type in the viz picker + await expect(vizPicker).toBeVisible(); + await vizPicker.click({ force: true }); - // Verify panel type is selected - await expect(vizPicker).toHaveText(panel.name); + await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click(); - // Ensure no unexpected error occurred - await expect(page.getByText('An unexpected error happened')).toBeHidden(); + // Verify panel type is selected + await expect(vizPicker).toHaveText(panel.name, { timeout: 10000 }); + + // Wait for panel to finish rendering + await expect(page.getByLabel('Panel loading bar')).toHaveCount(0, { timeout: 10000 }); + + // Ensure no unexpected error occurred + await expect(page.getByText('An unexpected error happened')).toBeHidden(); + } catch (error) { + throw new Error(`Panel '${panel.name}' failed: ${error}`); + } } }); } diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 84e5d786552..b3eaa1feb52 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3706,16 +3706,6 @@ "count": 7 } }, - "public/app/plugins/datasource/azuremonitor/types/query.ts": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, - "public/app/plugins/datasource/azuremonitor/types/templateVariables.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/plugins/datasource/azuremonitor/utils/common.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/go.mod b/go.mod index d8df0cd6bd7..576f6c0614f 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group + github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling @@ -138,7 +139,6 @@ require ( github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-backend github.com/microsoft/go-mssqldb v1.9.2 // @grafana/partner-datasources github.com/migueleliasweb/go-github-mock v1.1.0 // @grafana/grafana-git-ui-sync-team - github.com/mitchellh/copystructure v1.2.0 // @grafana/grafana-operator-experience-squad github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c //@grafana/identity-access-team github.com/mocktools/go-smtp-mock/v2 v2.5.1 // @grafana/grafana-backend-group github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // @grafana/alerting-backend @@ -197,7 +197,6 @@ require ( go.uber.org/goleak v1.3.0 // @grafana/grafana-search-and-storage go.uber.org/mock v0.6.0 // @grafana/grafana-operator-experience-squad go.uber.org/zap v1.27.0 // @grafana/identity-access-team - go.yaml.in/yaml/v2 v2.4.3 // @grafana/alerting-backend go.yaml.in/yaml/v3 v3.0.4 // @grafana/alerting-backend gocloud.dev v0.43.0 // @grafana/grafana-app-platform-squad gocloud.dev/secrets/hashivault v0.43.0 // @grafana/grafana-operator-experience-squad @@ -405,7 +404,6 @@ require ( github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.4.0+incompatible // indirect - github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad github.com/docker/go-units v0.5.0 // indirect github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect @@ -515,6 +513,7 @@ require ( github.com/miekg/dns v1.1.63 // indirect github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -628,6 +627,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go4.org/netipx v0.0.0-20230125063823-8449b0a6169f // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect diff --git a/package.json b/package.json index a41dee5cd15..1ab0dc7a811 100644 --- a/package.json +++ b/package.json @@ -296,8 +296,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.46.0", - "@grafana/scenes-react": "^6.46.0", + "@grafana/scenes": "6.47.1", + "@grafana/scenes-react": "6.47.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index bb7fe29a655..f519b1d56b4 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -306,6 +306,7 @@ export interface GrafanaConfig { sharedWithMeFolderUID: string; rootFolderUID: string; localFileSystemAvailable: boolean; + cloudMigrationEnabled: boolean; cloudMigrationIsTarget: boolean; cloudMigrationPollIntervalMs: number; pluginCatalogURL: string; diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 25e39084acc..e5bf0e10a21 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -646,7 +646,7 @@ export interface MetricFindValue { } export interface DataSourceGetDrilldownsApplicabilityOptions { - filters: AdHocVariableFilter[]; + filters?: AdHocVariableFilter[]; groupByKeys?: string[]; timeRange?: TimeRange; queries?: TQuery[]; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index acb3b1796de..509beeb56c9 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -329,6 +329,10 @@ export interface FeatureToggles { */ alertingUIUseBackendFilters?: boolean; /** + * Enables the UI to use rules backend-side filters 100% compatible with the frontend filters + */ + alertingUIUseFullyCompatBackendFilters?: boolean; + /** * Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. */ alertmanagerRemotePrimary?: boolean; @@ -365,6 +369,10 @@ export interface FeatureToggles { */ unlimitedLayoutsNesting?: boolean; /** + * Enables viewing non-applicable drilldowns on a panel level + */ + perPanelNonApplicableDrilldowns?: boolean; + /** * Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard */ panelFilterVariable?: boolean; @@ -381,11 +389,6 @@ export interface FeatureToggles { */ timeComparison?: boolean; /** - * Enables infinite scrolling for the Logs panel in Explore and Dashboards - * @default true - */ - logsInfiniteScrolling?: boolean; - /** * Enables shared crosshair in table panel */ tableSharedCrosshair?: boolean; @@ -407,11 +410,6 @@ export interface FeatureToggles { */ jitterAlertRulesWithinGroups?: boolean; /** - * Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. - * @default true - */ - onPremToCloudMigrations?: boolean; - /** * Enable the secrets management API and services under app platform */ secretsManagementAppPlatform?: boolean; @@ -502,14 +500,18 @@ export interface FeatureToggles { */ queryLibrary?: boolean; /** - * Enable dashboard library experiments that are production ready + * Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page */ dashboardLibrary?: boolean; /** - * Enable suggested dashboards when creating new dashboards + * Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page */ suggestedDashboards?: boolean; /** + * Enables a flow to get started with a new dashboard from a template + */ + dashboardTemplates?: boolean; + /** * Sets the logs table as default visualisation in logs explore */ logsExploreTableDefaultVisualization?: boolean; @@ -597,7 +599,7 @@ export interface FeatureToggles { */ alertingPrometheusRulesPrimary?: boolean; /** - * Used in Logs Drilldown to split queries into multiple queries based on the number of shards + * Deprecated. Replace with lokiShardSplitting. Used in Logs Drilldown to split queries into multiple queries based on the number of shards */ exploreLogsShardSplitting?: boolean; /** @@ -665,6 +667,10 @@ export interface FeatureToggles { */ timeRangePan?: boolean; /** + * Enables new keyboard shortcuts for time range zoom operations + */ + newTimeRangeZoomShortcuts?: boolean; + /** * Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. * @default false */ @@ -915,10 +921,6 @@ export interface FeatureToggles { */ grafanaAssistantInProfilesDrilldown?: boolean; /** - * Enables using PGX instead of libpq for PostgreSQL datasource - */ - postgresDSUsePGX?: boolean; - /** * Enables creating alerts from Tempo data source */ tempoAlerting?: boolean; @@ -1157,10 +1159,6 @@ export interface FeatureToggles { */ panelTimeSettings?: boolean; /** - * Enable template dashboards - */ - dashboardTemplates?: boolean; - /** * Enables app platform API for annotations * @default false */ @@ -1179,6 +1177,10 @@ export interface FeatureToggles { */ ttlPluginInstanceManager?: boolean; /** + * Send X-Loki-Query-Limits-Context header to Loki on first split request + */ + lokiQueryLimitsContext?: boolean; + /** * Enables the new version of rudderstack * @default false */ diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index c8cfb1d47cb..e56318477c0 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -426,6 +426,9 @@ export const versionedComponents = { loadingBar: { '10.0.0': () => `Panel loading bar`, }, + PanelNonApplicableDrilldownsSubHeader: { + '12.4.0': 'Panel non-applicable drilldowns subheader', + }, HoverWidget: { container: { '10.1.0': 'data-testid hover-header-container', diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 4c7a2714cd6..17fbbef447a 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -241,6 +241,7 @@ export class GrafanaBootConfig { sharedWithMeFolderUID?: string; rootFolderUID?: string; localFileSystemAvailable?: boolean; + cloudMigrationEnabled?: boolean; cloudMigrationIsTarget?: boolean; cloudMigrationPollIntervalMs = 2000; reportingStaticContext?: Record; diff --git a/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts index f8f056a3c8d..2e3c4991c65 100644 --- a/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts @@ -50,6 +50,14 @@ export interface LokiDataQuery extends common.DataQuery { * Used to override the name of the series. */ legendFormat?: string; + /** + * The full query plan for split/shard queries. Encoded and sent to Loki via `X-Loki-Query-Limits-Context` header. Requires "lokiQueryLimitsContext" feature flag + */ + limitsContext?: { + expr: string; + from: number; + to: number; + }; /** * Used to limit the number of log rows returned. */ diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx index c00acacda20..a1fbb1dbbd4 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx @@ -421,6 +421,70 @@ Component used for rendering content wrapped in the same style as grafana panels +### Sub-header content + +The panel supports displaying additional content below the main header using the `subHeaderContent` prop. This can be used to show context-specific information. + +```tsx + + Additional info + + } + width={400} + height={200} +> + {(innerwidth, innerheight) => { + return ( +
+ Content +
+ ); + }} +
+``` + + + + Additional info + + } + width={400} + height={200} + > + {(innerwidth, innerheight) => { + return ( +
+ Content +
+ ); + }} +
+
+ ### Collapsible The panel can be collapsed/expanded by clicking on the chevron or the title. diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx index 3032099a069..7576138f533 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx @@ -196,3 +196,30 @@ it('collapses the uncontrolled panel when user clicks on the chevron or the titl expect(button).not.toHaveAttribute('aria-controlls'); expect(screen.queryByTestId(selectors.components.Panels.Panel.content)?.id).toBe(undefined); }); + +it('renders panel with a header if prop subHeaderContent', () => { + setup({ + subHeaderContent:
This should be a sub-header node
, + }); + + expect(screen.getByTestId(selectors.components.Panels.Panel.headerContainer)).toBeInTheDocument(); +}); + +it('renders panel with sub-header content in place if prop subHeaderContent', () => { + setup({ + subHeaderContent:
This should be a sub-header node
, + }); + + expect(screen.getByText('This should be a sub-header node')).toBeInTheDocument(); +}); + +it('does not render sub-header content when panel is collapsed', () => { + setup({ + title: 'Test Panel', + collapsible: true, + collapsed: true, + subHeaderContent:
This should be a sub-header node
, + }); + + expect(screen.queryByText('This should be a sub-header node')).not.toBeInTheDocument(); +}); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index a10057a5cc3..a632f6ddca4 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -76,6 +76,11 @@ interface BaseProps { * If true, the VizPanelMenu will always be visible in the panel header. Defaults to false. */ showMenuAlways?: boolean; + /** + * Content to display in the sub-header area below the main header. + * Can contain text, pills, links, buttons, or any other React elements. + */ + subHeaderContent?: ReactNode; } interface FixedDimensions extends BaseProps { @@ -157,6 +162,7 @@ export function PanelChrome({ onMouseEnter, onDragStart, showMenuAlways = false, + subHeaderContent, }: PanelChromeProps) { const theme = useTheme2(); const styles = useStyles2(getStyles); @@ -164,6 +170,7 @@ export function PanelChrome({ const panelTitleId = useId().replace(/:/g, '_'); const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId); const pointerDistance = usePointerDistance(); + const [subHeaderRef, { height: measuredSubHeaderHeight }] = useMeasure(); const hasHeader = !hoverHeader; @@ -184,11 +191,13 @@ export function PanelChrome({ const isPanelTransparent = displayMode === 'transparent'; const headerHeight = getHeaderHeight(theme, hasHeader); + const subHeaderHeight = Math.min(measuredSubHeaderHeight, headerHeight); const { contentStyle, innerWidth, innerHeight } = getContentStyle( padding, theme, headerHeight, collapsed, + subHeaderHeight, height, width ); @@ -395,37 +404,44 @@ export function PanelChrome({ )} {hasHeader && ( -
- {statusMessage && ( -
- +
+ {statusMessage && ( +
+ +
+ )} + + {headerContent} + + {menu && ( + + )} +
+ {!collapsed && subHeaderContent && ( +
+ {subHeaderContent}
)} - - {headerContent} - - {menu && ( - - )} -
+ )} {!collapsed && ( @@ -466,6 +482,7 @@ const getContentStyle = ( theme: GrafanaTheme2, headerHeight: number, collapsed: boolean, + subHeaderHeight: number, height?: number, width?: number ) => { @@ -481,7 +498,7 @@ const getContentStyle = ( let innerHeight = 0; if (height) { - innerHeight = height - headerHeight - panelPadding - panelBorder; + innerHeight = height - headerHeight - panelPadding - panelBorder - subHeaderHeight; } if (collapsed) { @@ -579,6 +596,15 @@ const getStyles = (theme: GrafanaTheme2) => { padding: newPanelPadding ? theme.spacing(0, 1, 0, 1.5) : theme.spacing(0, 0.5, 0, 1), gap: theme.spacing(1), }), + subHeader: css({ + label: 'panel-sub-header', + display: 'flex', + alignItems: 'center', + maxHeight: theme.spacing.gridSize * theme.components.panel.headerHeight, + padding: newPanelPadding ? theme.spacing(0, 1, 0, 1.5) : theme.spacing(0, 0.5, 0, 1), + overflow: 'hidden', + gap: theme.spacing(1), + }), pointer: css({ cursor: 'pointer', }), diff --git a/packages/grafana-ui/src/components/Select/ValueContainer.tsx b/packages/grafana-ui/src/components/Select/ValueContainer.tsx index aa5cec83c19..6eb8326ae5d 100644 --- a/packages/grafana-ui/src/components/Select/ValueContainer.tsx +++ b/packages/grafana-ui/src/components/Select/ValueContainer.tsx @@ -26,6 +26,7 @@ class UnthemedValueContainer { + describe('Symlog', () => { + it('linear threshold should not dispatch a change for 0', async () => { + const onChange = jest.fn(); + const origValue = { type: ScaleDistribution.Symlog, log: 10 }; + + render(); + + // so annoying that this doesn't work. + // const el = await screen.findByLabelText('Linear threshold'); + const el = screen.getByTestId('input-wrapper').querySelector('input')!; + + await userEvent.type(el, '0'); + expect(onChange).not.toHaveBeenCalled(); + + await userEvent.type(el, '.'); + expect(onChange).not.toHaveBeenCalled(); + + await userEvent.type(el, '5'); + expect(onChange).toHaveBeenCalledWith({ linearThreshold: 0.5, ...origValue }); + + await userEvent.clear(el); + expect(onChange).toHaveBeenCalledWith(origValue); + }); + }); +}); diff --git a/packages/grafana-ui/src/options/builder/axis.tsx b/packages/grafana-ui/src/options/builder/axis.tsx index d3b6a74ee07..83237cb310c 100644 --- a/packages/grafana-ui/src/options/builder/axis.tsx +++ b/packages/grafana-ui/src/options/builder/axis.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { FieldConfigEditorBuilder, FieldType, @@ -126,12 +128,31 @@ const LOG_DISTRIBUTION_OPTIONS: Array> = [ }, ]; +const isValidLinearThreshold = (value: number): string | undefined => { + if (Number.isNaN(value)) { + return t('grafana-ui.axis-builder.linear-threshold.warning.nan', 'Linear threshold must be a number'); + } + if (value === 0) { + return t('grafana-ui.axis-builder.linear-threshold.warning.zero', 'Linear threshold cannot be zero'); + } + return; +}; + /** * @internal */ -export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps) => { +export const ScaleDistributionEditor = ({ + value, + onChange, +}: Pick, 'value' | 'onChange'>) => { const type = value?.type ?? ScaleDistribution.Linear; const log = value?.log ?? 2; + + const [localLinearThreshold, setLocalLinearThreshold] = useState( + value?.linearThreshold != null ? String(value.linearThreshold) : '' + ); + const [linearThresholdWarning, setLinearThresholdWarning] = useState(); + const DISTRIBUTION_OPTIONS: Array> = [ { label: t('grafana-ui.builder.axis.scale-distribution-editor.distribution-options.label-linear', 'Linear'), @@ -161,7 +182,7 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps }} /> {(type === ScaleDistribution.Log || type === ScaleDistribution.Symlog) && ( - + { + if (ev.currentTarget.value) { + setLinearThresholdWarning(isValidLinearThreshold(Number(ev.currentTarget.value))); + } + }} onChange={(v) => { - onChange({ - ...value, - linearThreshold: Number(v.currentTarget.value), - }); + setLocalLinearThreshold(v.currentTarget.value); + if (v.currentTarget.value === '') { + const newValue = { ...value }; + delete newValue.linearThreshold; + onChange(newValue); + setLinearThresholdWarning(undefined); + return; + } + + const asNumber = Number(v.currentTarget.value); + if (isValidLinearThreshold(asNumber) == null) { + setLinearThresholdWarning(undefined); + onChange({ + ...value, + linearThreshold: asNumber, + }); + } }} /> diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index 1bddc9490cc..81d866fe04f 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -313,6 +313,7 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(org.RoleEditor)}, } + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { // Keeping the name to avoid breaking changes (for users who have assigned this role to grant permissions on organization annotations) annotationsReaderRole = ac.RoleRegistration{ @@ -619,6 +620,7 @@ func (hs *HTTPServer) declareFixedRoles() error { libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole, snapshotsCreatorRole, snapshotsDeleterRole, snapshotsReaderRole} + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { allAnnotationsReaderRole := ac.RoleRegistration{ Role: ac.RoleDTO{ diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 287db4e04df..2a1b0216701 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -126,6 +126,7 @@ func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Respon } if canSave, err := hs.canCreateAnnotation(c, cmd.DashboardUID); err != nil || !canSave { + //nolint:staticcheck // not yet migrated to OpenFeature if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { return dashboardGuardianResponse(err) } else if err != nil { @@ -271,6 +272,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *contextmodel.ReqContext) response.Resp return resp } + //nolint:staticcheck // not yet migrated to OpenFeature if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave { return dashboardGuardianResponse(err) @@ -329,6 +331,7 @@ func (hs *HTTPServer) PatchAnnotation(c *contextmodel.ReqContext) response.Respo return resp } + //nolint:staticcheck // not yet migrated to OpenFeature if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave { return dashboardGuardianResponse(err) @@ -439,6 +442,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *contextmodel.ReqContext) response canSave, err := hs.canMassDeleteAnnotations(c, dashboardUID) if err != nil || !canSave { + //nolint:staticcheck // not yet migrated to OpenFeature if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { return dashboardGuardianResponse(err) } else if err != nil { @@ -500,6 +504,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response. return response.Error(http.StatusBadRequest, "annotationId is invalid", err) } + //nolint:staticcheck // not yet migrated to OpenFeature if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { annotation, resp := findAnnotationByID(c.Req.Context(), hs.annotationsRepo, annotationID, c.SignedInUser) if resp != nil { @@ -610,6 +615,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature }, } + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) { tempUser = &user.SignedInUser{ OrgID: orgID, @@ -626,6 +632,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature return nil, errors.New("could not resolve annotation type") } + //nolint:staticcheck // not yet migrated to OpenFeature if !features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) { switch annotation.GetType() { case annotations.Organization: @@ -662,6 +669,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature } func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardUID string) (bool, error) { + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { if dashboardUID != "" { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboardUID)) @@ -686,6 +694,7 @@ func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardU } func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashboardUID string) (bool, error) { + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { if dashboardUID == "" { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, accesscontrol.ScopeAnnotationsTypeOrganization) diff --git a/pkg/api/api.go b/pkg/api/api.go index 6a838206ba9..918ec160b69 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -121,8 +121,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/provisioning", reqOrgAdmin, hs.Index) r.Get("/admin/provisioning/*", reqOrgAdmin, hs.Index) - //nolint:staticcheck // not yet migrated to OpenFeature - if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) { + if hs.Cfg.CloudMigration.Enabled { r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index) } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 7bbf48ab0f2..1383f007c49 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -166,6 +166,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response } annotationPermissions := &dashboardsV1.AnnotationPermission{} + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) { hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Dashboard, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID)) } else { diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 4d412a0e271..218817c6801 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -287,6 +287,7 @@ type FrontendSettingsDTO struct { PublicDashboardAccessToken string `json:"publicDashboardAccessToken"` PublicDashboardsEnabled bool `json:"publicDashboardsEnabled"` + CloudMigrationEnabled bool `json:"cloudMigrationEnabled"` CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"` CloudMigrationPollIntervalMs int `json:"cloudMigrationPollIntervalMs"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 3554eb84244..2f223035bbb 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -150,6 +150,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro continue } + //nolint:staticcheck // not yet migrated to OpenFeature if panel.ID == "datagrid" && !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagEnableDatagridEditing) { continue } @@ -190,7 +191,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro hasAccess := accesscontrol.HasAccess(hs.AccessControl, c) trustedTypesDefaultPolicyEnabled := (hs.Cfg.CSPEnabled && strings.Contains(hs.Cfg.CSPTemplate, "require-trusted-types-for")) || (hs.Cfg.CSPReportOnlyEnabled && strings.Contains(hs.Cfg.CSPReportOnlyTemplate, "require-trusted-types-for")) - isCloudMigrationTarget := hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagOnPremToCloudMigrations) && hs.Cfg.CloudMigration.IsTarget + isCloudMigrationTarget := hs.Cfg.CloudMigration.Enabled && hs.Cfg.CloudMigration.IsTarget featureToggles := hs.Features.GetEnabled(c.Req.Context()) // this is needed for backwards compatibility with external plugins // we should remove this once we can be sure that no external plugins rely on this @@ -258,6 +259,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro PluginRestrictedAPIsBlockList: hs.Cfg.PluginRestrictedAPIsBlockList, PublicDashboardAccessToken: c.PublicDashboardAccessToken, PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled, + CloudMigrationEnabled: hs.Cfg.CloudMigration.Enabled, CloudMigrationIsTarget: isCloudMigrationTarget, CloudMigrationPollIntervalMs: int(hs.Cfg.CloudMigration.FrontendPollInterval.Milliseconds()), SharedWithMeFolderUID: folder.SharedWithMeFolderUID, @@ -406,6 +408,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro DisableSignoutMenu: hs.Cfg.DisableSignoutMenu, } + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) { hasEnabledProviders := hs.samlEnabled() || hs.authnService.IsClientEnabled(authn.ClientLDAP) @@ -444,6 +447,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro frontendSettings.Namespace = hs.namespacer(c.OrgID) // experimental scope features + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagScopeFilters) { frontendSettings.ListScopesEndpoint = hs.Cfg.ScopesListScopesURL frontendSettings.ListDashboardScopesEndpoint = hs.Cfg.ScopesListDashboardsURL diff --git a/pkg/api/index.go b/pkg/api/index.go index afbebb3b013..197a83d1013 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -62,6 +62,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV return nil, err } + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagIndividualCookiePreferences) { if !prefs.Cookies("analytics") { settings.GoogleAnalytics4Id = "" @@ -94,6 +95,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV } var regionalFormat string + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagLocaleFormatPreference) { regionalFormat = "en" diff --git a/pkg/api/login.go b/pkg/api/login.go index 89f452cd103..8c0bd9df5be 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -361,6 +361,7 @@ func (hs *HTTPServer) RedirectResponseWithError(c *contextmodel.ReqContext, err func (hs *HTTPServer) redirectURLWithErrorCookie(c *contextmodel.ReqContext, err error) string { setCookie := true + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagIndividualCookiePreferences) { var userID int64 if c.SignedInUser != nil && !c.IsNil() { diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index d6a333a05d1..0c20a98d2d3 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -81,6 +81,7 @@ func (proxy *PluginProxy) HandleRequest() { hasSlash := strings.HasSuffix(proxy.proxyPath, "/") proxy.proxyPath = path + //nolint:staticcheck // not yet migrated to OpenFeature if hasSlash && !strings.HasSuffix(path, "/") && proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagPluginProxyPreserveTrailingSlash) { proxy.proxyPath += "/" } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 517d2c3b295..4fa7292dcb5 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -144,6 +144,7 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons AngularDetected: pluginDef.Angular.Detected, } + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Cfg.ManagedServiceAccountsEnabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) { listItem.IAM = pluginDef.IAM } @@ -490,6 +491,7 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons return response.ErrOrFallback(http.StatusInternalServerError, "Failed to install plugin", err) } + //nolint:staticcheck // not yet migrated to OpenFeature if hs.Cfg.ManagedServiceAccountsEnabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) { // This is a non-blocking function that verifies that the installer has // the permissions that the plugin requests to have on Grafana. diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index 2e8a9892ab1..a884c8f16cf 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -83,7 +83,6 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err legacysql.NewDatabaseProvider(sqlStore), provisioning, acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), - featureToggles, ) if c.Bool("non-interactive") { diff --git a/pkg/expr/converter.go b/pkg/expr/converter.go index 3017afaeef1..70c20a5b47e 100644 --- a/pkg/expr/converter.go +++ b/pkg/expr/converter.go @@ -29,6 +29,7 @@ func (c *ResultConverter) Convert(ctx context.Context, } var dt data.FrameType + //nolint:staticcheck // not yet migrated to OpenFeature dt, useDataplane, _ := shouldUseDataplane(frames, logger, c.Features.IsEnabled(ctx, featuremgmt.FlagDisableSSEDataplane)) if useDataplane { logger.Debug("Handling SSE data source query through dataplane", "datatype", dt) diff --git a/pkg/expr/dataplane.go b/pkg/expr/dataplane.go index 3f79b11c428..c0931c4f3ed 100644 --- a/pkg/expr/dataplane.go +++ b/pkg/expr/dataplane.go @@ -70,6 +70,7 @@ func handleDataplaneFrames(ctx context.Context, tracer tracing.Tracer, features case data.KindTimeSeries: return handleDataplaneTimeseries(frames) case data.KindNumeric: + //nolint:staticcheck // not yet migrated to OpenFeature sortMetrics := !features.IsEnabled(ctx, featuremgmt.FlagDisableNumericMetricsSortingInExpressions) return handleDataplaneNumeric(frames, sortMetrics) default: diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go index ea711b635f3..25b2359cf38 100644 --- a/pkg/expr/graph.go +++ b/pkg/expr/graph.go @@ -68,7 +68,7 @@ type DataPipeline []Node // map of the refId of the of each command func (dp *DataPipeline) execute(c context.Context, now time.Time, s *Service) (mathexp.Vars, error) { vars := make(mathexp.Vars) - + //nolint:staticcheck // not yet migrated to OpenFeature groupByDSFlag := s.features.IsEnabled(c, featuremgmt.FlagSseGroupByDatasource) // Execute datasource nodes first, and grouped by datasource. if groupByDSFlag { diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 113c2f8e4bb..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -15,6 +15,7 @@ import ( _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" _ "github.com/crewjam/saml" + _ "github.com/docker/go-connections/nat" _ "github.com/go-jose/go-jose/v4" _ "github.com/gobwas/glob" _ "github.com/googleapis/gax-go/v2" @@ -30,6 +31,7 @@ import ( _ "github.com/spf13/cobra" // used by the standalone apiserver cli _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" + _ "github.com/testcontainers/testcontainers-go" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" _ "gocloud.dev/secrets/gcpkms" @@ -54,9 +56,7 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/grafana/tempo/pkg/traceql" - _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" - _ "github.com/testcontainers/testcontainers-go" + _ "github.com/grafana/tempo/pkg/traceql" ) diff --git a/pkg/middleware/loggermw/logger.go b/pkg/middleware/loggermw/logger.go index e83129cec3a..1255d87aa0e 100644 --- a/pkg/middleware/loggermw/logger.go +++ b/pkg/middleware/loggermw/logger.go @@ -64,6 +64,7 @@ func (l *loggerImpl) Middleware() web.Middleware { // put the start time on context so we can measure it later. r = r.WithContext(log.InitstartTime(r.Context(), time.Now())) + //nolint:staticcheck // not yet migrated to OpenFeature if l.flags.IsEnabled(r.Context(), featuremgmt.FlagUnifiedRequestLog) { r = r.WithContext(errutil.SetUnifiedLogging(r.Context())) } diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index cdcaacd6e51..9e32b75f7c9 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -114,6 +114,7 @@ func RequestMetrics(features featuremgmt.FeatureToggles, cfg *setting.Cfg, promR handler = "notfound" } else { // log requests where we could not identify handler so we can register them. + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(r.Context(), featuremgmt.FlagLogRequestsInstrumentedAsUnknown) { log.Warn("request instrumented as unknown", "path", r.URL.Path, "status_code", status) } diff --git a/pkg/plugins/backendplugin/coreplugin/registry.go b/pkg/plugins/backendplugin/coreplugin/registry.go index 811d400c520..1e610b1ef1c 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/plugins/backendplugin/coreplugin/registry.go @@ -237,7 +237,7 @@ func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient case Tempo: svc = tempo.ProvideService(httpClientProvider, tracer) case PostgreSQL: - svc = postgres.ProvideService(cfg) + svc = postgres.ProvideService() case MySQL: svc = mysql.ProvideService() case MSSQL: diff --git a/pkg/registry/apis/dashboard/datasources.go b/pkg/registry/apis/dashboard/datasources.go index 742dc76aad8..003a2c0de23 100644 --- a/pkg/registry/apis/dashboard/datasources.go +++ b/pkg/registry/apis/dashboard/datasources.go @@ -2,8 +2,10 @@ package dashboard import ( "context" + "encoding/json" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" @@ -112,6 +114,13 @@ func (l *libraryElementIndexProvider) GetLibraryElementInfo(ctx context.Context) } for _, elem := range result.Elements { + var modelUnstructured v0alpha1.Unstructured + if len(elem.Model) > 0 { + var modelObj map[string]any + if err := json.Unmarshal(elem.Model, &modelObj); err == nil { + modelUnstructured.Object = modelObj + } + } info = append(info, schemaversion.LibraryElementInfo{ UID: elem.UID, Name: elem.Name, @@ -119,6 +128,7 @@ func (l *libraryElementIndexProvider) GetLibraryElementInfo(ctx context.Context) Type: elem.Type, Description: elem.Description, FolderUID: elem.FolderUID, + Model: modelUnstructured, }) } diff --git a/pkg/registry/apis/dashboard/legacy/client.go b/pkg/registry/apis/dashboard/legacy/client.go index 1eeebd7b689..913dd02d14d 100644 --- a/pkg/registry/apis/dashboard/legacy/client.go +++ b/pkg/registry/apis/dashboard/legacy/client.go @@ -90,3 +90,8 @@ func (d *directResourceClient) Watch(ctx context.Context, in *resourcepb.WatchRe func (d *directResourceClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) { return nil, fmt.Errorf("BulkProcess not supported with direct resource client") } + +// RebuildIndexes implements resource.ResourceClient. +func (b *directResourceClient) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 7c17ec8b6aa..8c465ef7b24 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -83,9 +83,6 @@ type dashboardSqlAccess struct { namespacer request.NamespaceMapper provisioning provisioning.StubProvisioningService - // TODO: consider enabling this by default for on-prem migrations - invalidDashboardParseFallbackEnabled bool - // Use for writing (not reading) dashStore dashboards.Store dashboardSearchClient legacysearcher.DashboardSearchClient @@ -106,17 +103,15 @@ func ProvideMigratorDashboardAccessor( sql legacysql.LegacyDatabaseProvider, provisioning provisioning.StubProvisioningService, accessControl accesscontrol.AccessControl, - features featuremgmt.FeatureToggles, ) MigrationDashboardAccessor { return &dashboardSqlAccess{ - sql: sql, - namespacer: claims.OrgNamespaceFormatter, - dashStore: nil, // not needed for migration - provisioning: provisioning, - dashboardPermissionSvc: nil, // not needed for migration - libraryPanelSvc: nil, // not needed for migration - accessControl: accessControl, - invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled), + sql: sql, + namespacer: claims.OrgNamespaceFormatter, + dashStore: nil, // not needed for migration + provisioning: provisioning, + dashboardPermissionSvc: nil, // not needed for migration + libraryPanelSvc: nil, // not needed for migration + accessControl: accessControl, } } @@ -132,15 +127,14 @@ func NewDashboardSQLAccess(sql legacysql.LegacyDatabaseProvider, ) *dashboardSqlAccess { dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter) return &dashboardSqlAccess{ - sql: sql, - namespacer: namespacer, - dashStore: dashStore, - provisioning: provisioning, - dashboardSearchClient: *dashboardSearchClient, - dashboardPermissionSvc: dashboardPermissionSvc, - libraryPanelSvc: libraryPanelSvc, - accessControl: accessControl, - invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled), + sql: sql, + namespacer: namespacer, + dashStore: dashStore, + provisioning: provisioning, + dashboardSearchClient: *dashboardSearchClient, + dashboardPermissionSvc: dashboardPermissionSvc, + libraryPanelSvc: libraryPanelSvc, + accessControl: accessControl, } } @@ -591,17 +585,17 @@ func generateFallbackDashboard(data []byte, title, uid string) ([]byte, error) { func (a *dashboardSqlAccess) parseDashboard(dash *dashboardV1.Dashboard, data []byte, id int64, title string) error { if err := dash.Spec.UnmarshalJSON(data); err != nil { - a.log.Warn("error unmarshalling dashboard spec. Generating fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name) + a.log.Warn("error unmarshalling dashboard spec. Generating fallback dashboard data", "error", err, "uid", dash.UID, "id", id, "name", dash.Name) dash.Spec = *dashboardV0.NewDashboardSpec() dashboardData, err := generateFallbackDashboard(data, title, string(dash.UID)) if err != nil { - a.log.Warn("error generating fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name) + a.log.Warn("error generating fallback dashboard data", "error", err, "uid", dash.UID, "id", id, "name", dash.Name) return err } if err = dash.Spec.UnmarshalJSON(dashboardData); err != nil { - a.log.Warn("error unmarshalling fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name) + a.log.Warn("error unmarshalling fallback dashboard data", "error", err, "uid", dash.UID, "id", id, "name", dash.Name) return err } } @@ -713,14 +707,8 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo } if len(data) > 0 { - if a.invalidDashboardParseFallbackEnabled { - if err := a.parseDashboard(dash, data, dashboard_id, title); err != nil { - return row, err - } - } else { - if err := dash.Spec.UnmarshalJSON(data); err != nil { - return row, fmt.Errorf("JSON unmarshal error for: %s // %w", dash.Name, err) - } + if err := a.parseDashboard(dash, data, dashboard_id, title); err != nil { + return row, err } } // Ignore any saved values for id/version/uid @@ -1046,3 +1034,7 @@ func parseLibraryPanelRow(p panel) (dashboardV0.LibraryPanel, error) { return item, nil } + +func (b *dashboardSqlAccess) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest) (*resourcepb.RebuildIndexesResponse, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go index b142b144800..e95ee0db6aa 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -35,10 +35,9 @@ func TestScanRow(t *testing.T) { provisioner := provisioning.NewProvisioningServiceMock(context.Background()) provisioner.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "provisioner" } store := &dashboardSqlAccess{ - namespacer: func(_ int64) string { return "default" }, - provisioning: provisioner, - log: log.New("test"), - invalidDashboardParseFallbackEnabled: false, + namespacer: func(_ int64) string { return "default" }, + provisioning: provisioner, + log: log.New("test"), } columns := []string{"orgId", "dashboard_id", "name", "title", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data", "api_version"} @@ -194,39 +193,25 @@ func TestScanRow(t *testing.T) { require.Equal(t, "dashboard.grafana.app/"+migrationAPIVersion, row.Dash.APIVersion) }) - t.Run("should follow dashboard template when failing to unmarshal dashboard if feature flag X is enabled", func(t *testing.T) { + t.Run("should follow dashboard template when failing to unmarshal dashboard", func(t *testing.T) { // row with bad data badData := []byte(`{"rows":[{"panels":[{"targets":[{"refId":"A","target":"aliasSub(alias, '^(.{27}).+', '\1...')"}]}]}]}`) rows := sqlmock.NewRows(columns).AddRow(1, id, uid, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, badData, "vXyz") mock.ExpectQuery("SELECT *").WillReturnRows(rows) resultRows, err := mockDB.Query("SELECT *") require.NoError(t, err) - defer resultRows.Close() // nolint:errcheck + defer func() { + _ = resultRows.Close() + }() resultRows.Next() - row, err := store.scanRow(resultRows, false) - require.Error(t, err, "JSON unmarshal error for: Test Dashboard // invalid character '1' in string escape code") - require.NotNil(t, row) - // correctly scans these - require.Equal(t, uid, row.Dash.Name) - require.Equal(t, version, row.RV) - require.Equal(t, "default", row.Dash.Namespace) - require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token) - - // failure case: does NOT parse the dashboard itself - require.Equal(t, common.Unstructured{ - Object: nil, - }, row.Dash.Spec) - - // store with feature flag enabled store = &dashboardSqlAccess{ - namespacer: func(_ int64) string { return "default" }, - provisioning: provisioner, - log: log.New("test"), - invalidDashboardParseFallbackEnabled: true, + namespacer: func(_ int64) string { return "default" }, + provisioning: provisioner, + log: log.New("test"), } - row, err = store.scanRow(resultRows, false) + row, err := store.scanRow(resultRows, false) require.NoError(t, err) require.NotNil(t, row) require.Equal(t, uid, row.Dash.Name) diff --git a/pkg/registry/apis/dashboard/schema_validation.go b/pkg/registry/apis/dashboard/schema_validation.go index c53ed82481a..375455470c8 100644 --- a/pkg/registry/apis/dashboard/schema_validation.go +++ b/pkg/registry/apis/dashboard/schema_validation.go @@ -33,9 +33,11 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru case *v0.Dashboard: errorOnSchemaMismatches = false // Never error for v0 case *v1.Dashboard: + //nolint:staticcheck // not yet migrated to OpenFeature errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1) case *v2alpha1.Dashboard: case *v2beta1.Dashboard: + //nolint:staticcheck // not yet migrated to OpenFeature errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2) default: return nil, fmt.Errorf("invalid dashboard type: %T", obj) @@ -45,6 +47,7 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru return nil, apierrors.NewBadRequest("Not supported: FieldValidationMode: Warn") } + //nolint:staticcheck // not yet migrated to OpenFeature alwaysLogSchemaValidationErrors := b.features.IsEnabled(ctx, featuremgmt.FlagDashboardSchemaValidationLogging) var errors field.ErrorList diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index 1f67c0da9d8..c4bf07bc71c 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -581,3 +581,8 @@ func (m *mockSearchClient) GetStats(ctx context.Context, in *resourcepb.Resource func (m *mockSearchClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { return m.search, m.searchErr } + +// RebuildIndexes implements resourcepb.ResourceIndexClient. +func (m *mockSearchClient) RebuildIndexes(ctx context.Context, in *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go index a204bb5b1a7..1e8f5e2acfb 100644 --- a/pkg/registry/apis/secret/contracts/keeper.go +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -10,6 +10,9 @@ import ( ) var ( + // The name used to refer to the system keeper + SystemKeeperName = "system" + ErrKeeperNotFound = errors.New("keeper not found") ErrKeeperAlreadyExists = errors.New("keeper already exists") ) @@ -21,7 +24,9 @@ type KeeperMetadataStorage interface { Update(ctx context.Context, keeper *secretv1beta1.Keeper, actorUID string) (*secretv1beta1.Keeper, error) Delete(ctx context.Context, namespace xkube.Namespace, name string) error List(ctx context.Context, namespace xkube.Namespace) ([]secretv1beta1.Keeper, error) - GetKeeperConfig(ctx context.Context, namespace string, name *string, opts ReadOpts) (secretv1beta1.KeeperConfig, error) + GetKeeperConfig(ctx context.Context, namespace string, name string, opts ReadOpts) (secretv1beta1.KeeperConfig, error) + SetAsActive(ctx context.Context, namespace xkube.Namespace, name string) error + GetActiveKeeperConfig(ctx context.Context, namespace string) (string, secretv1beta1.KeeperConfig, error) } // ErrKeeperInvalidSecureValues is returned when a Keeper references SecureValues that do not exist. diff --git a/pkg/registry/apis/secret/contracts/secure_value.go b/pkg/registry/apis/secret/contracts/secure_value.go index 533cb005f79..bfb763be3f6 100644 --- a/pkg/registry/apis/secret/contracts/secure_value.go +++ b/pkg/registry/apis/secret/contracts/secure_value.go @@ -31,7 +31,7 @@ type ReadOpts struct { // SecureValueMetadataStorage is the interface for wiring and dependency injection. type SecureValueMetadataStorage interface { - Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) + Create(ctx context.Context, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) Read(ctx context.Context, namespace xkube.Namespace, name string, opts ReadOpts) (*secretv1beta1.SecureValue, error) List(ctx context.Context, namespace xkube.Namespace) ([]secretv1beta1.SecureValue, error) SetVersionToActive(ctx context.Context, namespace xkube.Namespace, name string, version int64) error @@ -47,6 +47,7 @@ type SecureValueService interface { List(ctx context.Context, namespace xkube.Namespace) (*secretv1beta1.SecureValueList, error) Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error) Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error) + SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, keeperName string) error } type SecureValueClient interface { diff --git a/pkg/registry/apis/secret/garbagecollectionworker/worker.go b/pkg/registry/apis/secret/garbagecollectionworker/worker.go index e10bbed600e..b967b1f21a1 100644 --- a/pkg/registry/apis/secret/garbagecollectionworker/worker.go +++ b/pkg/registry/apis/secret/garbagecollectionworker/worker.go @@ -93,14 +93,14 @@ func (w *Worker) CleanupInactiveSecureValues(ctx context.Context) ([]secretv1bet } func (w *Worker) Cleanup(ctx context.Context, sv *secretv1beta1.SecureValue) error { - keeperCfg, err := w.keeperMetadataStorage.GetKeeperConfig(ctx, sv.Namespace, sv.Spec.Keeper, contracts.ReadOpts{ForUpdate: false}) + keeperCfg, err := w.keeperMetadataStorage.GetKeeperConfig(ctx, sv.Namespace, sv.Status.Keeper, contracts.ReadOpts{ForUpdate: false}) if err != nil { - return fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Spec.Keeper, err) + return fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Status.Keeper, err) } keeper, err := w.keeperService.KeeperForConfig(keeperCfg) if err != nil { - return fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Spec.Keeper, err) + return fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Status.Keeper, err) } // Keeper deletion is idempotent diff --git a/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go b/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go index 23467d69b39..9b27aceefc1 100644 --- a/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go +++ b/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go @@ -1,7 +1,6 @@ package garbagecollectionworker_test import ( - "fmt" "slices" "testing" "time" @@ -11,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/storage/secret/encryption" - "github.com/mitchellh/copystructure" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -52,7 +50,7 @@ func TestBasic(t *testing.T) { sv, err := sut.CreateSv(t.Context()) require.NoError(t, err) - keeperCfg, err := sut.KeeperMetadataStorage.GetKeeperConfig(t.Context(), sv.Namespace, sv.Spec.Keeper, contracts.ReadOpts{ForUpdate: false}) + keeperCfg, err := sut.KeeperMetadataStorage.GetKeeperConfig(t.Context(), sv.Namespace, sv.Status.Keeper, contracts.ReadOpts{ForUpdate: false}) require.NoError(t, err) keeper, err := sut.KeeperService.KeeperForConfig(keeperCfg) @@ -133,7 +131,7 @@ func TestProperty(t *testing.T) { t.Repeat(map[string]func(*rapid.T){ "create": func(t *rapid.T) { sv := anySecureValueGen.Draw(t, "sv") - svCopy := deepCopy(sv) + svCopy := sv.DeepCopy() createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) svCopy.UID = createdSv.UID @@ -194,13 +192,15 @@ func newModel() *model { } func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) error { + created := now for _, item := range m.items { if item.active && item.Namespace == sv.Namespace && item.Name == sv.Name { item.active = false + created = item.created break } } - m.items = append(m.items, &modelSecureValue{SecureValue: sv, active: true, created: now}) + m.items = append(m.items, &modelSecureValue{SecureValue: sv, active: true, created: created}) return nil } @@ -219,6 +219,16 @@ func (m *model) cleanupInactiveSecureValues(now time.Time, minAge time.Duration, // Using a slice to allow duplicates toDelete := make([]*modelSecureValue, 0) + // The implementation query sorts by created time ascending + slices.SortFunc(m.items, func(a, b *modelSecureValue) int { + if a.created.Before(b.created) { + return -1 + } else if a.created.After(b.created) { + return 1 + } + return 0 + }) + for _, sv := range m.items { if len(toDelete) >= int(maxBatchSize) { break @@ -238,11 +248,3 @@ func (m *model) cleanupInactiveSecureValues(now time.Time, minAge time.Duration, return toDelete, nil } - -func deepCopy[T any](sv T) T { - copied, err := copystructure.Copy(sv) - if err != nil { - panic(fmt.Sprintf("failed to copy secure value: %v", err)) - } - return copied.(T) -} diff --git a/pkg/registry/apis/secret/service/secure_value.go b/pkg/registry/apis/secret/service/secure_value.go index cc4e6c80c72..91f6a2e73ff 100644 --- a/pkg/registry/apis/secret/service/secure_value.go +++ b/pkg/registry/apis/secret/service/secure_value.go @@ -93,7 +93,13 @@ func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.Secur s.metrics.SecureValueCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) }() - return s.createNewVersion(ctx, sv, actorUID) + // Secure value creation uses the active keeper + keeperName, keeperCfg, err := s.keeperMetadataStorage.GetActiveKeeperConfig(ctx, sv.Namespace) + if err != nil { + return nil, fmt.Errorf("fetching active keeper config: namespace=%+v %w", sv.Namespace, err) + } + + return s.createNewVersion(ctx, keeperName, keeperCfg, sv, actorUID) } func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, sync bool, updateErr error) { @@ -128,23 +134,22 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv s.metrics.SecureValueUpdateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) }() + currentVersion, err := s.secureValueMetadataStorage.Read(ctx, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, contracts.ReadOpts{}) + if err != nil { + return nil, false, fmt.Errorf("reading secure value secret: %+w", err) + } + + keeperCfg, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, currentVersion.Namespace, currentVersion.Status.Keeper, contracts.ReadOpts{}) + if err != nil { + return nil, false, fmt.Errorf("fetching keeper config: namespace=%+v keeper: %q %w", newSecureValue.Namespace, currentVersion.Status.Keeper, err) + } + if newSecureValue.Spec.Value == nil { - currentVersion, err := s.secureValueMetadataStorage.Read(ctx, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, contracts.ReadOpts{}) - if err != nil { - return nil, false, fmt.Errorf("reading secure value secret: %+w", err) - } - - // TODO: does this need to be for update? - keeperCfg, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, newSecureValue.Namespace, newSecureValue.Spec.Keeper, contracts.ReadOpts{ForUpdate: true}) - if err != nil { - return nil, false, fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Spec.Keeper, err) - } - keeper, err := s.keeperService.KeeperForConfig(keeperCfg) if err != nil { - return nil, false, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Spec.Keeper, err) + return nil, false, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Status.Keeper, err) } - logging.FromContext(ctx).Debug("retrieved keeper", "namespace", newSecureValue.Namespace, "keeperName", newSecureValue.Spec.Keeper, "type", keeperCfg.Type()) + logging.FromContext(ctx).Debug("retrieved keeper", "namespace", newSecureValue.Namespace, "type", keeperCfg.Type()) secret, err := keeper.Expose(ctx, keeperCfg, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, currentVersion.Status.Version) if err != nil { @@ -154,12 +159,16 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv newSecureValue.Spec.Value = &secret } + // Secure value updates use the keeper used to create the secure value const updateIsSync = true - createdSv, err := s.createNewVersion(ctx, newSecureValue, actorUID) + createdSv, err := s.createNewVersion(ctx, currentVersion.Status.Keeper, keeperCfg, newSecureValue, actorUID) return createdSv, updateIsSync, err } -func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) { +func (s *SecureValueService) createNewVersion(ctx context.Context, keeperName string, keeperCfg secretv1beta1.KeeperConfig, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) { + if keeperName == "" { + return nil, fmt.Errorf("keeper name is required, got empty string") + } if err := s.secureValueMutator.Mutate(sv, admission.Create); err != nil { return nil, err } @@ -168,25 +177,21 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1b return nil, contracts.NewErrValidateSecureValue(errorList) } - createdSv, err := s.secureValueMetadataStorage.Create(ctx, sv, actorUID) + createdSv, err := s.secureValueMetadataStorage.Create(ctx, keeperName, sv, actorUID) if err != nil { return nil, fmt.Errorf("creating secure value: %w", err) } + createdSv.Status = secretv1beta1.SecureValueStatus{ Version: createdSv.Status.Version, - } - - // TODO: does this need to be for update? - keeperCfg, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, createdSv.Namespace, createdSv.Spec.Keeper, contracts.ReadOpts{ForUpdate: true}) - if err != nil { - return nil, fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, createdSv.Spec.Keeper, err) + Keeper: keeperName, } keeper, err := s.keeperService.KeeperForConfig(keeperCfg) if err != nil { - return nil, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, createdSv.Spec.Keeper, err) + return nil, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, keeperName, err) } - logging.FromContext(ctx).Debug("retrieved keeper", "namespace", createdSv.Namespace, "keeperName", createdSv.Spec.Keeper, "type", keeperCfg.Type()) + logging.FromContext(ctx).Debug("retrieved keeper", "namespace", createdSv.Namespace, "type", keeperCfg.Type()) // TODO: can we stop using external id? // TODO: store uses only the namespace and returns and id. It could be a kv instead. @@ -364,3 +369,10 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa return sv, nil } + +func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error { + if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil { + return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err) + } + return nil +} diff --git a/pkg/registry/apis/secret/validator/keeper.go b/pkg/registry/apis/secret/validator/keeper.go index 737ef355794..b4220b45a65 100644 --- a/pkg/registry/apis/secret/validator/keeper.go +++ b/pkg/registry/apis/secret/validator/keeper.go @@ -41,6 +41,10 @@ func (v *keeperValidator) Validate(keeper *secretv1beta1.Keeper, oldKeeper *secr return errs } + if keeper.Name == contracts.SystemKeeperName { + errs = append(errs, field.Forbidden(field.NewPath("name"), "the keeper name `system` is reserved")) + } + if keeper.Spec.Description == "" { errs = append(errs, field.Required(field.NewPath("spec", "description"), "a `description` is required")) } diff --git a/pkg/registry/apis/secret/validator/keeper_test.go b/pkg/registry/apis/secret/validator/keeper_test.go index 1dcb51b3e31..99b108e480e 100644 --- a/pkg/registry/apis/secret/validator/keeper_test.go +++ b/pkg/registry/apis/secret/validator/keeper_test.go @@ -35,36 +35,6 @@ func TestValidateKeeper(t *testing.T) { }) }) - t.Run("only one `keeper` must be present", func(t *testing.T) { - keeper := &secretv1beta1.Keeper{ - ObjectMeta: objectMeta, - Spec: secretv1beta1.KeeperSpec{ - Description: "short description", - Aws: &secretv1beta1.KeeperAWSConfig{}, - Azure: &secretv1beta1.KeeperAzureConfig{}, - Gcp: &secretv1beta1.KeeperGCPConfig{}, - HashiCorpVault: &secretv1beta1.KeeperHashiCorpConfig{}, - }, - } - - errs := validator.Validate(keeper, nil, admission.Create) - require.Len(t, errs, 1) - require.Equal(t, "spec", errs[0].Field) - }) - - t.Run("at least one `keeper` must be present", func(t *testing.T) { - keeper := &secretv1beta1.Keeper{ - ObjectMeta: objectMeta, - Spec: secretv1beta1.KeeperSpec{ - Description: "description", - }, - } - - errs := validator.Validate(keeper, nil, admission.Create) - require.Len(t, errs, 1) - require.Equal(t, "spec", errs[0].Field) - }) - t.Run("aws keeper validation", func(t *testing.T) { validKeeperAWS := &secretv1beta1.Keeper{ ObjectMeta: objectMeta, @@ -341,4 +311,27 @@ func TestValidateKeeper(t *testing.T) { require.Len(t, errs, 1) require.Equal(t, "metadata.namespace", errs[0].Field) }) + + t.Run("keeper name `system` is reserved", func(t *testing.T) { + keeper := &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "system", + Namespace: "ns1", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + HashiCorpVault: &secretv1beta1.KeeperHashiCorpConfig{ + Address: "http://address", + Token: secretv1beta1.KeeperCredentialValue{ + ValueFromConfig: "config.path.value", + }, + }, + }, + } + + errs := validator.Validate(keeper, nil, admission.Create) + require.Len(t, errs, 1) + require.Equal(t, "name", errs[0].Field) + require.Equal(t, "the keeper name `system` is reserved", errs[0].Detail) + }) } diff --git a/pkg/registry/apis/secret/validator/secure_value.go b/pkg/registry/apis/secret/validator/secure_value.go index 0df885bff08..a29bb57d4ff 100644 --- a/pkg/registry/apis/secret/validator/secure_value.go +++ b/pkg/registry/apis/secret/validator/secure_value.go @@ -110,11 +110,6 @@ func validateSecureValueUpdate(sv, oldSv *secretv1beta1.SecureValue) field.Error } } - // Keeper cannot be changed. - if sv.Spec.Keeper != oldSv.Spec.Keeper { - errs = append(errs, field.Forbidden(field.NewPath("spec"), "the `keeper` cannot be changed")) - } - return errs } diff --git a/pkg/registry/apis/secret/validator/secure_value_test.go b/pkg/registry/apis/secret/validator/secure_value_test.go index 88a6220db54..b3aa25057fd 100644 --- a/pkg/registry/apis/secret/validator/secure_value_test.go +++ b/pkg/registry/apis/secret/validator/secure_value_test.go @@ -25,9 +25,9 @@ func TestValidateSecureValue(t *testing.T) { Spec: secretv1beta1.SecureValueSpec{ Description: "description", Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")), - Keeper: &keeper, Decrypters: []string{"app1", "app2"}, }, + Status: secretv1beta1.SecureValueStatus{Keeper: keeper}, } t.Run("the `description` must be present", func(t *testing.T) { @@ -182,28 +182,6 @@ func TestValidateSecureValue(t *testing.T) { require.Len(t, errs, 1) require.Equal(t, "spec", errs[0].Field) }) - - t.Run("when trying to change the `keeper`, it returns an error", func(t *testing.T) { - keeperA := "a-keeper" - keeperAnother := "another-keeper" - oldSv := &secretv1beta1.SecureValue{ - ObjectMeta: objectMeta, - Spec: secretv1beta1.SecureValueSpec{ - Keeper: &keeperA, - }, - } - - sv := &secretv1beta1.SecureValue{ - ObjectMeta: objectMeta, - Spec: secretv1beta1.SecureValueSpec{ - Keeper: &keeperAnother, - }, - } - - errs := validator.Validate(sv, oldSv, admission.Update) - require.Len(t, errs, 1) - require.Equal(t, "spec", errs[0].Field) - }) }) t.Run("`decrypters` must have unique items", func(t *testing.T) { diff --git a/pkg/server/search_server_distributor_test.go b/pkg/server/search_server_distributor_test.go index 92182586105..65f6346d0a8 100644 --- a/pkg/server/search_server_distributor_test.go +++ b/pkg/server/search_server_distributor_test.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "strconv" + "strings" "sync" "testing" "time" @@ -175,6 +176,28 @@ func TestIntegrationDistributor(t *testing.T) { } }) + t.Run("RebuildIndexes", func(t *testing.T) { + instanceResponseCount := make(map[string]int) + + // simulate RebuildIndexes for a single namespace + testNamespace := testNamespaces[0] + + req := &resourcepb.RebuildIndexesRequest{ + Namespace: testNamespace, + Keys: []*resourcepb.ResourceKey{{ + Namespace: testNamespace, + Group: "folder.grafana.app", + Resource: "folders", + }}, + } + distributorRes := getDistributorResponse(t, req, distributorServer.resourceClient.RebuildIndexes, instanceResponseCount) + require.Nil(t, distributorRes.Error) + + // assert all instances got the response by looking at the merged details + count := strings.Count(distributorRes.Details, "{instance:") + require.Equal(t, len(testServers), count) + }) + var wg sync.WaitGroup for _, testServer := range testServers { wg.Add(1) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 0cb27791c19..eb448ba41a8 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -400,7 +400,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api prometheusService := prometheus.ProvideService(httpclientProvider) tempoService := tempo.ProvideService(httpclientProvider, tracer) testdatasourceService := testdatasource.ProvideService() - postgresService := postgres.ProvideService(cfg) + postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() mssqlService := mssql.ProvideService(cfg) entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) @@ -532,9 +532,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles) + migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl) unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient) - unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore) + unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore, resourceClient) dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService) if err != nil { return nil, err @@ -1047,7 +1047,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac prometheusService := prometheus.ProvideService(httpclientProvider) tempoService := tempo.ProvideService(httpclientProvider, tracer) testdatasourceService := testdatasource.ProvideService() - postgresService := postgres.ProvideService(cfg) + postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() mssqlService := mssql.ProvideService(cfg) entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) @@ -1179,9 +1179,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles) + migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl) unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient) - unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore) + unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore, resourceClient) dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService) if err != nil { return nil, err diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 3c1deeb1935..1ea8bf95f77 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -741,6 +741,7 @@ func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.SaveExternalServiceRole") defer span.End() + //nolint:staticcheck // not yet migrated to OpenFeature if !s.cfg.ManagedServiceAccountsEnabled || !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.") return nil @@ -757,6 +758,7 @@ func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalService ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.DeleteExternalServiceRole") defer span.End() + //nolint:staticcheck // not yet migrated to OpenFeature if !s.cfg.ManagedServiceAccountsEnabled || !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.") return nil diff --git a/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go b/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go index a588ac14ce7..317585ef8ac 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go @@ -28,6 +28,7 @@ var DashboardEditActions = append(DashboardViewActions, []string{dashboards.Acti var DashboardAdminActions = append(DashboardEditActions, []string{dashboards.ActionDashboardsPermissionsRead, dashboards.ActionDashboardsPermissionsWrite}...) func getDashboardViewActions(features featuremgmt.FeatureToggles) []string { + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { return append(DashboardViewActions, accesscontrol.ActionAnnotationsRead) } @@ -35,6 +36,7 @@ func getDashboardViewActions(features featuremgmt.FeatureToggles) []string { } func getDashboardEditActions(features featuremgmt.FeatureToggles) []string { + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { return append(DashboardEditActions, []string{accesscontrol.ActionAnnotationsRead, accesscontrol.ActionAnnotationsWrite, accesscontrol.ActionAnnotationsDelete, accesscontrol.ActionAnnotationsCreate}...) } @@ -42,6 +44,7 @@ func getDashboardEditActions(features featuremgmt.FeatureToggles) []string { } func getDashboardAdminActions(features featuremgmt.FeatureToggles) []string { + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) { return append(DashboardAdminActions, []string{accesscontrol.ActionAnnotationsRead, accesscontrol.ActionAnnotationsWrite, accesscontrol.ActionAnnotationsDelete, accesscontrol.ActionAnnotationsCreate}...) } diff --git a/pkg/services/annotations/accesscontrol/accesscontrol.go b/pkg/services/annotations/accesscontrol/accesscontrol.go index 1627469f213..19260e63c27 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol.go @@ -61,6 +61,7 @@ func (authz *AuthService) Authorize(ctx context.Context, query annotations.ItemQ scopeTypes := annotationScopeTypes(scopes) _, canAccessOrgAnnotations := scopeTypes[annotations.Organization.String()] _, canAccessDashAnnotations := scopeTypes[annotations.Dashboard.String()] + //nolint:staticcheck // not yet migrated to OpenFeature if authz.features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) { canAccessDashAnnotations = true } @@ -122,6 +123,7 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context, } filterType := searchstore.TypeDashboard + //nolint:staticcheck // not yet migrated to OpenFeature if authz.features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) { filterType = searchstore.TypeAnnotation } diff --git a/pkg/services/apiserver/auth/authorizer/role.go b/pkg/services/apiserver/auth/authorizer/role.go index 82e80b38164..39b3b440665 100644 --- a/pkg/services/apiserver/auth/authorizer/role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -3,6 +3,7 @@ package authorizer import ( "context" "fmt" + "slices" "k8s.io/apiserver/pkg/authorization/authorizer" @@ -12,6 +13,10 @@ import ( var _ authorizer.Authorizer = &roleAuthorizer{} +var orgRoleNoneAsViewerAPIGroups = []string{ + "productactivation.ext.grafana.com", +} + type roleAuthorizer struct{} func newRoleAuthorizer() *roleAuthorizer { @@ -43,6 +48,16 @@ func (auth roleAuthorizer) Authorize(ctx context.Context, a authorizer.Attribute return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } case org.RoleNone: + // HOTFIX: granting Viewer actions to None roles to a fixed group of APIs, + // while we work on a proper fix. + if slices.Contains(orgRoleNoneAsViewerAPIGroups, a.GetAPIGroup()) { + switch a.GetVerb() { + case "get", "list", "watch": + return authorizer.DecisionAllow, "", nil + default: + return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil + } + } return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } return authorizer.DecisionDeny, "", nil diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index 434ffabdea9..19cbe411b78 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -83,6 +83,7 @@ func ProvideRegistration( } } + //nolint:staticcheck // not yet migrated to OpenFeature if cfg.PasswordlessMagicLinkAuth.Enabled && features.IsEnabled(context.Background(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) { hasEnabledProviders := authnSvc.IsClientEnabled(authn.ClientSAML) || authnSvc.IsClientEnabled(authn.ClientLDAP) if !hasEnabledProviders { diff --git a/pkg/services/caching/service.go b/pkg/services/caching/service.go index c5bc577b122..d874554cae9 100644 --- a/pkg/services/caching/service.go +++ b/pkg/services/caching/service.go @@ -210,6 +210,7 @@ func (c *CachingServiceClient) WithQueryDataCaching(ctx context.Context, req *ba // Update the query cache with the result for this metrics request if err == nil && cr.UpdateCacheFn != nil { // If AWS async caching is not enabled, use the old code path + //nolint:staticcheck // not yet migrated to OpenFeature if c.features == nil || !c.features.IsEnabled(ctx, featuremgmt.FlagAwsAsyncQueryCaching) { cr.UpdateCacheFn(ctx, resp) } else if reqCtx != nil { diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go index 30c8a4e409d..2f2befcc779 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -61,7 +61,6 @@ type Service struct { isSyncSnapshotStatusFromGMSRunning int32 - features featuremgmt.FeatureToggles gmsClient gmsclient.Client objectStorage objectstorage.ObjectStorage @@ -119,8 +118,7 @@ func ProvideService( libraryElementsService libraryelements.Service, ngAlert *ngalert.AlertNG, ) (cloudmigration.Service, error) { - //nolint:staticcheck // not yet migrated to OpenFeature - if !features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) { + if !cfg.CloudMigration.Enabled { return &NoopServiceImpl{}, nil } @@ -132,7 +130,6 @@ func ProvideService( store: &sqlStore{db: db, secretsStore: secretsStore, secretsService: secretsService}, log: log.New(LogPrefix), cfg: cfg, - features: features, dsService: dsService, tracer: tracer, metrics: newMetrics(), diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go index 2d393236801..9a99e2d702b 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go @@ -907,6 +907,7 @@ func setUpServiceTest(t *testing.T, cfgOverrides ...configOverrides) cloudmigrat _, err = section.NewKey("domain", "localhost:1234") require.NoError(t, err) + cfg.CloudMigration.Enabled = true cfg.CloudMigration.IsDeveloperMode = true // ensure local implementations are used cfg.CloudMigration.SnapshotFolder = filepath.Join(os.TempDir(), uuid.NewString()) @@ -919,15 +920,11 @@ func setUpServiceTest(t *testing.T, cfgOverrides ...configOverrides) cloudmigrat }, } - featureToggles := featuremgmt.WithFeatures( - featuremgmt.FlagOnPremToCloudMigrations, - ) + featureToggles := featuremgmt.WithFeatures() sqlStore := sqlstore.NewTestStore(t, sqlstore.WithCfg(cfg), - sqlstore.WithFeatureFlags( - featuremgmt.FlagOnPremToCloudMigrations, - ), + sqlstore.WithFeatureFlags(), ) kvStore := kvstore.ProvideService(sqlStore) diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go index 611b10c317b..ee2c9889c20 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -43,7 +42,6 @@ func TestGetAlertMuteTimings(t *testing.T) { t.Parallel() s := setUpServiceTest(t).(*Service) - s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations) user := &user.SignedInUser{OrgID: 1} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 52b1488e71e..8c48121f925 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -207,6 +207,33 @@ type AuthHTTPHeaderList struct { Items []string } +func GetAuthHTTPHeaders(jwtAuth *setting.AuthJWTSettings, authProxy *setting.AuthProxySettings) []string { + var items []string + + // used by basic auth, api keys and potentially jwt auth + items = append(items, "Authorization") + + // remove X-Grafana-Device-Id as it is only used for auth in authn clients. + items = append(items, "X-Grafana-Device-Id") + + // if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization + if jwtAuth.Enabled && jwtAuth.HeaderName != "" && jwtAuth.HeaderName != "Authorization" { + items = append(items, jwtAuth.HeaderName) + } + + // if auth proxy is enabled add the main proxy header and all configured headers + if authProxy.Enabled { + items = append(items, authProxy.HeaderName) + for _, header := range authProxy.Headers { + if header != "" { + items = append(items, header) + } + } + } + + return items +} + // WithAuthHTTPHeaders returns a new context in which all possible configured auth header will be included // and later retrievable by AuthHTTPHeaderListFromContext. func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context { @@ -217,26 +244,7 @@ func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context } } - // used by basic auth, api keys and potentially jwt auth - list.Items = append(list.Items, "Authorization") - - // remove X-Grafana-Device-Id as it is only used for auth in authn clients. - list.Items = append(list.Items, "X-Grafana-Device-Id") - - // if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization - if cfg.JWTAuth.Enabled && cfg.JWTAuth.HeaderName != "" && cfg.JWTAuth.HeaderName != "Authorization" { - list.Items = append(list.Items, cfg.JWTAuth.HeaderName) - } - - // if auth proxy is enabled add the main proxy header and all configured headers - if cfg.AuthProxy.Enabled { - list.Items = append(list.Items, cfg.AuthProxy.HeaderName) - for _, header := range cfg.AuthProxy.Headers { - if header != "" { - list.Items = append(list.Items, header) - } - } - } + list.Items = append(list.Items, GetAuthHTTPHeaders(&cfg.JWTAuth, &cfg.AuthProxy)...) return context.WithValue(ctx, authHTTPHeaderListKey, list) } diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index 45d3dc810f4..304d17ee075 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -10,6 +10,10 @@ import ( type FeatureToggles interface { // IsEnabled checks if a feature is enabled for a given context. // The settings may be per user, tenant, or globally set in the cloud + // + // Deprecated: FeatureToggles.IsEnabled is deprecated and will be removed in a future release. + // Evaluate with OpenFeature instead (see [github.com/open-feature/go-sdk/openfeature.Client]), for example: + // openfeature.NewDefaultClient().Boolean(ctx, "your-flag", false, openfeature.TransactionContext(ctx)) IsEnabled(ctx context.Context, flag string) bool // IsEnabledGlobally checks if a flag is configured globally. For now, this is the same @@ -19,7 +23,9 @@ type FeatureToggles interface { // a full server restart for a change to take place. // // Deprecated: FeatureToggles.IsEnabledGlobally is deprecated and will be removed in a future release. - // Evaluate with OpenFeature instead (see [github.com/open-feature/go-sdk/openfeature.Client]) + // Toggles that must be reliably evaluated at the service startup should be + // changed to settings (see setting.StartupSettings), and/or removed entirely. + // For app registration please use `grafana-apiserver.runtime_config` in settings.ini IsEnabledGlobally(flag string) bool // Get the enabled flags -- this *may* also include disabled flags (with value false) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e6587428f18..24e057cd882 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -527,6 +527,13 @@ var ( Owner: grafanaAlertingSquad, HideFromDocs: true, }, + { + Name: "alertingUIUseFullyCompatBackendFilters", + Description: "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + }, { Name: "alertmanagerRemotePrimary", Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", @@ -586,6 +593,13 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, }, + { + Name: "perPanelNonApplicableDrilldowns", + Description: "Enables viewing non-applicable drilldowns on a panel level", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, + }, { Name: "panelFilterVariable", Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard", @@ -615,14 +629,6 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, }, - { - Name: "logsInfiniteScrolling", - Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards", - Stage: FeatureStageGeneralAvailability, - Expression: "true", - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - }, { Name: "tableSharedCrosshair", Description: "Enables shared crosshair in table panel", @@ -661,13 +667,6 @@ var ( HideFromDocs: true, RequiresRestart: true, }, - { - Name: "onPremToCloudMigrations", - Description: "Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack.", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOperatorExperienceSquad, - Expression: "true", - }, { Name: "secretsManagementAppPlatform", Description: "Enable the secrets management API and services under app platform", @@ -825,18 +824,25 @@ var ( }, { Name: "dashboardLibrary", - Description: "Enable dashboard library experiments that are production ready", + Description: "Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page", Stage: FeatureStageExperimental, Owner: grafanaSharingSquad, FrontendOnly: false, }, { Name: "suggestedDashboards", - Description: "Enable suggested dashboards when creating new dashboards", + Description: "Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page", Stage: FeatureStageExperimental, Owner: grafanaSharingSquad, FrontendOnly: false, }, + { + Name: "dashboardTemplates", + Description: "Enables a flow to get started with a new dashboard from a template", + Stage: FeatureStagePublicPreview, + Owner: grafanaSharingSquad, + FrontendOnly: false, + }, { Name: "logsExploreTableDefaultVisualization", Description: "Sets the logs table as default visualisation in logs explore", @@ -984,7 +990,7 @@ var ( }, { Name: "exploreLogsShardSplitting", - Description: "Used in Logs Drilldown to split queries into multiple queries based on the number of shards", + Description: "Deprecated. Replace with lokiShardSplitting. Used in Logs Drilldown to split queries into multiple queries based on the number of shards", Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, @@ -1092,6 +1098,13 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, }, + { + Name: "newTimeRangeZoomShortcuts", + Description: "Enables new keyboard shortcuts for time range zoom operations", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, { Name: "azureMonitorDisableLogLimit", Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.", @@ -1505,12 +1518,6 @@ var ( FrontendOnly: true, Expression: "true", }, - { - Name: "postgresDSUsePGX", - Description: "Enables using PGX instead of libpq for PostgreSQL datasource", - Stage: FeatureStageExperimental, - Owner: grafanaOSSBigTent, - }, { Name: "tempoAlerting", Description: "Enables creating alerts from Tempo data source", @@ -1904,13 +1911,6 @@ var ( RequiresRestart: false, HideFromDocs: false, }, - { - Name: "dashboardTemplates", - Description: "Enable template dashboards", - Stage: FeatureStageExperimental, - Owner: grafanaSharingSquad, - FrontendOnly: false, - }, { Name: "kubernetesAnnotations", Description: "Enables app platform API for annotations", @@ -1939,6 +1939,13 @@ var ( FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, }, + { + Name: "lokiQueryLimitsContext", + Description: "Send X-Loki-Query-Limits-Context header to Loki on first split request", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaObservabilityLogsSquad, + }, { Name: "rudderstackUpgrade", Description: "Enables the new version of rudderstack", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 24f9769ea4f..fd1fe6619f9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -73,6 +73,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false +alertingUIUseFullyCompatBackendFilters,experimental,@grafana/alerting-squad,false,false,false alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true @@ -81,17 +82,16 @@ dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true +perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true timeComparison,experimental,@grafana/dataviz-squad,false,false,true -logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false -onPremToCloudMigrations,GA,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false @@ -115,6 +115,7 @@ grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,fa queryLibrary,preview,@grafana/sharing-squad,false,false,false dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false +dashboardTemplates,preview,@grafana/sharing-squad,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false @@ -151,6 +152,7 @@ pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false timeRangePan,experimental,@grafana/dataviz-squad,false,false,true +newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false @@ -206,7 +208,6 @@ unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true grafanaAssistantInProfilesDrilldown,GA,@grafana/observability-traces-and-profiling,false,false,true -postgresDSUsePGX,experimental,@grafana/oss-big-tent,false,false,false tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,false pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true @@ -258,9 +259,9 @@ pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,f newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false -dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true +lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index fdc48861894..f68009ab2e3 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -243,6 +243,10 @@ const ( // Enables the UI to use certain backend-side filters FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters" + // FlagAlertingUIUseFullyCompatBackendFilters + // Enables the UI to use rules backend-side filters 100% compatible with the frontend filters + FlagAlertingUIUseFullyCompatBackendFilters = "alertingUIUseFullyCompatBackendFilters" + // FlagAlertmanagerRemotePrimary // Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. FlagAlertmanagerRemotePrimary = "alertmanagerRemotePrimary" @@ -271,10 +275,6 @@ const ( // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled. FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" - // FlagOnPremToCloudMigrations - // Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. - FlagOnPremToCloudMigrations = "onPremToCloudMigrations" - // FlagSecretsManagementAppPlatform // Enable the secrets management API and services under app platform FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform" @@ -348,13 +348,17 @@ const ( FlagQueryLibrary = "queryLibrary" // FlagDashboardLibrary - // Enable dashboard library experiments that are production ready + // Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page FlagDashboardLibrary = "dashboardLibrary" // FlagSuggestedDashboards - // Enable suggested dashboards when creating new dashboards + // Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page FlagSuggestedDashboards = "suggestedDashboards" + // FlagDashboardTemplates + // Enables a flow to get started with a new dashboard from a template + FlagDashboardTemplates = "dashboardTemplates" + // FlagAlertingDisableSendAlertsExternal // Disables the ability to send alerts to an external Alertmanager datasource. FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" @@ -602,10 +606,6 @@ const ( // use multi-tenant path for awsTempCredentials FlagMultiTenantTempCredentials = "multiTenantTempCredentials" - // FlagPostgresDSUsePGX - // Enables using PGX instead of libpq for PostgreSQL datasource - FlagPostgresDSUsePGX = "postgresDSUsePGX" - // FlagTempoAlerting // Enables creating alerts from Tempo data source FlagTempoAlerting = "tempoAlerting" @@ -750,10 +750,6 @@ const ( // Enables a new panel time settings drawer FlagPanelTimeSettings = "panelTimeSettings" - // FlagDashboardTemplates - // Enable template dashboards - FlagDashboardTemplates = "dashboardTemplates" - // FlagKubernetesAnnotations // Enables app platform API for annotations FlagKubernetesAnnotations = "kubernetesAnnotations" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 48eba662c0a..0e7dba8b5ec 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3,46 +3,10 @@ "apiVersion": "featuretoggle.grafana.app/v0alpha1", "metadata": {}, "items": [ - { - "metadata": { - "name": "addFieldFromCalculationStatFunctions", - "resourceVersion": "1762442825881", - "creationTimestamp": "2023-11-03T14:39:58Z", - "deletionTimestamp": "2025-11-12T14:07:50Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" - } - }, - "spec": { - "description": "Add cumulative and window functions to the add field from calculation transformation", - "stage": "GA", - "codeowner": "@grafana/datapro", - "frontend": true, - "expression": "true" - } - }, - { - "metadata": { - "name": "adhocFiltersInTooltips", - "resourceVersion": "1756814786992", - "creationTimestamp": "2025-07-29T17:53:43Z", - "deletionTimestamp": "2025-11-11T09:49:48Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-02 12:06:26.992384 +0000 UTC" - } - }, - "spec": { - "description": "Enable adhoc filter buttons in visualization tooltips", - "stage": "GA", - "codeowner": "@grafana/datapro", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "aiGeneratedDashboardChanges", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-03-05T12:01:31Z" }, "spec": { @@ -55,7 +19,7 @@ { "metadata": { "name": "alertEnrichment", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-06T12:16:07Z" }, "spec": { @@ -69,8 +33,9 @@ { "metadata": { "name": "alertEnrichmentConditional", - "resourceVersion": "1757418974334", - "creationTimestamp": "2025-09-09T11:56:14Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enable conditional alert enrichment steps.", @@ -83,8 +48,9 @@ { "metadata": { "name": "alertEnrichmentMultiStep", - "resourceVersion": "1757418224384", - "creationTimestamp": "2025-09-09T11:43:44Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Allow multiple steps per enrichment.", @@ -97,7 +63,7 @@ { "metadata": { "name": "alertRuleRestore", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-05T14:15:26Z" }, "spec": { @@ -110,7 +76,7 @@ { "metadata": { "name": "alertRuleUseFiredAtForStartsAt", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-22T11:16:38Z" }, "spec": { @@ -123,7 +89,7 @@ { "metadata": { "name": "alertingAIAnalyzeCentralStateHistory", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-16T16:42:42Z" }, "spec": { @@ -137,7 +103,7 @@ { "metadata": { "name": "alertingAIFeedback", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-23T12:38:09Z" }, "spec": { @@ -151,7 +117,7 @@ { "metadata": { "name": "alertingAIGenAlertRules", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-16T16:42:42Z" }, "spec": { @@ -165,7 +131,7 @@ { "metadata": { "name": "alertingAIGenTemplates", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-16T16:42:42Z" }, "spec": { @@ -179,7 +145,7 @@ { "metadata": { "name": "alertingAIImproveAlertRules", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-16T16:42:42Z" }, "spec": { @@ -193,7 +159,7 @@ { "metadata": { "name": "alertingBacktesting", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-12-14T14:44:14Z" }, "spec": { @@ -205,7 +171,7 @@ { "metadata": { "name": "alertingBulkActionsInUI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-24T14:49:59Z" }, "spec": { @@ -220,11 +186,8 @@ { "metadata": { "name": "alertingCentralAlertHistory", - "resourceVersion": "1763541314825", - "creationTimestamp": "2024-05-29T15:01:38Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 08:35:14.825756 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-05-29T15:01:38Z" }, "spec": { "description": "Enables the new central alert history.", @@ -235,7 +198,7 @@ { "metadata": { "name": "alertingDisableSendAlertsExternal", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-05-23T12:29:19Z" }, "spec": { @@ -248,8 +211,9 @@ { "metadata": { "name": "alertingEnrichmentAssistantInvestigations", - "resourceVersion": "1757606567075", - "creationTimestamp": "2025-09-11T16:02:47Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Enable Assistant Investigations enrichment type.", @@ -259,26 +223,12 @@ "expression": "false" } }, - { - "metadata": { - "name": "alertingEnrichmentAssistantInvestigationsUI", - "resourceVersion": "1757541861834", - "creationTimestamp": "2025-09-10T22:04:21Z", - "deletionTimestamp": "2025-09-11T16:02:47Z" - }, - "spec": { - "description": "Enable Assistant Investigations enrichment type in the UI.", - "stage": "experimental", - "codeowner": "@grafana/alerting-squad", - "hideFromDocs": true, - "expression": "false" - } - }, { "metadata": { "name": "alertingEnrichmentPerRule", - "resourceVersion": "1756206837948", - "creationTimestamp": "2025-08-28T08:30:28Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enable enrichment per rule in the alerting UI.", @@ -291,7 +241,7 @@ { "metadata": { "name": "alertingFilterV2", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-11T11:29:26Z" }, "spec": { @@ -304,7 +254,7 @@ { "metadata": { "name": "alertingImportAlertmanagerAPI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-10T08:32:50Z" }, "spec": { @@ -318,8 +268,9 @@ { "metadata": { "name": "alertingImportAlertmanagerUI", - "resourceVersion": "1754585847887", - "creationTimestamp": "2025-08-13T15:28:43Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables the UI to see imported Alertmanager configuration", @@ -332,7 +283,7 @@ { "metadata": { "name": "alertingImportYAMLUI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-05-21T15:59:41Z" }, "spec": { @@ -346,7 +297,7 @@ { "metadata": { "name": "alertingJiraIntegration", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-02-14T12:22:04Z" }, "spec": { @@ -360,7 +311,7 @@ { "metadata": { "name": "alertingListViewV2", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-05-24T14:40:49Z" }, "spec": { @@ -373,7 +324,7 @@ { "metadata": { "name": "alertingListViewV2PreviewToggle", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-22T08:50:34Z" }, "spec": { @@ -386,7 +337,7 @@ { "metadata": { "name": "alertingMigrationUI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-14T16:40:05Z" }, "spec": { @@ -400,7 +351,7 @@ { "metadata": { "name": "alertingNotificationHistory", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-17T13:26:26Z" }, "spec": { @@ -414,7 +365,7 @@ { "metadata": { "name": "alertingNotificationsStepMode", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-22T11:07:45Z" }, "spec": { @@ -428,7 +379,7 @@ { "metadata": { "name": "alertingPrometheusRulesPrimary", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-27T12:27:16Z" }, "spec": { @@ -441,7 +392,7 @@ { "metadata": { "name": "alertingProvenanceLockWrites", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-23T18:16:06Z" }, "spec": { @@ -454,7 +405,7 @@ { "metadata": { "name": "alertingQueryAndExpressionsStepMode", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-26T06:33:14Z" }, "spec": { @@ -468,7 +419,7 @@ { "metadata": { "name": "alertingQueryOptimization", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-01-10T20:52:58Z" }, "spec": { @@ -478,25 +429,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "alertingRuleNotificationMessageSectionExtension", - "resourceVersion": "1756193222535", - "creationTimestamp": "2025-08-26T07:27:02Z", - "deletionTimestamp": "2025-08-26T11:13:57Z" - }, - "spec": { - "description": "Enable rule notification message section extension.", - "stage": "experimental", - "codeowner": "@grafana/alerting-squad", - "hideFromDocs": true, - "expression": "false" - } - }, { "metadata": { "name": "alertingRulePermanentlyDelete", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-03T11:18:25Z" }, "spec": { @@ -511,11 +447,8 @@ { "metadata": { "name": "alertingRuleRecoverDeleted", - "resourceVersion": "1763541314825", - "creationTimestamp": "2025-03-27T14:39:26Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 08:35:14.825756 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-03-27T14:39:26Z" }, "spec": { "description": "Enables the UI functionality to recover and view deleted alert rules", @@ -528,7 +461,7 @@ { "metadata": { "name": "alertingRuleVersionHistoryRestore", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-02-17T12:25:32Z" }, "spec": { @@ -543,11 +476,8 @@ { "metadata": { "name": "alertingSaveStateCompressed", - "resourceVersion": "1759485036332", - "creationTimestamp": "2025-01-27T17:47:33Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-03 09:50:36.332762 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-01-27T17:47:33Z" }, "spec": { "description": "Enables the compressed protobuf-based alert state storage. Default is enabled.", @@ -559,7 +489,7 @@ { "metadata": { "name": "alertingSaveStatePeriodic", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-01-23T16:03:30Z" }, "spec": { @@ -571,11 +501,9 @@ { "metadata": { "name": "alertingTriage", - "resourceVersion": "1763541314825", - "creationTimestamp": "2025-09-01T09:33:33Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 08:35:14.825756 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables the alerting triage feature", @@ -588,7 +516,7 @@ { "metadata": { "name": "alertingUIOptimizeReducer", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-18T10:59:00Z" }, "spec": { @@ -602,8 +530,8 @@ { "metadata": { "name": "alertingUIUseBackendFilters", - "resourceVersion": "1762966218072", - "creationTimestamp": "2025-11-12T16:50:18Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-13T14:52:14Z" }, "spec": { "description": "Enables the UI to use certain backend-side filters", @@ -612,15 +540,41 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "alertingUIUseCompatibleBackendFilters", + "resourceVersion": "1763478911684", + "creationTimestamp": "2025-11-18T15:15:11Z", + "deletionTimestamp": "2025-11-18T15:17:12Z" + }, + "spec": { + "description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "alertingUIUseFullyCompatBackendFilters", + "resourceVersion": "1763479032988", + "creationTimestamp": "2025-11-18T15:17:12Z" + }, + "spec": { + "description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingUseNewSimplifiedRoutingHashAlgorithm", - "resourceVersion": "1759339813575", - "creationTimestamp": "2025-10-01T17:28:42Z", - "deletionTimestamp": "2025-10-01T17:29:29Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-01 17:30:13.575464 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "", @@ -631,29 +585,10 @@ "expression": "true" } }, - { - "metadata": { - "name": "alertingUseOldSimplifiedRoutingHashAlgorithm", - "resourceVersion": "1759339782639", - "creationTimestamp": "2025-10-01T17:29:29Z", - "deletionTimestamp": "2025-10-01T17:30:13Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-01 17:29:42.63941 +0000 UTC" - } - }, - "spec": { - "description": "", - "stage": "deprecated", - "codeowner": "@grafana/alerting-squad", - "requiresRestart": true, - "hideFromDocs": true, - "expression": "false" - } - }, { "metadata": { "name": "alertmanagerRemotePrimary", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-30T16:27:08Z" }, "spec": { @@ -665,7 +600,7 @@ { "metadata": { "name": "alertmanagerRemoteSecondary", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-30T16:27:08Z" }, "spec": { @@ -677,11 +612,8 @@ { "metadata": { "name": "alertmanagerRemoteSecondaryWithRemoteState", - "resourceVersion": "1753776005753", - "creationTimestamp": "2025-07-25T15:06:59Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-07-29 08:00:05.753498 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-25T15:06:59Z" }, "spec": { "description": "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", @@ -693,7 +625,7 @@ { "metadata": { "name": "annotationPermissionUpdate", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-31T13:30:13Z" }, "spec": { @@ -706,7 +638,7 @@ { "metadata": { "name": "appPlatformGrpcClientAuth", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-14T10:47:18Z" }, "spec": { @@ -719,7 +651,7 @@ { "metadata": { "name": "assetSriChecks", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-04T10:56:35Z" }, "spec": { @@ -732,7 +664,7 @@ { "metadata": { "name": "authZGRPCServer", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-13T09:41:35Z" }, "spec": { @@ -745,7 +677,7 @@ { "metadata": { "name": "awsAsyncQueryCaching", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-07-21T15:34:07Z" }, "spec": { @@ -758,11 +690,8 @@ { "metadata": { "name": "awsDatasourcesHttpProxy", - "resourceVersion": "1762964349996", - "creationTimestamp": "2025-11-12T16:15:47Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 16:19:09.996919 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-12T18:51:23Z" }, "spec": { "description": "Enables http proxy settings for aws datasources", @@ -774,7 +703,7 @@ { "metadata": { "name": "awsDatasourcesTempCredentials", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-07-06T15:06:11Z" }, "spec": { @@ -787,7 +716,7 @@ { "metadata": { "name": "azureMonitorDisableLogLimit", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-24T13:32:09Z" }, "spec": { @@ -800,7 +729,7 @@ { "metadata": { "name": "azureMonitorEnableUserAuth", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-27T14:01:54Z" }, "spec": { @@ -813,7 +742,7 @@ { "metadata": { "name": "azureMonitorLogsBuilderEditor", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-02T14:15:25Z" }, "spec": { @@ -826,7 +755,7 @@ { "metadata": { "name": "azureMonitorPrometheusExemplars", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-06T16:53:17Z" }, "spec": { @@ -839,8 +768,9 @@ { "metadata": { "name": "azureResourcePickerUpdates", - "resourceVersion": "1754910058337", - "creationTimestamp": "2025-09-02T10:02:01Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables the updated Azure Monitor resource picker", @@ -853,7 +783,7 @@ { "metadata": { "name": "cachingOptimizeSerializationMemoryUsage", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-12T16:56:49Z" }, "spec": { @@ -865,9 +795,8 @@ { "metadata": { "name": "canvasPanelNesting", - "resourceVersion": "1753448760331", - "creationTimestamp": "2022-05-31T19:03:34Z", - "deletionTimestamp": "2025-11-06T14:43:27Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2022-05-31T19:03:34Z" }, "spec": { "description": "Allow elements nesting", @@ -879,7 +808,7 @@ { "metadata": { "name": "canvasPanelPanZoom", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-01-02T19:52:21Z" }, "spec": { @@ -892,22 +821,9 @@ { "metadata": { "name": "cdnPluginsLoadFirst", - "resourceVersion": "1758882341746", - "creationTimestamp": "2025-09-26T10:25:41Z" - }, - "spec": { - "description": "Prioritize loading plugins from the CDN before other sources", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend", - "expression": "false" - } - }, - { - "metadata": { - "name": "cdnPluginsLoadedFirst", - "resourceVersion": "1758881920003", - "creationTimestamp": "2025-09-26T10:18:40Z", - "deletionTimestamp": "2025-09-26T10:25:41Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Prioritize loading plugins from the CDN before other sources", @@ -919,8 +835,9 @@ { "metadata": { "name": "cdnPluginsUrls", - "resourceVersion": "1759489886228", - "creationTimestamp": "2025-10-03T11:11:26Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Enable loading plugins via declarative URLs", @@ -932,7 +849,7 @@ { "metadata": { "name": "cloudRBACRoles", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-01-10T13:19:01Z" }, "spec": { @@ -946,7 +863,7 @@ { "metadata": { "name": "cloudWatchBatchQueries", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-20T19:09:41Z" }, "spec": { @@ -958,7 +875,7 @@ { "metadata": { "name": "cloudWatchCrossAccountQuerying", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-11-28T11:39:12Z" }, "spec": { @@ -971,7 +888,7 @@ { "metadata": { "name": "cloudWatchNewLabelParsing", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-05T15:57:56Z" }, "spec": { @@ -984,7 +901,7 @@ { "metadata": { "name": "cloudWatchRoundUpEndTime", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-27T15:10:28Z" }, "spec": { @@ -997,7 +914,7 @@ { "metadata": { "name": "configurableSchedulerTick", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-07-26T16:44:12Z" }, "spec": { @@ -1008,27 +925,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "correlations", - "resourceVersion": "1762442825881", - "creationTimestamp": "2022-09-16T13:14:27Z", - "deletionTimestamp": "2025-11-12T13:11:31Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" - } - }, - "spec": { - "description": "Correlations page", - "stage": "GA", - "codeowner": "@grafana/datapro", - "expression": "true" - } - }, { "metadata": { "name": "crashDetection", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-12T15:07:27Z" }, "spec": { @@ -1041,7 +941,7 @@ { "metadata": { "name": "dashboardDisableSchemaValidationV1", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-11T16:52:46Z" }, "spec": { @@ -1053,7 +953,7 @@ { "metadata": { "name": "dashboardDisableSchemaValidationV2", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-11T16:52:46Z" }, "spec": { @@ -1062,28 +962,10 @@ "codeowner": "@grafana/grafana-app-platform-squad" } }, - { - "metadata": { - "name": "dashboardDsAdHocFiltering", - "resourceVersion": "1756814786992", - "creationTimestamp": "2025-07-23T08:12:25Z", - "deletionTimestamp": "2025-09-27T19:59:33Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-02 12:06:26.992384 +0000 UTC" - } - }, - "spec": { - "description": "Enables adhoc filtering support for the dashboard datasource", - "stage": "GA", - "codeowner": "@grafana/datapro", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "dashboardLevelTimeMacros", - "resourceVersion": "1753435849295", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-31T09:49:07Z" }, "spec": { @@ -1096,14 +978,12 @@ { "metadata": { "name": "dashboardLibrary", - "resourceVersion": "1762521182817", - "creationTimestamp": "2025-09-26T16:02:12Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-07 13:13:02.817210943 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { - "description": "Enable dashboard library experiments that are production ready", + "description": "Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page", "stage": "experimental", "codeowner": "@grafana/sharing-squad" } @@ -1111,11 +991,8 @@ { "metadata": { "name": "dashboardNewLayouts", - "resourceVersion": "1763533458962", - "creationTimestamp": "2024-10-23T08:55:45Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 06:24:18.962973 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-10-23T08:55:45Z" }, "spec": { "description": "Enables experimental new dashboard layouts", @@ -1126,7 +1003,7 @@ { "metadata": { "name": "dashboardScene", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-11-13T08:51:21Z" }, "spec": { @@ -1140,7 +1017,7 @@ { "metadata": { "name": "dashboardSceneForViewers", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-11-02T19:02:25Z" }, "spec": { @@ -1154,7 +1031,7 @@ { "metadata": { "name": "dashboardSceneSolo", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-02-11T08:08:47Z" }, "spec": { @@ -1168,7 +1045,7 @@ { "metadata": { "name": "dashboardSchemaValidationLogging", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-11T16:52:46Z" }, "spec": { @@ -1180,20 +1057,21 @@ { "metadata": { "name": "dashboardTemplates", - "resourceVersion": "1761575312733", - "creationTimestamp": "2025-10-27T14:28:32Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-28T20:05:32Z" }, "spec": { - "description": "Enable template dashboards", - "stage": "experimental", + "description": "Enables a flow to get started with a new dashboard from a template", + "stage": "preview", "codeowner": "@grafana/sharing-squad" } }, { "metadata": { "name": "dashboardUndoRedo", - "resourceVersion": "1757940426210", - "creationTimestamp": "2025-09-15T12:47:06Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Enables undo/redo in dynamic dashboards", @@ -1205,7 +1083,7 @@ { "metadata": { "name": "dashgpt", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-08-30T20:22:05Z" }, "spec": { @@ -1219,7 +1097,7 @@ { "metadata": { "name": "dataplaneAggregator", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-08-09T08:41:07Z" }, "spec": { @@ -1229,25 +1107,10 @@ "requiresRestart": true } }, - { - "metadata": { - "name": "dataplaneFrontendFallback", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-04-07T21:13:19Z", - "deletionTimestamp": "2025-11-06T17:39:31Z" - }, - "spec": { - "description": "Support dataplane contract field name change for transformations and field name matchers where the name is different", - "stage": "GA", - "codeowner": "@grafana/observability-metrics", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "datasourceAPIServers", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-19T08:28:27Z" }, "spec": { @@ -1260,7 +1123,7 @@ { "metadata": { "name": "datasourceConnectionsTab", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-01-21T17:39:48Z" }, "spec": { @@ -1273,7 +1136,7 @@ { "metadata": { "name": "datasourceQueryTypes", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-05-23T16:46:28Z" }, "spec": { @@ -1286,7 +1149,7 @@ { "metadata": { "name": "disableClassicHTTPHistogram", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-18T19:37:44Z" }, "spec": { @@ -1299,7 +1162,7 @@ { "metadata": { "name": "disableEnvelopeEncryption", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-05-24T08:34:47Z" }, "spec": { @@ -1312,7 +1175,7 @@ { "metadata": { "name": "disableNumericMetricsSortingInExpressions", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-16T14:52:47Z" }, "spec": { @@ -1325,11 +1188,8 @@ { "metadata": { "name": "disableSSEDataplane", - "resourceVersion": "1762552416963", - "creationTimestamp": "2023-04-12T16:24:34Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-07 21:53:36.963146843 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2023-04-12T16:24:34Z" }, "spec": { "description": "Disables dataplane specific processing in server side expressions.", @@ -1337,50 +1197,11 @@ "codeowner": "@grafana/grafana-datasources-core-services" } }, - { - "metadata": { - "name": "dskitBackgroundServices", - "resourceVersion": "1757339637779", - "creationTimestamp": "2025-09-03T12:20:24Z", - "deletionTimestamp": "2025-09-17T12:19:32Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-08 13:53:57.77994 +0000 UTC" - } - }, - "spec": { - "description": "Enables dskit background service wrapper", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend", - "requiresRestart": true, - "hideFromDocs": true, - "expression": "false" - } - }, - { - "metadata": { - "name": "editPanelCSVDragAndDrop", - "resourceVersion": "1762783224740", - "creationTimestamp": "2023-01-24T09:43:44Z", - "deletionTimestamp": "2025-11-10T16:31:10Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-10 14:00:24.740459 +0000 UTC" - } - }, - "spec": { - "description": "Enables drag and drop for CSV and Excel files", - "stage": "experimental", - "codeowner": "@grafana/dataviz-squad", - "frontend": true - } - }, { "metadata": { "name": "elasticsearchCrossClusterSearch", - "resourceVersion": "1757441088767", - "creationTimestamp": "2024-12-12T22:20:04Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-09 18:04:48.76781 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-12-12T22:20:04Z" }, "spec": { "description": "Enables cross cluster search in the Elasticsearch data source", @@ -1392,7 +1213,7 @@ { "metadata": { "name": "elasticsearchImprovedParsing", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-01-15T17:05:54Z" }, "spec": { @@ -1404,7 +1225,7 @@ { "metadata": { "name": "enableAppChromeExtensions", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-30T04:32:08Z" }, "spec": { @@ -1419,8 +1240,8 @@ { "metadata": { "name": "enableDashboardEmptyExtensions", - "resourceVersion": "1759194774156", - "creationTimestamp": "2025-09-30T01:12:54Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-13T07:03:13Z" }, "spec": { "description": "Set this to true to enable all dashboard empty state extensions registered by plugins.", @@ -1434,11 +1255,8 @@ { "metadata": { "name": "enableDatagridEditing", - "resourceVersion": "1763539789149", - "creationTimestamp": "2023-04-24T14:46:31Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 08:09:49.149669 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2023-04-24T14:46:31Z" }, "spec": { "description": "Enables the edit functionality in the datagrid panel", @@ -1449,7 +1267,7 @@ { "metadata": { "name": "enableExtensionsAdminPage", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-05T15:55:10Z" }, "spec": { @@ -1462,7 +1280,7 @@ { "metadata": { "name": "enableNativeHTTPHistogram", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-03T18:23:55Z" }, "spec": { @@ -1472,26 +1290,10 @@ "requiresRestart": true } }, - { - "metadata": { - "name": "enablePluginImporter", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-07-16T04:42:28Z", - "deletionTimestamp": "2025-10-20T05:42:33Z" - }, - "spec": { - "description": "Set this to true to use the new PluginImporter functionality", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend", - "frontend": true, - "hideFromDocs": true, - "expression": "false" - } - }, { "metadata": { "name": "enableSCIM", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-07T14:38:46Z" }, "spec": { @@ -1503,7 +1305,7 @@ { "metadata": { "name": "enableScopesInMetricsExplore", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-06T13:11:33Z" }, "spec": { @@ -1516,7 +1318,7 @@ { "metadata": { "name": "exploreLogsAggregatedMetrics", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-08-29T13:55:59Z" }, "spec": { @@ -1529,7 +1331,7 @@ { "metadata": { "name": "exploreLogsLimitedTimeRange", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-08-29T13:55:59Z" }, "spec": { @@ -1542,11 +1344,11 @@ { "metadata": { "name": "exploreLogsShardSplitting", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-08-29T13:55:59Z" }, "spec": { - "description": "Used in Logs Drilldown to split queries into multiple queries based on the number of shards", + "description": "Deprecated. Replace with lokiShardSplitting. Used in Logs Drilldown to split queries into multiple queries based on the number of shards", "stage": "experimental", "codeowner": "@grafana/observability-logs", "frontend": true @@ -1555,7 +1357,7 @@ { "metadata": { "name": "exploreMetricsRelatedLogs", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-05T16:28:43Z" }, "spec": { @@ -1565,38 +1367,10 @@ "frontend": true } }, - { - "metadata": { - "name": "expressionParser", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-02-17T00:59:11Z", - "deletionTimestamp": "2025-08-26T13:21:24Z" - }, - "spec": { - "description": "Enable new expression parser", - "stage": "experimental", - "codeowner": "@grafana/grafana-app-platform-squad", - "requiresRestart": true - } - }, - { - "metadata": { - "name": "extensionSidebar", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-04-03T10:16:35Z", - "deletionTimestamp": "2025-09-01T10:14:17Z" - }, - "spec": { - "description": "Enables the extension sidebar", - "stage": "experimental", - "codeowner": "@grafana/observability-logs", - "frontend": true - } - }, { "metadata": { "name": "externalServiceAccounts", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-09-28T07:26:37Z" }, "spec": { @@ -1608,7 +1382,7 @@ { "metadata": { "name": "extraThemes", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-05-10T13:37:04Z", "deletionTimestamp": "2025-05-20T08:18:08Z" }, @@ -1619,27 +1393,10 @@ "frontend": true } }, - { - "metadata": { - "name": "extractFieldsNameDeduplication", - "resourceVersion": "1762442825881", - "creationTimestamp": "2023-11-02T15:47:42Z", - "deletionTimestamp": "2025-11-11T16:00:57Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" - } - }, - "spec": { - "description": "Make sure extracted field names are unique in the dataframe", - "stage": "experimental", - "codeowner": "@grafana/datapro", - "frontend": true - } - }, { "metadata": { "name": "faroDatasourceSelector", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-05-05T00:35:10Z" }, "spec": { @@ -1652,8 +1409,9 @@ { "metadata": { "name": "favoriteDatasources", - "resourceVersion": "1754648387873", - "creationTimestamp": "2025-08-08T13:28:17Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enable favorite datasources", @@ -1665,7 +1423,7 @@ { "metadata": { "name": "featureHighlights", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-02-03T11:53:23Z" }, "spec": { @@ -1675,42 +1433,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "featureToggleAdminPage", - "resourceVersion": "1758022099771", - "creationTimestamp": "2023-07-18T20:43:32Z", - "deletionTimestamp": "2025-09-29T13:36:16Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-16 11:28:19.771156 +0000 UTC" - } - }, - "spec": { - "description": "Enable admin page for managing feature toggles from the Grafana front-end. Grafana Cloud only.", - "stage": "experimental", - "codeowner": "@grafana/grafana-backend-services-squad", - "requiresRestart": true, - "hideFromDocs": true - } - }, - { - "metadata": { - "name": "feedbackButton", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-12-02T17:08:15Z", - "deletionTimestamp": "2025-11-20T09:53:52Z" - }, - "spec": { - "description": "Enables a button to send feedback from the Grafana UI", - "stage": "experimental", - "codeowner": "@grafana/grafana-operator-experience-squad", - "hideFromDocs": true - } - }, { "metadata": { "name": "fetchRulesUsingPost", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-01-29T12:17:44Z" }, "spec": { @@ -1720,25 +1446,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "filterOutBotsFromFrontendLogs", - "resourceVersion": "1758000919535", - "creationTimestamp": "2025-09-16T05:35:19Z", - "deletionTimestamp": "2025-10-13T11:09:22Z" - }, - "spec": { - "description": "Filter out bots from collecting data for Frontend Observability", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend", - "frontend": true, - "expression": "false" - } - }, { "metadata": { "name": "foldersAppPlatformAPI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-03T14:15:23Z" }, "spec": { @@ -1750,28 +1461,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "formatString", - "resourceVersion": "1762442825881", - "creationTimestamp": "2023-10-13T18:17:12Z", - "deletionTimestamp": "2025-11-12T00:00:00Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" - } - }, - "spec": { - "description": "Enable format string transformer", - "stage": "GA", - "codeowner": "@grafana/datapro", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "grafanaAPIServerEnsureKubectlAccess", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-12-06T20:21:21Z" }, "spec": { @@ -1785,7 +1478,7 @@ { "metadata": { "name": "grafanaAPIServerWithExperimentalAPIs", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-10-06T18:55:22Z" }, "spec": { @@ -1799,7 +1492,7 @@ { "metadata": { "name": "grafanaAdvisor", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-01-20T10:08:00Z" }, "spec": { @@ -1811,11 +1504,9 @@ { "metadata": { "name": "grafanaAssistantInProfilesDrilldown", - "resourceVersion": "1754572610001", - "creationTimestamp": "2025-08-19T07:54:00Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-08-07 13:16:50.001205 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables integration with Grafana Assistant in Profiles Drilldown", @@ -1825,24 +1516,10 @@ "expression": "true" } }, - { - "metadata": { - "name": "grafanaAssistantInProfilesDrillfown", - "resourceVersion": "1754034112469", - "creationTimestamp": "2025-08-01T07:41:52Z", - "deletionTimestamp": "2025-08-01T07:43:17Z" - }, - "spec": { - "description": "Enables interation with Grafana Assitant in Profiles Drilldown", - "stage": "experimental", - "codeowner": "@grafana/observability-traces-and-profiling", - "frontend": true - } - }, { "metadata": { "name": "grafanaManagedRecordingRules", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-22T17:53:16Z", "deletionTimestamp": "2025-05-19T10:15:49Z" }, @@ -1853,26 +1530,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "grafanaPathfinder", - "resourceVersion": "1760434668782", - "creationTimestamp": "2025-08-20T12:30:29Z", - "deletionTimestamp": "2025-10-21T16:10:16Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-14 09:37:48.782577 +0000 UTC" - } - }, - "spec": { - "description": "Enables Pathfinder app", - "stage": "preview", - "codeowner": "@grafana/pathfinder" - } - }, { "metadata": { "name": "grafanaconThemes", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-02-06T11:08:04Z" }, "spec": { @@ -1887,8 +1548,9 @@ { "metadata": { "name": "graphiteBackendMode", - "resourceVersion": "1755870507537", - "creationTimestamp": "2025-09-01T15:13:47Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables the Graphite data source full backend mode", @@ -1900,7 +1562,7 @@ { "metadata": { "name": "groupAttributeSync", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-09T15:29:43Z" }, "spec": { @@ -1913,7 +1575,7 @@ { "metadata": { "name": "groupByVariable", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-02-14T17:18:04Z" }, "spec": { @@ -1923,28 +1585,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "groupToNestedTableTransformation", - "resourceVersion": "1762442825881", - "creationTimestamp": "2024-02-07T14:28:26Z", - "deletionTimestamp": "2025-11-12T14:33:44Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" - } - }, - "spec": { - "description": "Enables the group to nested table transformation", - "stage": "GA", - "codeowner": "@grafana/datapro", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "grpcServer", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-09-26T20:25:34Z" }, "spec": { @@ -1956,7 +1600,7 @@ { "metadata": { "name": "improvedExternalSessionHandling", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-17T10:54:39Z" }, "spec": { @@ -1969,7 +1613,7 @@ { "metadata": { "name": "improvedExternalSessionHandlingSAML", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-01-09T17:02:49Z" }, "spec": { @@ -1982,7 +1626,7 @@ { "metadata": { "name": "individualCookiePreferences", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-02-21T10:19:07Z" }, "spec": { @@ -1994,7 +1638,7 @@ { "metadata": { "name": "infinityRunQueriesInParallel", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-14T12:54:04Z" }, "spec": { @@ -2006,7 +1650,7 @@ { "metadata": { "name": "influxdbBackendMigration", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-02-09T18:26:16Z", "deletionTimestamp": "2023-01-17T14:11:26Z" }, @@ -2021,7 +1665,7 @@ { "metadata": { "name": "influxdbRunQueriesInParallel", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-02-01T10:58:24Z" }, "spec": { @@ -2033,7 +1677,7 @@ { "metadata": { "name": "influxqlStreamingParser", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-11-29T17:29:35Z" }, "spec": { @@ -2045,8 +1689,8 @@ { "metadata": { "name": "interactiveLearning", - "resourceVersion": "1761063016739", - "creationTimestamp": "2025-10-21T16:10:16Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-22T08:06:21Z" }, "spec": { "description": "Enables the interactive learning app", @@ -2057,7 +1701,7 @@ { "metadata": { "name": "investigationsBackend", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-18T08:31:03Z" }, "spec": { @@ -2067,26 +1711,11 @@ "expression": "false" } }, - { - "metadata": { - "name": "inviteUserExperimental", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-07T19:09:59Z", - "deletionTimestamp": "2025-11-14T10:29:28Z" - }, - "spec": { - "description": "Renders invite user button along the app", - "stage": "experimental", - "codeowner": "@grafana/sharing-squad", - "frontend": true, - "hideFromDocs": true - } - }, { "metadata": { "name": "jaegerEnableGrpcEndpoint", - "resourceVersion": "1760451551713", - "creationTimestamp": "2025-10-14T14:19:11Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-31T18:19:16Z" }, "spec": { "description": "Enable querying trace data through Jaeger's gRPC endpoint (HTTP)", @@ -2097,7 +1726,7 @@ { "metadata": { "name": "jitterAlertRulesWithinGroups", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-01-18T18:48:11Z" }, "spec": { @@ -2111,7 +1740,7 @@ { "metadata": { "name": "k8SFolderCounts", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-27T17:10:44Z" }, "spec": { @@ -2124,7 +1753,7 @@ { "metadata": { "name": "k8SFolderMove", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-27T17:10:44Z" }, "spec": { @@ -2137,7 +1766,7 @@ { "metadata": { "name": "kubernetesAggregator", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-02-12T20:59:35Z" }, "spec": { @@ -2150,7 +1779,7 @@ { "metadata": { "name": "kubernetesAggregatorCapTokenAuth", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-05-15T18:14:23Z" }, "spec": { @@ -2163,8 +1792,9 @@ { "metadata": { "name": "kubernetesAlertingRules", - "resourceVersion": "1754340669702", - "creationTimestamp": "2025-08-04T20:51:09Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Adds support for Kubernetes alerting and recording rules", @@ -2176,8 +1806,8 @@ { "metadata": { "name": "kubernetesAnnotations", - "resourceVersion": "1761142826172", - "creationTimestamp": "2025-10-22T14:20:26Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-06T18:22:20Z" }, "spec": { "description": "Enables app platform API for annotations", @@ -2189,8 +1819,9 @@ { "metadata": { "name": "kubernetesAuthZHandlerRedirect", - "resourceVersion": "1758820248165", - "creationTimestamp": "2025-09-25T17:10:48Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints", @@ -2202,11 +1833,8 @@ { "metadata": { "name": "kubernetesAuthnMutation", - "resourceVersion": "1753454405614", - "creationTimestamp": "2025-07-25T15:05:32Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-07-25 14:40:05.614358 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-25T15:05:32Z" }, "spec": { "description": "Enables create, delete, and update mutations for resources owned by IAM identity", @@ -2218,7 +1846,7 @@ { "metadata": { "name": "kubernetesAuthzApis", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-18T07:43:01Z" }, "spec": { @@ -2228,25 +1856,12 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "kubernetesAuthzEndpoints", - "resourceVersion": "1758779666607", - "creationTimestamp": "2025-09-25T05:54:26Z", - "deletionTimestamp": "2025-09-25T17:10:48Z" - }, - "spec": { - "description": "Enables K8s AuthZ endpoints", - "stage": "experimental", - "codeowner": "@grafana/identity-access-team", - "hideFromDocs": true - } - }, { "metadata": { "name": "kubernetesAuthzResourcePermissionApis", - "resourceVersion": "1754668670559", - "creationTimestamp": "2025-08-11T08:54:36Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Registers AuthZ resource permission /apis endpoints", @@ -2258,11 +1873,8 @@ { "metadata": { "name": "kubernetesAuthzZanzanaSync", - "resourceVersion": "1758887751768", - "creationTimestamp": "2025-09-26T09:35:02Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-26 11:55:51.768754 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-13T19:37:13Z" }, "spec": { "description": "Enable sync of Zanzana authorization store on AuthZ CRD mutations", @@ -2274,8 +1886,9 @@ { "metadata": { "name": "kubernetesCorrelations", - "resourceVersion": "1757513374180", - "creationTimestamp": "2025-09-10T14:09:34Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Adds support for Kubernetes correlations", @@ -2287,11 +1900,8 @@ { "metadata": { "name": "kubernetesDashboards", - "resourceVersion": "1763533458962", - "creationTimestamp": "2024-06-05T14:34:23Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 06:24:18.962973 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-06-05T14:34:23Z" }, "spec": { "description": "Use the kubernetes API in the frontend for dashboards", @@ -2303,7 +1913,7 @@ { "metadata": { "name": "kubernetesFeatureToggles", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-01-18T05:32:44Z" }, "spec": { @@ -2316,7 +1926,7 @@ { "metadata": { "name": "kubernetesLibraryPanels", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-25T22:21:56Z" }, "spec": { @@ -2329,11 +1939,8 @@ { "metadata": { "name": "kubernetesLogsDrilldown", - "resourceVersion": "1760632282014", - "creationTimestamp": "2025-10-07T19:26:08Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-16 16:31:22.014483 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-16T21:31:42Z" }, "spec": { "description": "Adds support for Kubernetes logs drilldown", @@ -2345,8 +1952,8 @@ { "metadata": { "name": "kubernetesQueryCaching", - "resourceVersion": "1760972620939", - "creationTimestamp": "2025-10-20T15:03:40Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-20T16:11:25Z" }, "spec": { "description": "Adds support for Kubernetes querycaching", @@ -2358,11 +1965,9 @@ { "metadata": { "name": "kubernetesShortURLs", - "resourceVersion": "1756914263808", - "creationTimestamp": "2025-08-04T12:12:12Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-03 15:44:23.80856 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables k8s short url api and uses it under the hood when handling legacy /api", @@ -2374,7 +1979,7 @@ { "metadata": { "name": "kubernetesSnapshots", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-12-05T22:31:49Z" }, "spec": { @@ -2387,8 +1992,9 @@ { "metadata": { "name": "kubernetesStars", - "resourceVersion": "1759149842036", - "creationTimestamp": "2025-09-29T12:44:02Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Routes stars requests from /api to the /apis endpoint", @@ -2397,24 +2003,10 @@ "requiresRestart": true } }, - { - "metadata": { - "name": "kubernetesZanzanaPopulate", - "resourceVersion": "1758879077577", - "creationTimestamp": "2025-09-26T09:31:17Z", - "deletionTimestamp": "2025-09-26T09:35:02Z" - }, - "spec": { - "description": "Populate Zanzana on AuthZ CRDs creation or update", - "stage": "experimental", - "codeowner": "@grafana/identity-access-team", - "hideFromDocs": true - } - }, { "metadata": { "name": "localeFormatPreference", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-31T13:59:07Z" }, "spec": { @@ -2423,23 +2015,10 @@ "codeowner": "@grafana/grafana-frontend-platform" } }, - { - "metadata": { - "name": "localizationForPlugins", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-31T04:38:38Z", - "deletionTimestamp": "2025-09-29T07:10:59Z" - }, - "spec": { - "description": "Enables localization for plugins", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend" - } - }, { "metadata": { "name": "logQLScope", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-11T11:53:24Z" }, "spec": { @@ -2453,7 +2032,7 @@ { "metadata": { "name": "logRequestsInstrumentedAsUnknown", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-06-10T08:56:55Z" }, "spec": { @@ -2462,25 +2041,10 @@ "codeowner": "@grafana/grafana-backend-group" } }, - { - "metadata": { - "name": "logRowsPopoverMenu", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-11-16T09:48:10Z", - "deletionTimestamp": "2025-11-07T10:57:27Z" - }, - "spec": { - "description": "Enable filtering menu displayed when text of a log line is selected", - "stage": "GA", - "codeowner": "@grafana/observability-logs", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "logsContextDatasourceUi", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-01-27T14:12:01Z" }, "spec": { @@ -2494,7 +2058,7 @@ { "metadata": { "name": "logsExploreTableDefaultVisualization", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-05-02T15:28:15Z" }, "spec": { @@ -2507,7 +2071,7 @@ { "metadata": { "name": "logsExploreTableVisualisation", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-07-12T13:52:42Z" }, "spec": { @@ -2518,24 +2082,10 @@ "expression": "true" } }, - { - "metadata": { - "name": "logsInfiniteScrolling", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-11-09T10:54:03Z" - }, - "spec": { - "description": "Enables infinite scrolling for the Logs panel in Explore and Dashboards", - "stage": "GA", - "codeowner": "@grafana/observability-logs", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "logsPanelControls", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-07T14:38:55Z" }, "spec": { @@ -2549,7 +2099,7 @@ { "metadata": { "name": "lokiExperimentalStreaming", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-06-19T10:03:51Z" }, "spec": { @@ -2561,7 +2111,7 @@ { "metadata": { "name": "lokiLabelNamesQueryApi", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-13T14:31:41Z" }, "spec": { @@ -2574,7 +2124,7 @@ { "metadata": { "name": "lokiLogsDataplane", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-07-13T07:58:00Z" }, "spec": { @@ -2583,10 +2133,23 @@ "codeowner": "@grafana/observability-logs" } }, + { + "metadata": { + "name": "lokiQueryLimitsContext", + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-21T14:16:23Z" + }, + "spec": { + "description": "Send X-Loki-Query-Limits-Context header to Loki on first split request", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, { "metadata": { "name": "lokiQuerySplitting", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-02-09T17:27:02Z" }, "spec": { @@ -2600,7 +2163,7 @@ { "metadata": { "name": "lokiRunQueriesInParallel", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-09-19T09:34:01Z" }, "spec": { @@ -2612,7 +2175,7 @@ { "metadata": { "name": "lokiShardSplitting", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-23T11:21:03Z" }, "spec": { @@ -2625,7 +2188,7 @@ { "metadata": { "name": "managedDualWriter", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-02-19T14:50:39Z" }, "spec": { @@ -2638,7 +2201,7 @@ { "metadata": { "name": "metricsFromProfiles", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-09T10:55:28Z" }, "spec": { @@ -2651,7 +2214,7 @@ { "metadata": { "name": "mlExpressions", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-07-13T17:37:50Z" }, "spec": { @@ -2660,23 +2223,10 @@ "codeowner": "@grafana/alerting-squad" } }, - { - "metadata": { - "name": "multiTenantFrontend", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-04-25T09:24:25Z", - "deletionTimestamp": "2025-09-08T17:06:39Z" - }, - "spec": { - "description": "Register MT frontend", - "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform" - } - }, { "metadata": { "name": "multiTenantTempCredentials", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-02T20:25:50Z" }, "spec": { @@ -2689,7 +2239,7 @@ { "metadata": { "name": "mysqlAnsiQuotes", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-10-12T11:43:35Z" }, "spec": { @@ -2701,8 +2251,9 @@ { "metadata": { "name": "newClickhouseConfigPageDesign", - "resourceVersion": "1754075145003", - "creationTimestamp": "2025-08-05T13:37:28Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables new design for the Clickhouse data source configuration page", @@ -2711,25 +2262,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "newDashboardSharingComponent", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-05-03T15:02:18Z", - "deletionTimestamp": "2025-09-12T17:27:39Z" - }, - "spec": { - "description": "Enables the new sharing drawer design", - "stage": "GA", - "codeowner": "@grafana/sharing-squad", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "newDashboardWithFiltersAndGroupBy", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-04T11:25:21Z" }, "spec": { @@ -2742,7 +2278,7 @@ { "metadata": { "name": "newFiltersUI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-08-30T12:48:13Z" }, "spec": { @@ -2755,8 +2291,8 @@ { "metadata": { "name": "newGauge", - "resourceVersion": "1760700645318", - "creationTimestamp": "2025-10-17T11:30:45Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-20T16:33:19Z" }, "spec": { "description": "Enable new gauge visualization", @@ -2769,7 +2305,7 @@ { "metadata": { "name": "newInfluxDSConfigPageDesign", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-25T16:39:54Z" }, "spec": { @@ -2782,8 +2318,8 @@ { "metadata": { "name": "newLogContext", - "resourceVersion": "1754044501326", - "creationTimestamp": "2025-08-01T11:30:17Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z" }, "spec": { "description": "New Log Context component", @@ -2795,11 +2331,8 @@ { "metadata": { "name": "newLogsPanel", - "resourceVersion": "1762166984808", - "creationTimestamp": "2025-02-04T17:40:17Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-03 10:49:44.808226 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-02-04T17:40:17Z" }, "spec": { "description": "Enables the new logs panel", @@ -2809,25 +2342,11 @@ "expression": "true" } }, - { - "metadata": { - "name": "newPDFRendering", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-02-08T12:09:34Z", - "deletionTimestamp": "2025-11-14T14:39:41Z" - }, - "spec": { - "description": "New implementation for the dashboard-to-PDF rendering", - "stage": "GA", - "codeowner": "@grafana/grafana-operator-experience-squad", - "expression": "true" - } - }, { "metadata": { "name": "newPanelPadding", - "resourceVersion": "1760780310038", - "creationTimestamp": "2025-10-18T09:38:30Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-12T15:40:46Z" }, "spec": { "description": "Increases panel padding globally", @@ -2839,7 +2358,7 @@ { "metadata": { "name": "newShareReportDrawer", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-02-17T19:05:46Z" }, "spec": { @@ -2849,11 +2368,24 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "newTimeRangeZoomShortcuts", + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-21T14:16:23Z" + }, + "spec": { + "description": "Enables new keyboard shortcuts for time range zoom operations", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, { "metadata": { "name": "newVizSuggestions", - "resourceVersion": "1762456851857", - "creationTimestamp": "2025-11-06T19:20:51Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-12T19:26:29Z" }, "spec": { "description": "Enable new visualization suggestions", @@ -2866,7 +2398,7 @@ { "metadata": { "name": "oauthRequireSubClaim", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-03-25T13:22:24Z" }, "spec": { @@ -2879,8 +2411,9 @@ { "metadata": { "name": "onPremToCloudMigrations", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-01-22T16:09:08Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-01-22T16:09:08Z", + "deletionTimestamp": "2025-11-20T09:59:41Z" }, "spec": { "description": "Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack.", @@ -2892,11 +2425,8 @@ { "metadata": { "name": "onlyStoreActionSets", - "resourceVersion": "1759844046154", - "creationTimestamp": "2025-10-07T13:24:26Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-07 13:34:06.15476 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-20T15:02:56Z" }, "spec": { "description": "When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission", @@ -2909,7 +2439,7 @@ { "metadata": { "name": "otelLogsFormatting", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-16T15:42:14Z" }, "spec": { @@ -2922,7 +2452,7 @@ { "metadata": { "name": "panelFilterVariable", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-11-03T12:15:54Z" }, "spec": { @@ -2933,40 +2463,11 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "panelMonitoring", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-10-09T05:19:08Z", - "deletionTimestamp": "2025-11-06T15:46:51Z" - }, - "spec": { - "description": "Enables panel monitoring through logs and measurements", - "stage": "GA", - "codeowner": "@grafana/dataviz-squad", - "frontend": true, - "expression": "true" - } - }, - { - "metadata": { - "name": "panelPadding", - "resourceVersion": "1760779980125", - "creationTimestamp": "2025-10-18T09:33:00Z", - "deletionTimestamp": "2025-10-18T09:38:30Z" - }, - "spec": { - "description": "Increases panel padding globally", - "stage": "experimental", - "codeowner": "@grafana/dashboards-squad", - "expression": "false" - } - }, { "metadata": { "name": "panelTimeSettings", - "resourceVersion": "1761555646368", - "creationTimestamp": "2025-10-27T09:00:46Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-29T08:06:23Z" }, "spec": { "description": "Enables a new panel time settings drawer", @@ -2977,7 +2478,7 @@ { "metadata": { "name": "panelTitleSearch", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-02-15T18:26:03Z" }, "spec": { @@ -2989,7 +2490,7 @@ { "metadata": { "name": "passwordlessMagicLinkAuthentication", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-14T13:50:55Z" }, "spec": { @@ -3002,7 +2503,7 @@ { "metadata": { "name": "pdfTables", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-11-06T13:39:22Z" }, "spec": { @@ -3011,10 +2512,23 @@ "codeowner": "@grafana/grafana-operator-experience-squad" } }, + { + "metadata": { + "name": "perPanelNonApplicableDrilldowns", + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-21T14:16:23Z" + }, + "spec": { + "description": "Enables viewing non-applicable drilldowns on a panel level", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "permissionsFilterRemoveSubquery", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-08-02T07:39:25Z" }, "spec": { @@ -3023,27 +2537,10 @@ "codeowner": "@grafana/search-and-storage" } }, - { - "metadata": { - "name": "pinNavItems", - "resourceVersion": "1762958248290", - "creationTimestamp": "2024-06-10T11:40:03Z", - "deletionTimestamp": "2025-11-13T14:52:10Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" - } - }, - "spec": { - "description": "Enables pinning of nav items", - "stage": "GA", - "codeowner": "@grafana/grafana-search-navigate-organise", - "expression": "true" - } - }, { "metadata": { "name": "playlistsReconciler", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-20T03:09:31Z" }, "spec": { @@ -3053,27 +2550,12 @@ "requiresRestart": true } }, - { - "metadata": { - "name": "pluginAssetProvider", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-07-17T15:20:35Z", - "deletionTimestamp": "2025-10-03T16:13:14Z" - }, - "spec": { - "description": "Allows decoupled core plugins to load from the Grafana CDN", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend", - "requiresRestart": true, - "hideFromDocs": true, - "expression": "false" - } - }, { "metadata": { "name": "pluginContainers", - "resourceVersion": "1756911074581", - "creationTimestamp": "2025-09-03T14:51:14Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables running plugins in containers", @@ -3086,8 +2568,8 @@ { "metadata": { "name": "pluginInstallAPISync", - "resourceVersion": "1760543624249", - "creationTimestamp": "2025-10-15T15:53:44Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-24T12:09:26Z" }, "spec": { "description": "Enable syncing plugin installations to the installs API", @@ -3099,7 +2581,7 @@ { "metadata": { "name": "pluginProxyPreserveTrailingSlash", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-05T11:36:14Z" }, "spec": { @@ -3112,11 +2594,8 @@ { "metadata": { "name": "pluginStoreServiceLoading", - "resourceVersion": "1761144346944", - "creationTimestamp": "2025-10-17T14:52:48Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-22 14:45:46.944669 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-17T20:01:43Z" }, "spec": { "description": "Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded", @@ -3128,7 +2607,7 @@ { "metadata": { "name": "pluginsAutoUpdate", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-16T11:44:39Z" }, "spec": { @@ -3137,36 +2616,10 @@ "codeowner": "@grafana/plugins-platform-backend" } }, - { - "metadata": { - "name": "pluginsFrontendSandbox", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-06-05T08:51:36Z", - "deletionTimestamp": "2025-11-05T10:06:22Z" - }, - "spec": { - "description": "Enables the plugins frontend sandbox", - "stage": "privatePreview", - "codeowner": "@grafana/plugins-platform-backend" - } - }, - { - "metadata": { - "name": "pluginsSkipHostEnvVars", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-11-15T17:09:14Z", - "deletionTimestamp": "2025-11-04T16:51:13Z" - }, - "spec": { - "description": "Disables passing host environment variable to plugin processes", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend" - } - }, { "metadata": { "name": "pluginsSriChecks", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-04T12:55:09Z" }, "spec": { @@ -3179,7 +2632,7 @@ { "metadata": { "name": "postgresDSUsePGX", - "resourceVersion": "1753174666056", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-05-26T06:54:18Z", "deletionTimestamp": "2025-06-03T12:45:07Z" }, @@ -3192,7 +2645,7 @@ { "metadata": { "name": "preferLibraryPanelTitle", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-17T11:21:21Z" }, "spec": { @@ -3202,24 +2655,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "preinstallAutoUpdate", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-11-07T12:14:25Z", - "deletionTimestamp": "2025-11-07T10:10:01Z" - }, - "spec": { - "description": "Enables automatic updates for pre-installed plugins", - "stage": "GA", - "codeowner": "@grafana/plugins-platform-backend", - "expression": "true" - } - }, { "metadata": { "name": "preserveDashboardStateWhenNavigating", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-05-27T12:28:06Z" }, "spec": { @@ -3232,8 +2671,8 @@ { "metadata": { "name": "preventPanelChromeOverflow", - "resourceVersion": "1760704390127", - "creationTimestamp": "2025-10-17T12:33:10Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-17T14:40:08Z" }, "spec": { "description": "Restrict PanelChrome contents with overflow: hidden;", @@ -3243,25 +2682,10 @@ "expression": "true" } }, - { - "metadata": { - "name": "promQLScope", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-01-29T20:22:17Z", - "deletionTimestamp": "2025-10-05T00:24:17Z" - }, - "spec": { - "description": "In-development feature that will allow injection of labels into prometheus queries.", - "stage": "GA", - "codeowner": "@grafana/oss-big-tent", - "hideFromDocs": true, - "expression": "true" - } - }, { "metadata": { "name": "prometheusAzureOverrideAudience", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-05-30T15:43:32Z", "deletionTimestamp": "2023-07-16T21:30:14Z" }, @@ -3272,24 +2696,10 @@ "expression": "true" } }, - { - "metadata": { - "name": "prometheusCodeModeMetricNamesSearch", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-04-04T20:38:23Z", - "deletionTimestamp": "2025-08-27T13:11:58Z" - }, - "spec": { - "description": "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names", - "stage": "experimental", - "codeowner": "@grafana/oss-big-tent", - "frontend": true - } - }, { "metadata": { "name": "prometheusSpecialCharsInLabelValues", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-18T21:31:08Z" }, "spec": { @@ -3302,11 +2712,9 @@ { "metadata": { "name": "prometheusTypeMigration", - "resourceVersion": "1757089774247", - "creationTimestamp": "2025-08-25T21:53:16Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-05 16:29:34.247055837 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Checks for deprecated Prometheus authentication methods (SigV4 and Azure), installs the relevant data source, and migrates the Prometheus data sources", @@ -3319,7 +2727,7 @@ { "metadata": { "name": "provisioning", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-22T09:03:50Z" }, "spec": { @@ -3332,7 +2740,7 @@ { "metadata": { "name": "publicDashboardsEmailSharing", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-01-03T19:45:15Z" }, "spec": { @@ -3345,7 +2753,7 @@ { "metadata": { "name": "publicDashboardsScene", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-03-22T14:48:21Z" }, "spec": { @@ -3359,8 +2767,9 @@ { "metadata": { "name": "queryCacheRequestDeduplication", - "resourceVersion": "1757521912495", - "creationTimestamp": "2025-09-10T16:31:52Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Enable request deduplication when query caching is enabled. Requests issuing the same query will be deduplicated, only the first request to arrive will be executed and the response will be shared with requests arriving while there is a request in-flight", @@ -3372,12 +2781,9 @@ { "metadata": { "name": "queryLibrary", - "resourceVersion": "1758208636622", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-10-07T18:31:45Z", - "deletionTimestamp": "2023-03-20T16:00:14Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-18 15:17:16.622111 +0000 UTC" - } + "deletionTimestamp": "2023-03-20T16:00:14Z" }, "spec": { "description": "Enables Saved queries (query library) feature", @@ -3388,7 +2794,7 @@ { "metadata": { "name": "queryService", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-19T09:26:21Z" }, "spec": { @@ -3401,7 +2807,7 @@ { "metadata": { "name": "queryServiceFromExplore", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-02T10:00:33Z" }, "spec": { @@ -3414,7 +2820,7 @@ { "metadata": { "name": "queryServiceFromUI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-19T09:26:21Z" }, "spec": { @@ -3427,7 +2833,7 @@ { "metadata": { "name": "queryServiceRewrite", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-04-19T09:26:21Z" }, "spec": { @@ -3440,7 +2846,7 @@ { "metadata": { "name": "queryServiceWithConnections", - "resourceVersion": "1756367172351", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-08-28T19:28:26Z", "deletionTimestamp": "2025-08-29T12:49:57Z" }, @@ -3451,24 +2857,10 @@ "requiresRestart": true } }, - { - "metadata": { - "name": "recordedQueriesMulti", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-06-14T12:34:22Z", - "deletionTimestamp": "2025-11-07T17:31:39Z" - }, - "spec": { - "description": "Enables writing multiple items from a single query within Recorded Queries", - "stage": "GA", - "codeowner": "@grafana/observability-metrics", - "expression": "true" - } - }, { "metadata": { "name": "refactorVariablesTimeRange", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-06-06T13:12:09Z" }, "spec": { @@ -3480,8 +2872,8 @@ { "metadata": { "name": "refreshTokenRequired", - "resourceVersion": "1763561990273", - "creationTimestamp": "2025-11-19T14:19:50Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-21T14:16:23Z" }, "spec": { "description": "Require that refresh tokens are present in oauth tokens.", @@ -3490,27 +2882,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "regressionTransformation", - "resourceVersion": "1762442825881", - "creationTimestamp": "2023-11-24T14:49:16Z", - "deletionTimestamp": "2025-07-01T13:59:22Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" - } - }, - "spec": { - "description": "Enables regression analysis transformation", - "stage": "preview", - "codeowner": "@grafana/datapro", - "frontend": true - } - }, { "metadata": { "name": "reloadDashboardsOnParamsChange", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-25T12:56:54Z" }, "spec": { @@ -3523,7 +2898,7 @@ { "metadata": { "name": "renderAuthJWT", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-04-03T16:53:38Z" }, "spec": { @@ -3535,7 +2910,7 @@ { "metadata": { "name": "rendererDisableAppPluginsPreload", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-02-24T14:43:06Z" }, "spec": { @@ -3549,7 +2924,7 @@ { "metadata": { "name": "reportingRetries", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-08-31T07:47:47Z" }, "spec": { @@ -3562,11 +2937,8 @@ { "metadata": { "name": "restoreDashboards", - "resourceVersion": "1762958248290", - "creationTimestamp": "2025-05-23T14:35:54Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-05-23T14:35:54Z" }, "spec": { "description": "Enables restore deleted dashboards feature", @@ -3578,11 +2950,9 @@ { "metadata": { "name": "restrictedPluginApis", - "resourceVersion": "1753776783657", - "creationTimestamp": "2025-09-01T09:57:00Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-07-29 08:13:03.657209 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables sharing a list of APIs with a list of plugins", @@ -3596,7 +2966,7 @@ { "metadata": { "name": "rolePickerDrawer", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-26T12:51:38Z" }, "spec": { @@ -3608,8 +2978,8 @@ { "metadata": { "name": "rudderstackUpgrade", - "resourceVersion": "1763481217872", - "creationTimestamp": "2025-11-18T15:53:37Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-21T14:16:23Z" }, "spec": { "description": "Enables the new version of rudderstack", @@ -3619,26 +2989,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "savedQueries", - "resourceVersion": "1756920131554", - "creationTimestamp": "2025-08-25T21:22:09Z", - "deletionTimestamp": "2025-09-18T12:07:31Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-03 17:22:11.554759 +0000 UTC" - } - }, - "spec": { - "description": "Enables Saved Queries feature", - "stage": "preview", - "codeowner": "@grafana/sharing-squad" - } - }, { "metadata": { "name": "scanRowInvalidDashboardParseFallbackEnabled", - "resourceVersion": "1753730899886", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-30T14:18:38Z" }, "spec": { @@ -3650,7 +3004,7 @@ { "metadata": { "name": "scopeApi", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-11-27T07:58:25Z" }, "spec": { @@ -3663,7 +3017,7 @@ { "metadata": { "name": "scopeFilters", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-03-05T15:41:19Z" }, "spec": { @@ -3676,11 +3030,8 @@ { "metadata": { "name": "scopeSearchAllLevels", - "resourceVersion": "1762958248290", - "creationTimestamp": "2025-04-14T07:42:16Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-04-14T07:42:16Z" }, "spec": { "description": "Enable scope search to include all levels of the scope node tree", @@ -3692,7 +3043,7 @@ { "metadata": { "name": "secretsManagementAppPlatform", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-19T09:25:14Z" }, "spec": { @@ -3704,8 +3055,9 @@ { "metadata": { "name": "secretsManagementAppPlatformUI", - "resourceVersion": "1756816818369", - "creationTimestamp": "2025-09-02T12:40:18Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enable the secrets management app platform UI", @@ -3716,11 +3068,8 @@ { "metadata": { "name": "sharingDashboardImage", - "resourceVersion": "1761782859498", - "creationTimestamp": "2025-07-15T21:07:39Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-30 00:07:39.498343 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-15T21:07:39Z" }, "spec": { "description": "Enables image sharing functionality for dashboards", @@ -3733,7 +3082,7 @@ { "metadata": { "name": "showDashboardValidationWarnings", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-10-14T13:51:05Z" }, "spec": { @@ -3742,43 +3091,11 @@ "codeowner": "@grafana/dashboards-squad" } }, - { - "metadata": { - "name": "skipTokenRotationIfRecent", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-06-03T06:59:40Z", - "deletionTimestamp": "2025-10-22T10:29:12Z" - }, - "spec": { - "description": "Skip token rotation if it was already rotated less than 5 seconds ago", - "stage": "GA", - "codeowner": "@grafana/identity-access-team", - "hideFromDocs": true, - "expression": "true" - } - }, - { - "metadata": { - "name": "sqlDatasourceDatabaseSelection", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-06-06T16:28:52Z", - "deletionTimestamp": "2025-08-12T13:22:30Z" - }, - "spec": { - "description": "Enables previous SQL data source dataset dropdown behavior", - "stage": "preview", - "codeowner": "@grafana/oss-big-tent", - "frontend": true - } - }, { "metadata": { "name": "sqlExpressions", - "resourceVersion": "1756828051955", - "creationTimestamp": "2024-02-27T21:16:00Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-02 15:47:31.955288671 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-02-27T21:16:00Z" }, "spec": { "description": "Enables SQL Expressions, which can execute SQL queries against data source results.", @@ -3789,7 +3106,7 @@ { "metadata": { "name": "sqlExpressionsColumnAutoComplete", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-23T21:49:58Z" }, "spec": { @@ -3802,11 +3119,8 @@ { "metadata": { "name": "sseGroupByDatasource", - "resourceVersion": "1762552416963", - "creationTimestamp": "2023-09-07T20:02:07Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-07 21:53:36.963146843 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2023-09-07T20:02:07Z" }, "spec": { "description": "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", @@ -3817,7 +3131,7 @@ { "metadata": { "name": "ssoSettingsLDAP", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-18T11:31:27Z" }, "spec": { @@ -3831,11 +3145,9 @@ { "metadata": { "name": "starsFromAPIServer", - "resourceVersion": "1762958248290", - "creationTimestamp": "2025-09-19T10:00:55Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "populate star status from apiserver", @@ -3848,7 +3160,7 @@ { "metadata": { "name": "storage", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2022-03-17T17:19:23Z" }, "spec": { @@ -3860,33 +3172,19 @@ { "metadata": { "name": "suggestedDashboards", - "resourceVersion": "1762521182817", - "creationTimestamp": "2025-11-07T13:13:02Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-07T13:38:59Z" }, "spec": { - "description": "Enable suggested dashboards when creating new dashboards", + "description": "Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page", "stage": "experimental", "codeowner": "@grafana/sharing-squad" } }, - { - "metadata": { - "name": "tableNextGen", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-26T03:57:57Z", - "deletionTimestamp": "2025-08-26T21:25:16Z" - }, - "spec": { - "description": "Allows access to the new react-data-grid based table component.", - "stage": "preview", - "codeowner": "@grafana/dataviz-squad", - "frontend": true - } - }, { "metadata": { "name": "tableSharedCrosshair", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-12-13T09:33:14Z" }, "spec": { @@ -3899,7 +3197,7 @@ { "metadata": { "name": "tabularNumbers", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-06-24T11:52:03Z" }, "spec": { @@ -3912,8 +3210,9 @@ { "metadata": { "name": "teamFolders", - "resourceVersion": "1755099058649", - "creationTimestamp": "2025-08-13T16:41:00Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Enables team folders functionality", @@ -3922,24 +3221,10 @@ "expression": "false" } }, - { - "metadata": { - "name": "teamHttpHeadersMimir", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-01-13T10:42:47Z", - "deletionTimestamp": "2025-08-07T09:04:46Z" - }, - "spec": { - "description": "Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams", - "stage": "GA", - "codeowner": "@grafana/identity-access-team", - "expression": "true" - } - }, { "metadata": { "name": "teamHttpHeadersTempo", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-05-22T19:13:31Z" }, "spec": { @@ -3948,42 +3233,11 @@ "codeowner": "@grafana/identity-access-team" } }, - { - "metadata": { - "name": "templateDashboards", - "resourceVersion": "1758897202631", - "creationTimestamp": "2025-09-26T14:33:22Z", - "deletionTimestamp": "2025-09-26T16:02:12Z" - }, - "spec": { - "description": "Enables template dashboards suggestions when creating new dashboards", - "stage": "experimental", - "codeowner": "@grafana/sharing-squad", - "frontend": true - } - }, - { - "metadata": { - "name": "templateVariablesUsesCombobox", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-01-31T09:53:13Z", - "deletionTimestamp": "2025-11-12T14:40:39Z" - }, - "spec": { - "description": "Use new **Combobox** component for template variables", - "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", - "frontend": true - } - }, { "metadata": { "name": "tempoAlerting", - "resourceVersion": "1763532089512", - "creationTimestamp": "2025-07-15T13:36:36Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-19 06:01:29.512182 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-15T13:36:36Z" }, "spec": { "description": "Enables creating alerts from Tempo data source", @@ -3994,11 +3248,9 @@ { "metadata": { "name": "tempoSearchBackendMigration", - "resourceVersion": "1758029567165", - "creationTimestamp": "2025-09-16T11:18:16Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-16 13:32:47.165146 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Run search queries through the tempo backend", @@ -4011,7 +3263,7 @@ { "metadata": { "name": "timeComparison", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-24T20:07:28Z" }, "spec": { @@ -4024,12 +3276,8 @@ { "metadata": { "name": "timeRangePan", - "resourceVersion": "1762290731154", - "creationTimestamp": "2025-10-24T19:49:53Z", - "deletionTimestamp": "2025-11-06T17:39:31Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-04 21:12:11.154822 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-05T01:39:46Z" }, "spec": { "description": "Enables time range panning functionality", @@ -4041,7 +3289,7 @@ { "metadata": { "name": "timeRangeProvider", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-22T10:52:33Z" }, "spec": { @@ -4050,28 +3298,11 @@ "codeowner": "@grafana/grafana-frontend-platform" } }, - { - "metadata": { - "name": "tlsMemcached", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-05-09T19:12:08Z", - "deletionTimestamp": "2025-11-11T13:50:51Z" - }, - "spec": { - "description": "Use TLS-enabled memcached in the enterprise caching feature", - "stage": "GA", - "codeowner": "@grafana/grafana-operator-experience-squad", - "expression": "true" - } - }, { "metadata": { "name": "transformationsEmptyPlaceholder", - "resourceVersion": "1763373021129", - "creationTimestamp": "2025-11-11T13:14:25Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-17 09:50:21.129721 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-17T13:57:05Z" }, "spec": { "description": "Show transformation quick-start cards in empty transformations state", @@ -4080,26 +3311,11 @@ "frontend": true } }, - { - "metadata": { - "name": "transformationsRedesign", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-07-12T16:35:49Z", - "deletionTimestamp": "2025-11-06T20:37:15Z" - }, - "spec": { - "description": "Enables the transformations redesign", - "stage": "GA", - "codeowner": "@grafana/observability-metrics", - "frontend": true, - "expression": "true" - } - }, { "metadata": { "name": "ttlPluginInstanceManager", - "resourceVersion": "1763462850634", - "creationTimestamp": "2025-11-18T10:47:30Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-11-18T11:17:23Z" }, "spec": { "description": "Enable TTL plugin instance manager", @@ -4111,11 +3327,8 @@ { "metadata": { "name": "unifiedHistory", - "resourceVersion": "1762958248290", - "creationTimestamp": "2024-12-13T10:41:18Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2024-12-13T10:41:18Z" }, "spec": { "description": "Displays the navigation history so the user can navigate back to previous pages", @@ -4127,7 +3340,7 @@ { "metadata": { "name": "unifiedNavbars", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-04-09T12:51:22Z" }, "spec": { @@ -4141,7 +3354,7 @@ { "metadata": { "name": "unifiedRequestLog", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2023-03-31T13:38:09Z" }, "spec": { @@ -4154,7 +3367,7 @@ { "metadata": { "name": "unifiedStorageBigObjectsSupport", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-10-17T10:18:29Z" }, "spec": { @@ -4166,7 +3379,7 @@ { "metadata": { "name": "unifiedStorageGrpcConnectionPool", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-03-21T13:24:54Z" }, "spec": { @@ -4176,25 +3389,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "unifiedStorageHistoryPruner", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-17T10:36:38Z", - "deletionTimestamp": "2025-11-17T12:35:33Z" - }, - "spec": { - "description": "Enables the unified storage history pruner", - "stage": "GA", - "codeowner": "@grafana/search-and-storage", - "hideFromDocs": true, - "expression": "true" - } - }, { "metadata": { "name": "unifiedStorageSearch", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-30T19:46:14Z" }, "spec": { @@ -4204,29 +3402,10 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "unifiedStorageSearchAfterWriteExperimentalAPI", - "resourceVersion": "1755089543487", - "creationTimestamp": "2025-08-13T14:05:15Z", - "deletionTimestamp": "2025-09-10T09:52:56Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-08-13 12:52:23.487521 +0000 UTC" - } - }, - "spec": { - "description": "Enable experimental search-after-write guarantees to unified-storage search endpoints", - "stage": "experimental", - "codeowner": "@grafana/search-and-storage", - "requiresRestart": true, - "hideFromDocs": true, - "expression": "false" - } - }, { "metadata": { "name": "unifiedStorageSearchDualReaderEnabled", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-18T12:43:56Z" }, "spec": { @@ -4239,7 +3418,7 @@ { "metadata": { "name": "unifiedStorageSearchSprinkles", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-18T17:00:54Z" }, "spec": { @@ -4252,7 +3431,7 @@ { "metadata": { "name": "unifiedStorageSearchUI", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-12-19T18:21:48Z" }, "spec": { @@ -4262,25 +3441,11 @@ "hideFromDocs": true } }, - { - "metadata": { - "name": "unifiedStorageUseFullNgram", - "resourceVersion": "1758820248165", - "creationTimestamp": "2025-09-25T17:10:48Z", - "deletionTimestamp": "2025-10-22T12:24:39Z" - }, - "spec": { - "description": "Use full n-gram indexing instead of edge n-gram for unified storage search", - "stage": "experimental", - "codeowner": "@grafana/search-and-storage", - "hideFromDocs": true - } - }, { "metadata": { "name": "unlimitedLayoutsNesting", - "resourceVersion": "1760013838902", - "creationTimestamp": "2025-10-09T12:43:58Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-10T12:15:54Z" }, "spec": { "description": "Enables unlimited dashboard panel grouping", @@ -4292,11 +3457,9 @@ { "metadata": { "name": "useKubernetesShortURLsAPI", - "resourceVersion": "1756914263808", - "creationTimestamp": "2025-09-03T10:49:07Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-03 15:44:23.80856 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Routes short url requests from /api to the /apis endpoint in the frontend. Depends on kubernetesShortURLs", @@ -4308,8 +3471,9 @@ { "metadata": { "name": "useMultipleScopeNodesEndpoint", - "resourceVersion": "1759237515008", - "creationTimestamp": "2025-09-30T13:05:15Z" + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-08-29T14:46:39Z", + "deletionTimestamp": "2025-09-01T09:33:33Z" }, "spec": { "description": "Makes the frontend use the 'names' param for fetching multiple scope nodes at once", @@ -4323,7 +3487,7 @@ { "metadata": { "name": "useScopeSingleNodeEndpoint", - "resourceVersion": "1753960766702", + "resourceVersion": "1763734583253", "creationTimestamp": "2025-07-31T14:32:41Z" }, "spec": { @@ -4338,11 +3502,8 @@ { "metadata": { "name": "useScopesNavigationEndpoint", - "resourceVersion": "1762958248290", - "creationTimestamp": "2025-03-31T15:20:00Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-03-31T15:20:00Z" }, "spec": { "description": "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", @@ -4355,7 +3516,7 @@ { "metadata": { "name": "useSessionStorageForRedirection", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-09-23T09:31:23Z" }, "spec": { @@ -4368,11 +3529,9 @@ { "metadata": { "name": "vizActionsAuth", - "resourceVersion": "1756904995830", - "creationTimestamp": "2025-08-08T18:59:18Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-09-03 13:09:55.830412 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-07-31T22:56:50Z", + "deletionTimestamp": "2025-08-01T11:30:17Z" }, "spec": { "description": "Allows authenticated API calls in actions", @@ -4385,7 +3544,7 @@ { "metadata": { "name": "zanzana", - "resourceVersion": "1753448760331", + "resourceVersion": "1763734583253", "creationTimestamp": "2024-06-19T13:59:47Z" }, "spec": { @@ -4398,11 +3557,8 @@ { "metadata": { "name": "zanzanaNoLegacyClient", - "resourceVersion": "1761063016739", - "creationTimestamp": "2025-10-20T09:52:55Z", - "annotations": { - "grafana.app/updatedTimestamp": "2025-10-21 16:10:16.739546 +0000 UTC" - } + "resourceVersion": "1763734583253", + "creationTimestamp": "2025-10-21T14:03:17Z" }, "spec": { "description": "Use openFGA as main authorization engine and disable legacy RBAC clietn.", diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 2a8cebebef6..2eb4d82dce3 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -187,7 +187,15 @@ // Wrap in an IIFE to avoid polluting the global scope. Intentionally global-scope properties // are explicitly assigned to the `window` object. (() => { + // Grafana can only fail to load once + // However, it can fail to load in multiple different places + // To avoid double reporting the error, we use this boolean to check if we've already failed + let hasFailedToBoot = false; window.__grafana_load_failed = function(err) { + if (hasFailedToBoot) { + return; + } + hasFailedToBoot = true; console.error('Failed to load Grafana', err); document.querySelector('.fs-variant-loader').classList.add('fs-hidden'); document.querySelector('.fs-variant-error').classList.remove('fs-hidden'); @@ -356,7 +364,6 @@ document.head.appendChild(cssLink); } - window.__grafana_boot_data_promise = initGrafana() window.__grafana_boot_data_promise.catch((err) => { console.error("__grafana_boot_data_promise rejected", err); diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 312905cf0c8..4895e7e6505 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -148,6 +148,7 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon // 404: notFoundError // 500: internalServerError func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response { + //nolint:staticcheck // not yet migrated to OpenFeature if l.features.IsEnabled(c.Req.Context(), featuremgmt.FlagKubernetesLibraryPanels) { l.k8sHandler.getK8sLibraryElement(c) return nil // already handled in the k8s handler diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 7e2af182a87..ca91ab4f915 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -44,7 +44,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Text: "Organizations", SubTitle: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building", }) } - if hasAccess(cloudmigration.MigrationAssistantAccess) && s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) { + if hasAccess(cloudmigration.MigrationAssistantAccess) && s.cfg.CloudMigration.Enabled { generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{ Text: "Migrate to Grafana Cloud", Id: "migrate-to-cloud", @@ -99,6 +99,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink }) } + //nolint:staticcheck // not yet migrated to OpenFeature if (s.cfg.Env == setting.Dev) || s.features.IsEnabled(ctx, featuremgmt.FlagEnableExtensionsAdminPage) && hasAccess(pluginaccesscontrol.AdminAccessEvaluator) { pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{ Text: "Extensions", @@ -147,6 +148,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink }) } + //nolint:staticcheck // not yet migrated to OpenFeature if s.license.FeatureEnabled("groupsync") && s.features.IsEnabled(ctx, featuremgmt.FlagGroupAttributeSync) && hasAccess(ac.EvalAny( diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 0c059c204b5..5b9eb985bd8 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -407,6 +407,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt }) } + //nolint:staticcheck // not yet migrated to OpenFeature if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagRestoreDashboards) && (c.GetOrgRole() == org.RoleAdmin || c.IsGrafanaAdmin) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Recently deleted", @@ -435,6 +436,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na hasAccess := ac.HasAccess(s.accessControl, c) var alertChildNavs []*navtree.NavLink + //nolint:staticcheck // not yet migrated to OpenFeature if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) { if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ @@ -492,6 +494,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts with active notifications", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) } + //nolint:staticcheck // not yet migrated to OpenFeature if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) { if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ @@ -503,6 +506,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na }) } } + //nolint:staticcheck // not yet migrated to OpenFeature if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ Text: "Recently deleted", diff --git a/pkg/services/ngalert/api/api_configuration.go b/pkg/services/ngalert/api/api_configuration.go index 20f131f927b..01e0291c585 100644 --- a/pkg/services/ngalert/api/api_configuration.go +++ b/pkg/services/ngalert/api/api_configuration.go @@ -77,6 +77,7 @@ func (srv ConfigSrv) RoutePostNGalertConfig(c *contextmodel.ReqContext, body api return response.Error(http.StatusBadRequest, "Invalid alertmanager choice specified", err) } + //nolint:staticcheck // not yet migrated to OpenFeature disableExternal := srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingDisableSendAlertsExternal) if disableExternal && sendAlertsTo != ngmodels.InternalAlertmanager { return response.Error(http.StatusBadRequest, "Sending alerts to external alertmanagers is disallowed on this instance", err) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 2b8ef4b48e7..e31e713fb33 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -23,6 +23,7 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -327,6 +328,7 @@ func withLabels(labels data.Labels) forEachState { } } +//nolint:gocyclo func TestRouteGetRuleStatuses(t *testing.T) { timeNow = func() time.Time { return time.Date(2022, 3, 10, 14, 0, 0, 0, time.UTC) } orgID := int64(1) @@ -2219,6 +2221,575 @@ func TestRouteGetRuleStatuses(t *testing.T) { require.True(t, foundNoProv, "should find rule without provenance") require.True(t, foundWithProv, "should find rule with provenance") }) + + t.Run("filter-aware pagination", func(t *testing.T) { + createRulesWithState := func(t *testing.T, store *fakes.RuleStore, aim *fakeAlertInstanceManager, + orgID int64, numGroups int, rulesPerGroup int, + stateFunc func(groupIdx int) eval.State, + healthFunc func(groupIdx int) error, + stateMutators ...func(groupIdx int, s *state.State) *state.State) { + t.Helper() + + // create folders + for i := 1; i <= numGroups; i++ { + store.Folders[orgID] = append(store.Folders[orgID], &folder.Folder{ + ID: int64(i), + UID: fmt.Sprintf("ns-%d", i), + Title: fmt.Sprintf("Namespace %d", i), + Fullpath: fmt.Sprintf("/namespace-%d", i), + }) + } + + for i := 0; i < numGroups; i++ { + for j := 0; j < rulesPerGroup; j++ { + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = fmt.Sprintf("ns-%d", i+1) + r.RuleGroup = fmt.Sprintf("group-%d", i+1) + r.UID = fmt.Sprintf("rule-%d-%d", i+1, j+1) + }, withClassicConditionSingleQuery()).GenerateRef() + + alertState := stateFunc(i) + healthErr := healthFunc(i) + + aim.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = alertState + s.Error = healthErr + s.Labels = data.Labels{"test": "label"} + + for _, mutator := range stateMutators { + s = mutator(i, s) + } + return s + }) + store.PutRule(context.Background(), rule) + } + } + } + + t.Run("state filter fetches multiple pages to fill group_limit", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 10 groups (2 rules each = 20 rules total): groups 1,3,5,7,9 firing, groups 2,4,6,8,10 normal + // Request group_limit=3 with state=firing should fetch pages until 3 firing groups collected + createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2, + func(i int) eval.State { + if i%2 == 0 { + return eval.Alerting + } + return eval.Normal + }, + func(i int) error { return nil }) + + // Request 3 groups with state=firing filter + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=3", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 3 firing groups + require.Len(t, res.Data.RuleGroups, 3) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Equal(t, "group-3", res.Data.RuleGroups[1].Name) + require.Equal(t, "group-5", res.Data.RuleGroups[2].Name) + + // Verify all have firing alerts + for _, rg := range res.Data.RuleGroups { + hasFiring := false + for _, rule := range rg.Rules { + for _, alert := range rule.Alerts { + if alert.State == eval.Alerting.String() { + hasFiring = true + } + } + } + require.True(t, hasFiring) + } + }) + + t.Run("state filter continues when first page has no matches", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 8 groups (2 rules each = 16 rules total): first 4 normal, last 4 firing + // Request state=firing with group_limit=2 should skip first 4 and return groups 5,6 + createRulesWithState(t, fakeStore, fakeAIM, orgID, 8, 2, + func(i int) eval.State { + if i < 4 { + return eval.Normal // groups 1-4 normal + } + return eval.Alerting // groups 5-8 firing + }, + func(i int) error { return nil }) + + // Request 2 firing groups - should skip past the first page of normal rules + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 2 firing groups + require.Len(t, res.Data.RuleGroups, 2) + require.Equal(t, "group-5", res.Data.RuleGroups[0].Name) + require.Equal(t, "group-6", res.Data.RuleGroups[1].Name) + }) + + t.Run("health filter fetches multiple pages", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 8 groups (2 rules each = 16 rules total): groups 1,3,5,7 with error health, groups 2,4,6,8 with ok health + // Request health=error with group_limit=3 should fetch pages until 3 error groups collected + createRulesWithState(t, fakeStore, fakeAIM, orgID, 8, 2, + func(i int) eval.State { return eval.Normal }, + func(i int) error { + if i%2 == 0 { + return fmt.Errorf("evaluation error") + } + return nil + }) + + // Request 3 groups with health=error filter + req, err := http.NewRequest("GET", "/api/v1/rules?health=error&group_limit=3", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 3 error groups + require.Len(t, res.Data.RuleGroups, 3) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Equal(t, "group-3", res.Data.RuleGroups[1].Name) + require.Equal(t, "group-5", res.Data.RuleGroups[2].Name) + + // Verify all have error health + for _, rg := range res.Data.RuleGroups { + for _, rule := range rg.Rules { + require.Equal(t, "error", rule.Health) + } + } + }) + + t.Run("combined state and health filters", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 10 groups (2 rules each = 20 rules total) + // Groups 1-5: firing, groups 6-10: normal + // Groups 1,3,5,7,9: ok health, groups 2,4,6,8,10: error health + // Groups matching both filters (firing + ok): 1,3,5 + createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2, + func(i int) eval.State { + if i < 5 { + return eval.Alerting + } + return eval.Normal + }, + func(i int) error { + if i%2 == 1 { + return fmt.Errorf("evaluation error") + } + return nil + }) + + // Request 3 groups with state=firing AND health=ok + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&health=ok&group_limit=3", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 3 groups matching both filters + require.Len(t, res.Data.RuleGroups, 3) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Equal(t, "group-3", res.Data.RuleGroups[1].Name) + require.Equal(t, "group-5", res.Data.RuleGroups[2].Name) + + // Verify all match both criteria + for _, rg := range res.Data.RuleGroups { + for _, rule := range rg.Rules { + require.Equal(t, "ok", rule.Health) + hasFiring := false + for _, alert := range rule.Alerts { + if alert.State == eval.Alerting.String() { + hasFiring = true + } + } + require.True(t, hasFiring) + } + } + }) + + t.Run("rule_limit hit before group_limit", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 5 groups (3 rules each = 15 rules total) + // Request: group_limit=10, rule_limit=8 + // Expected: Should return groups 1-3 (9 rules total, exceeds limit but complete group included) + createRulesWithState(t, fakeStore, fakeAIM, orgID, 5, 3, + func(i int) eval.State { return eval.Alerting }, // all firing + func(i int) error { return nil }) // all healthy + + // Request group_limit=10, rule_limit=8 - rule limit should hit first + req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10&rule_limit=8", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Expected behavior with rule_limit=8: + // Group 1: 3 rules (total: 3, under 8, continue) + // Group 2: 3 rules (total: 6, under 8, continue) + // Group 3: 3 rules (total: 9, exceeds 8 but we include complete group) + // Result: 3 groups, 9 rules total + totalRules := 0 + for _, rg := range res.Data.RuleGroups { + t.Logf("Group %s has %d rules", rg.Name, len(rg.Rules)) + totalRules += len(rg.Rules) + } + t.Logf("Total: %d rules, %d groups", totalRules, len(res.Data.RuleGroups)) + require.Equal(t, 9, totalRules) + require.Equal(t, 3, len(res.Data.RuleGroups)) + }) + + t.Run("rule_limit without group_limit", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 10 groups (2 rules each = 20 rules total) + // Request rule_limit=7 should stop after 4 groups (8 rules total) + createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2, + func(i int) eval.State { return eval.Alerting }, + func(i int) error { return nil }) + + // Request rule_limit=7 only (group_limit unlimited) + req, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=7", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 4 groups (8 rules total, stops after exceeding 7) + totalRules := 0 + for _, rg := range res.Data.RuleGroups { + totalRules += len(rg.Rules) + } + require.Equal(t, 8, totalRules) + require.Equal(t, 4, len(res.Data.RuleGroups)) + require.NotEmpty(t, res.Data.NextToken) + }) + + t.Run("empty page in middle of pagination", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 15 groups (2 rules each = 30 rules total): groups 1-3 firing, 4-8 normal, 9-13 firing, 14-15 normal + // Request state=firing with group_limit=5 should skip over normal groups + createRulesWithState(t, fakeStore, fakeAIM, orgID, 15, 2, + func(i int) eval.State { + groupNum := i + 1 + if groupNum <= 3 || (groupNum >= 9 && groupNum <= 13) { + return eval.Alerting + } + return eval.Normal + }, + func(i int) error { return nil }) + + // Request state=firing, group_limit=5 + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=5", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 5 firing groups from pages 1 and 3 (skipping empty page 2) + require.Len(t, res.Data.RuleGroups, 5) + + // Verify all are firing + for _, rg := range res.Data.RuleGroups { + for _, rule := range rg.Rules { + for _, alert := range rule.Alerts { + require.Equal(t, eval.Alerting.String(), alert.State) + } + } + } + }) + + t.Run("group_limit=0 returns empty response", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 1 group (2 rules) to verify group_limit=0 returns empty + createRulesWithState(t, fakeStore, fakeAIM, orgID, 1, 2, + func(i int) eval.State { return eval.Alerting }, + func(i int) error { return nil }) + + // Request group_limit=0 + req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=0", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + require.Len(t, res.Data.RuleGroups, 0) + require.Empty(t, res.Data.NextToken) + }) + + t.Run("resume with token and filters active", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 10 groups (2 rules each = 20 rules total): odd groups firing, even groups normal + // Test pagination continuation with state filter: first page returns groups 1,3 then second page returns groups 5,7 + createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2, + func(i int) eval.State { + if i%2 == 0 { + return eval.Alerting + } + return eval.Normal + }, + func(i int) error { return nil }) + + // First request: state=firing, group_limit=2 + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res1 apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res1)) + + // Should return 2 firing groups (1, 3) + require.Len(t, res1.Data.RuleGroups, 2) + require.NotEmpty(t, res1.Data.NextToken) + + // Verify both are firing + for _, rg := range res1.Data.RuleGroups { + for _, rule := range rg.Rules { + require.Equal(t, "firing", rule.State) + } + } + + // Second request: resume with token AND filter still active + req2, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/rules?state=firing&group_limit=2&group_next_token=%s", res1.Data.NextToken), nil) + require.NoError(t, err) + c2 := &contextmodel.ReqContext{ + Context: &web.Context{Req: req2}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp2 := api.RouteGetRuleStatuses(c2) + require.Equal(t, http.StatusOK, resp2.Status()) + + var res2 apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp2.Body(), &res2)) + + // Should return next 2 firing groups (5, 7) + require.Len(t, res2.Data.RuleGroups, 2) + require.NotEmpty(t, res2.Data.NextToken) + + // Verify both are firing + for _, rg := range res2.Data.RuleGroups { + for _, rule := range rg.Rules { + require.Equal(t, "firing", rule.State) + } + } + + // Verify we got different groups than first request + firstGroupNames := make(map[string]bool) + for _, rg := range res1.Data.RuleGroups { + firstGroupNames[rg.Name] = true + } + for _, rg := range res2.Data.RuleGroups { + require.False(t, firstGroupNames[rg.Name]) + } + }) + + t.Run("rule_limit with state filter", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 10 groups (2 rules each = 20 rules total), alternating firing/normal + // Firing groups: 1,3,5,7,9 (5 groups × 2 rules = 10 firing rules) + // Request state=firing with rule_limit=7 should return groups 1,3,5,7 (8 rules) + createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2, + func(i int) eval.State { + if i%2 == 0 { + return eval.Alerting + } + return eval.Normal + }, + func(i int) error { return nil }) + + // Request state=firing, rule_limit=7 + // Should fetch multiple pages to accumulate 7+ firing rules + // Groups 1, 3, 5 = 6 rules (under limit) + // Group 7 = +2 rules = 8 total (exceeds 7, but we include full group) + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&rule_limit=7", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Count total firing rules returned + totalRules := 0 + for _, rg := range res.Data.RuleGroups { + for _, rule := range rg.Rules { + require.Equal(t, "firing", rule.State) + totalRules++ + } + } + + // Should return 4 firing groups (1,3,5,7) with 8 rules total + require.Equal(t, 8, totalRules) + require.Equal(t, 4, len(res.Data.RuleGroups)) + require.NotEmpty(t, res.Data.NextToken) + }) + + t.Run("rule_limit with health filter", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 8 groups (3 rules each = 24 rules total), alternating ok/error health + // Error groups: 1,3,5,7 (4 groups × 3 rules = 12 error rules) + // Request health=error with rule_limit=8 should return groups 1,3,5 (9 rules) + createRulesWithState(t, fakeStore, fakeAIM, orgID, 8, 3, + func(i int) eval.State { return eval.Normal }, + func(i int) error { + if i%2 == 0 { + return fmt.Errorf("evaluation error") + } + return nil + }) + + // Request health=error, rule_limit=8 + // Should fetch multiple pages to accumulate 8+ error rules + // Groups 1, 3 = 6 rules (under limit) + // Group 5 = +3 rules = 9 total (exceeds 8, but we include full group) + req, err := http.NewRequest("GET", "/api/v1/rules?health=error&rule_limit=8", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Count total error rules returned + totalRules := 0 + for _, rg := range res.Data.RuleGroups { + for _, rule := range rg.Rules { + require.Equal(t, "error", rule.Health) + totalRules++ + } + } + + // Should return 3 error groups (1,3,5) with 9 rules total + require.Equal(t, 9, totalRules) + require.Equal(t, 3, len(res.Data.RuleGroups)) + require.NotEmpty(t, res.Data.NextToken) + }) + }) } func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) { diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index ceda51a7a22..3bda2e3f28f 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -79,6 +79,7 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, return response.ErrOrFallback(http.StatusInternalServerError, "failed to authorize access to rule group", err) } + //nolint:staticcheck // not yet migrated to OpenFeature if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingQueryOptimization) { if _, err := store.OptimizeAlertQueries(rule.Data); err != nil { return ErrResp(http.StatusInternalServerError, err, "Failed to optimize query") @@ -178,6 +179,7 @@ func (srv TestingApiSrv) RouteEvalQueries(c *contextmodel.ReqContext, cmd apimod } var optimizations []store.Optimization + //nolint:staticcheck // not yet migrated to OpenFeature if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingQueryOptimization) { var err error optimizations, err = store.OptimizeAlertQueries(cond.Data) @@ -223,6 +225,7 @@ func addOptimizedQueryWarnings(evalResults *backend.QueryDataResponse, optimizat } func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimodels.BacktestConfig) response.Response { + //nolint:staticcheck // not yet migrated to OpenFeature if !srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingBacktesting) { return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled") } diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index c835fb2acdd..082c1bfd333 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/url" "slices" "sort" @@ -26,6 +27,10 @@ import ( ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type RuleStoreReader interface { @@ -54,6 +59,9 @@ type PrometheusSrv struct { provenanceStore ProvenanceStore } +// Package-level OpenTelemetry tracer per Grafana instrumentation conventions. +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/ngalert/api/prometheus") + func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status StatusReader, store RuleStoreReader, authz RuleGroupAccessControlService, provenanceStore ProvenanceStore) *PrometheusSrv { return &PrometheusSrv{ log, @@ -219,6 +227,14 @@ func GetStatesFromQuery(v url.Values) (map[eval.State]struct{}, error) { return states, nil } +func MapStateSetToStrings(stateSet map[eval.State]struct{}) []string { + states := make([]string, 0, len(stateSet)) + for state := range stateSet { + states = append(states, state.String()) + } + return states +} + func GetHealthFromQuery(v url.Values) (map[string]struct{}, error) { health := make(map[string]struct{}) for _, s := range v["health"] { @@ -252,6 +268,13 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon // As we are using req.Form directly, this triggers a call to ParseForm() if needed. c.Query("") + ctx, span := tracer.Start(c.Req.Context(), "api.prometheus.RouteGetRuleStatuses") + defer span.End() + // Propagate the new context so child spans can attach to it. + c.Req = c.Req.WithContext(ctx) + orgID := c.GetOrgID() + span.SetAttributes(attribute.Int64("org_id", orgID)) + ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ Status: "success", @@ -261,13 +284,14 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon }, } - namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.GetOrgID(), c.SignedInUser) + namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), orgID, c.SignedInUser) if err != nil { ruleResponse.Status = "error" ruleResponse.Error = fmt.Sprintf("failed to get namespaces visible to the user: %s", err.Error()) ruleResponse.ErrorType = apiv1.ErrServer return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) } + span.AddEvent("User visible namespaces retrieved") allowedNamespaces := map[string]string{} for namespaceUID, folder := range namespaceMap { @@ -283,6 +307,8 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon allowedNamespaces[namespaceUID] = folder.Fullpath } } + span.AddEvent("User permissions checked") + span.SetAttributes(attribute.Int("allowedNamespaces", len(allowedNamespaces))) provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType()) if err != nil { @@ -297,7 +323,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon srv.store, RuleGroupStatusesOptions{ Ctx: c.Req.Context(), - OrgID: c.OrgID, + OrgID: orgID, Query: c.Req.Form, AllowedNamespaces: allowedNamespaces, }, @@ -404,7 +430,182 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler } } +// paginationContext holds limits and filters for filter-aware pagination +type paginationContext struct { + opts RuleGroupStatusesOptions + provenanceRecords map[string]ngmodels.Provenance + ruleStatusMutator RuleStatusMutator + alertStateMutator RuleAlertStateMutator + + // Query parameters + namespaceUIDs []string + ruleUIDs []string + dashboardUID string + panelID int64 + ruleGroups []string + receiverName string + title string + searchRuleGroup string + ruleType ngmodels.RuleTypeFilter + ruleNamesSet map[string]struct{} + + // Filters + stateFilterSet map[eval.State]struct{} + healthFilterSet map[string]struct{} + matchers labels.Matchers + labelOptions []ngmodels.LabelOption + limitAlertsPerRule int64 + limitRulesPerGroup int64 +} + +// pageResult is the result of fetching and filtering of one page +type pageResult struct { + groups []apimodels.RuleGroup + totalsDelta map[string]int64 + nextToken string + hasMore bool +} + +func accumulateTotals(dest, source map[string]int64) { + for k, v := range source { + dest[k] += v + } +} + +// fetchAndFilterPage fetches one page from the store and applies filters +func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlertRulesStoreV2, span trace.Span, token string, remainingGroups, remainingRules int64) (pageResult, error) { + byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ + OrgID: ctx.opts.OrgID, + NamespaceUIDs: ctx.namespaceUIDs, + RuleUIDs: ctx.ruleUIDs, + DashboardUID: ctx.dashboardUID, + PanelID: ctx.panelID, + RuleGroups: ctx.ruleGroups, + ReceiverName: ctx.receiverName, + SearchTitle: ctx.title, + SearchRuleGroup: ctx.searchRuleGroup, + }, + RuleType: ctx.ruleType, + Limit: remainingGroups, + RuleLimit: remainingRules, + ContinueToken: token, + } + + ruleList, newToken, err := store.ListAlertRulesByGroup(ctx.opts.Ctx, &byGroupQuery) + if err != nil { + return pageResult{}, err + } + + span.SetAttributes( + attribute.Int("store_rule_list_len", len(ruleList)), + attribute.Bool("store_continue_token_set", newToken != ""), + ) + span.AddEvent("Alert rules retrieved from store") + + groupedRules := getGroupedRules(log, ruleList, ctx.ruleNamesSet, ctx.opts.AllowedNamespaces) + + result := pageResult{ + groups: make([]apimodels.RuleGroup, 0, len(groupedRules)), + totalsDelta: make(map[string]int64), + nextToken: newToken, + hasMore: newToken != "", + } + + for _, rg := range groupedRules { + ruleGroup, totals := toRuleGroup( + log, rg.GroupKey, rg.Folder, rg.Rules, + ctx.provenanceRecords, ctx.limitAlertsPerRule, + ctx.stateFilterSet, ctx.matchers, ctx.labelOptions, + ctx.ruleStatusMutator, ctx.alertStateMutator, + ) + ruleGroup.Totals = totals + accumulateTotals(result.totalsDelta, totals) + + if len(ctx.stateFilterSet) > 0 { + filterRulesByState(ruleGroup, ctx.stateFilterSet) + } + + if len(ctx.healthFilterSet) > 0 { + filterRulesByHealth(ruleGroup, ctx.healthFilterSet) + } + + if ctx.limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > ctx.limitRulesPerGroup { + ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup] + } + + if len(ruleGroup.Rules) > 0 { + result.groups = append(result.groups, *ruleGroup) + } + } + + return result, nil +} + +// paginateRuleGroups fetches pages until limits are satisfied applying filters at each step +func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *paginationContext, span trace.Span, maxGroups, maxRules int64, startToken string) ([]apimodels.RuleGroup, map[string]int64, string, error) { + allGroups := []apimodels.RuleGroup{} + rulesTotals := make(map[string]int64) + + continueToken := startToken + groupsReturned := int64(0) + rulesReturned := int64(0) + + for { + remainingGroups := maxGroups + if maxGroups > 0 { + remainingGroups = maxGroups - groupsReturned + if remainingGroups <= 0 { + break + } + } + remainingRules := maxRules + if maxRules > 0 { + remainingRules = maxRules - rulesReturned + if remainingRules <= 0 { + break + } + } + + page, err := ctx.fetchAndFilterPage(log, store, span, continueToken, remainingGroups, remainingRules) + if err != nil { + return nil, nil, "", err + } + + accumulateTotals(rulesTotals, page.totalsDelta) + + // Add groups and check limits + for _, group := range page.groups { + allGroups = append(allGroups, group) + groupsReturned++ + rulesReturned += int64(len(group.Rules)) + + // Check if we've hit limits + if (maxGroups > 0 && groupsReturned == maxGroups) || (maxRules > 0 && rulesReturned >= maxRules) { + return allGroups, rulesTotals, page.nextToken, nil + } + } + + if !page.hasMore { + return allGroups, rulesTotals, "", nil + } + + if page.nextToken == continueToken { + log.Warn("Pagination loop detected same token, stopping", "token", page.nextToken) + return allGroups, rulesTotals, page.nextToken, nil + } + + continueToken = page.nextToken + } + + return allGroups, rulesTotals, continueToken, nil +} + func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { + ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2") + defer span.End() + opts.Ctx = ctx + ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ Status: "success", @@ -428,9 +629,17 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.ErrorType = apiv1.ErrBadData return ruleResponse } + span.SetAttributes( + attribute.String("dashboard_uid", dashboardUID), + attribute.Int64("panel_id", panelID), + ) limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1) limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1) + span.SetAttributes( + attribute.Int64("limit_rules", limitRulesPerGroup), + attribute.Int64("limit_alerts", limitAlertsPerRule), + ) matchers, err := getMatchersFromQuery(opts.Query) if err != nil { ruleResponse.Status = "error" @@ -438,6 +647,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.ErrorType = apiv1.ErrBadData return ruleResponse } + span.SetAttributes(attribute.Int("matcher_count", len(matchers))) + stateFilterSet, err := GetStatesFromQuery(opts.Query) if err != nil { ruleResponse.Status = "error" @@ -445,6 +656,10 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.ErrorType = apiv1.ErrBadData return ruleResponse } + span.SetAttributes( + attribute.Int("state_filter_count", len(stateFilterSet)), + attribute.StringSlice("state_filter", MapStateSetToStrings(stateFilterSet)), + ) healthFilterSet, err := GetHealthFromQuery(opts.Query) if err != nil { @@ -453,11 +668,18 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.ErrorType = apiv1.ErrBadData return ruleResponse } + span.SetAttributes( + attribute.Int("health_filter_count", len(healthFilterSet)), + attribute.StringSlice("health_filter", slices.Collect(maps.Keys(healthFilterSet))), + ) var labelOptions []ngmodels.LabelOption if !getBoolWithDefault(opts.Query, queryIncludeInternalLabels, false) { labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels()) } + span.SetAttributes( + attribute.Bool("include_internal_labels", len(labelOptions) == 0), + ) if len(opts.AllowedNamespaces) == 0 { log.Debug("User does not have access to any namespaces") @@ -476,19 +698,36 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt } } + span.SetAttributes( + attribute.Bool("folder_uid_set", folderUID != ""), + attribute.Int("namespace_count", len(namespaceUIDs)), + ) + ruleGroups := opts.Query["rule_group"] ruleUIDs := opts.Query["rule_uid"] + span.SetAttributes( + attribute.Int("rule_group_count", len(ruleGroups)), + attribute.Int("rule_uid_count", len(ruleUIDs)), + ) + receiverName := opts.Query.Get("receiver_name") + span.SetAttributes(attribute.Bool("receiver_name_set", receiverName != "")) + title := opts.Query.Get("search.rule_name") + span.SetAttributes(attribute.Bool("search_rule_name_set", title != "")) + searchRuleGroup := opts.Query.Get("search.rule_group") + span.SetAttributes(attribute.Bool("search_rule_group_set", searchRuleGroup != "")) var ruleType ngmodels.RuleTypeFilter switch ngmodels.RuleType(opts.Query.Get("rule_type")) { case ngmodels.RuleTypeAlerting: ruleType = ngmodels.RuleTypeFilterAlerting + span.SetAttributes(attribute.Bool("alerting_only", true)) case ngmodels.RuleTypeRecording: ruleType = ngmodels.RuleTypeFilterRecording + span.SetAttributes(attribute.Bool("recording_only", true)) default: ruleType = ngmodels.RuleTypeFilterAll } @@ -507,68 +746,55 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) maxRules := getInt64WithDefault(opts.Query, "rule_limit", -1) nextToken := opts.Query.Get("group_next_token") + span.SetAttributes( + attribute.Int64("group_limit", maxGroups), + attribute.Int64("rule_limit", maxRules), + attribute.Bool("group_next_token_set", nextToken != ""), + ) if maxGroups == 0 || maxRules == 0 { return ruleResponse } - byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{ - ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ - OrgID: opts.OrgID, - NamespaceUIDs: namespaceUIDs, - RuleUIDs: ruleUIDs, - DashboardUID: dashboardUID, - PanelID: panelID, - RuleGroups: ruleGroups, - ReceiverName: receiverName, - SearchTitle: title, - SearchRuleGroup: searchRuleGroup, - }, - RuleType: ruleType, - Limit: maxGroups, - RuleLimit: maxRules, - ContinueToken: nextToken, - } - ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery) - if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = fmt.Sprintf("failure getting rules: %s", err.Error()) - ruleResponse.ErrorType = apiv1.ErrServer - return ruleResponse - } - ruleNames := opts.Query["rule_name"] ruleNamesSet := make(map[string]struct{}, len(ruleNames)) for _, rn := range ruleNames { ruleNamesSet[rn] = struct{}{} } + span.SetAttributes(attribute.Int("rule_name_count", len(ruleNamesSet))) - groupedRules := getGroupedRules(log, ruleList, ruleNamesSet, opts.AllowedNamespaces) - rulesTotals := make(map[string]int64, len(groupedRules)) - for _, rg := range groupedRules { - ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator) - ruleGroup.Totals = totals - for k, v := range totals { - rulesTotals[k] += v - } - - if len(stateFilterSet) > 0 { - filterRulesByState(ruleGroup, stateFilterSet) - } - - if len(healthFilterSet) > 0 { - filterRulesByHealth(ruleGroup, healthFilterSet) - } - - if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup { - ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup] - } - - if len(ruleGroup.Rules) > 0 { - ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, *ruleGroup) - } + pagCtx := &paginationContext{ + opts: opts, + provenanceRecords: provenanceRecords, + ruleStatusMutator: ruleStatusMutator, + alertStateMutator: alertStateMutator, + namespaceUIDs: namespaceUIDs, + ruleUIDs: ruleUIDs, + dashboardUID: dashboardUID, + panelID: panelID, + ruleGroups: ruleGroups, + receiverName: receiverName, + title: title, + searchRuleGroup: searchRuleGroup, + ruleType: ruleType, + ruleNamesSet: ruleNamesSet, + stateFilterSet: stateFilterSet, + healthFilterSet: healthFilterSet, + matchers: matchers, + labelOptions: labelOptions, + limitAlertsPerRule: limitAlertsPerRule, + limitRulesPerGroup: limitRulesPerGroup, } + groups, rulesTotals, continueToken, err := paginateRuleGroups(log, store, pagCtx, span, maxGroups, maxRules, nextToken) + if err != nil { + ruleResponse.Status = "error" + ruleResponse.Error = fmt.Sprintf("failure getting rules: %s", err.Error()) + ruleResponse.ErrorType = apiv1.ErrServer + return ruleResponse + } + + ruleResponse.Data.RuleGroups = groups ruleResponse.Data.NextToken = continueToken // Only return Totals if there is no pagination @@ -903,6 +1129,7 @@ func toRuleGroup(log log.Logger, groupKey ngmodels.AlertRuleGroupKey, folderFull // mutate rule for alert states totals, totalsFiltered := ruleAlertStateMutator(rule, &alertingRule, stateFilterSet, matchers, labelOptions) + if alertingRule.State != "" { rulesTotals[alertingRule.State] += 1 } diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 299a511c35e..53e49621117 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -194,8 +194,11 @@ func (ng *AlertNG) init() error { var opts []notifier.Option moaLogger := log.New("ngalert.multiorg.alertmanager") crypto := notifier.NewCrypto(ng.SecretsService, ng.store, moaLogger) + //nolint:staticcheck // not yet migrated to OpenFeature remotePrimary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemotePrimary) + //nolint:staticcheck // not yet migrated to OpenFeature remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary) + //nolint:staticcheck // not yet migrated to OpenFeature remoteSecondaryWithRemoteState := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondaryWithRemoteState) if remotePrimary || remoteSecondary || remoteSecondaryWithRemoteState { m := ng.Metrics.GetRemoteAlertmanagerMetrics() @@ -717,6 +720,7 @@ func configureNotificationHistorian( l log.Logger, tracer tracing.Tracer, ) (nfstatus.NotificationHistorian, error) { + //nolint:staticcheck // not yet migrated to OpenFeature if !featureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingNotificationHistory) || !cfg.Enabled { met.Info.Set(0) return nil, nil diff --git a/pkg/services/ngalert/sender/notifier_test.go b/pkg/services/ngalert/sender/notifier_test.go index e7e9aedec96..a8b452437c1 100644 --- a/pkg/services/ngalert/sender/notifier_test.go +++ b/pkg/services/ngalert/sender/notifier_test.go @@ -28,6 +28,7 @@ import ( "net/http/httptest" "net/url" "strconv" + "strings" "testing" "time" @@ -38,7 +39,7 @@ import ( "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "go.uber.org/atomic" - "go.yaml.in/yaml/v2" + "go.yaml.in/yaml/v3" "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/discovery" @@ -660,11 +661,10 @@ alerting: alertmanagers: - static_configs: ` - err := yaml.UnmarshalStrict([]byte(s), cfg) - require.NoError(t, err, "Unable to load YAML config.") + mustStrictlyDecodeConfig(t, strings.NewReader(s), cfg) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 1) - err = n.ApplyConfig(cfg, nil) + err := n.ApplyConfig(cfg, nil) require.NoError(t, err, "Error applying the config.") tgs := make(map[string][]*targetgroup.Group) @@ -711,11 +711,10 @@ alerting: regex: 'alertmanager:9093' action: drop ` - err := yaml.UnmarshalStrict([]byte(s), cfg) - require.NoError(t, err, "Unable to load YAML config.") + mustStrictlyDecodeConfig(t, strings.NewReader(s), cfg) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 1) - err = n.ApplyConfig(cfg, nil) + err := n.ApplyConfig(cfg, nil) require.NoError(t, err, "Error applying the config.") tgs := make(map[string][]*targetgroup.Group) @@ -1091,7 +1090,7 @@ alerting: - foo.json ` // 1. Ensure known alertmanagers are not dropped during ApplyConfig. - require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) + mustStrictlyDecodeConfig(t, strings.NewReader(s), cfg) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 1) // First, apply the config and reload. @@ -1117,7 +1116,7 @@ alerting: - files: - foo.json ` - require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) + mustStrictlyDecodeConfig(t, strings.NewReader(s), cfg) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 2) require.NoError(t, n.ApplyConfig(cfg, nil)) @@ -1140,7 +1139,7 @@ alerting: - files: - foo.json ` - require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) + mustStrictlyDecodeConfig(t, strings.NewReader(s), cfg) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 2) require.NoError(t, n.ApplyConfig(cfg, nil)) @@ -1167,9 +1166,22 @@ alerting: regex: 'doesntmatter:1234' action: drop ` - require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) + mustStrictlyDecodeConfig(t, strings.NewReader(s), cfg) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 2) require.NoError(t, n.ApplyConfig(cfg, nil)) require.Empty(t, n.Alertmanagers()) } + +// Maintain strict yaml decode behavior from v2: https://github.com/go-yaml/yaml/issues/639#issuecomment-666935833 +func mustStrictlyDecodeConfig(t testing.TB, r io.Reader, cfg *config.Config) { + t.Helper() + + dec := yaml.NewDecoder(r) + dec.KnownFields(true) + + err := dec.Decode(cfg) + if err != nil { + require.Equal(t, io.EOF, err, "Unable to load YAML config.") + } +} diff --git a/pkg/services/ngalert/sender/router.go b/pkg/services/ngalert/sender/router.go index ea2648feac3..7f1f8f3a897 100644 --- a/pkg/services/ngalert/sender/router.go +++ b/pkg/services/ngalert/sender/router.go @@ -89,6 +89,7 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase(ctx context.Context) error d.logger.Debug("Attempting to sync admin configs", "count", len(cfgs)) + //nolint:staticcheck // not yet migrated to OpenFeature disableExternal := d.featureManager.IsEnabled(ctx, featuremgmt.FlagAlertingDisableSendAlertsExternal) orgsFound := make(map[int64]struct{}, len(cfgs)) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index b05ba01a562..076974ea9c9 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -1097,6 +1097,7 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel continue } } + //nolint:staticcheck // not yet migrated to OpenFeature if st.FeatureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingQueryOptimization) { if optimizations, err := OptimizeAlertQueries(converted.Data); err != nil { st.Logger.Error("Could not migrate rule from range to instant query", "rule", rule.UID, "err", err) diff --git a/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware.go index 5c93e1da565..1321c1fc474 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware.go @@ -6,21 +6,26 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/setting" ) // NewClearAuthHeadersMiddleware creates a new backend.HandlerMiddleware // that will clear any outgoing HTTP headers that was part of the incoming // HTTP request and used when authenticating to Grafana. -func NewClearAuthHeadersMiddleware() backend.HandlerMiddleware { +func NewClearAuthHeadersMiddleware(cfgJWTAuth *setting.AuthJWTSettings, cfgAuthProxy *setting.AuthProxySettings) backend.HandlerMiddleware { return backend.HandlerMiddlewareFunc(func(next backend.Handler) backend.Handler { return &ClearAuthHeadersMiddleware{ - BaseHandler: backend.NewBaseHandler(next), + BaseHandler: backend.NewBaseHandler(next), + cfgJWTAuth: cfgJWTAuth, + cfgAuthProxy: cfgAuthProxy, } }) } type ClearAuthHeadersMiddleware struct { backend.BaseHandler + cfgJWTAuth *setting.AuthJWTSettings + cfgAuthProxy *setting.AuthProxySettings } func (m *ClearAuthHeadersMiddleware) clearHeaders(ctx context.Context, h backend.ForwardHTTPHeaders) { @@ -30,11 +35,9 @@ func (m *ClearAuthHeadersMiddleware) clearHeaders(ctx context.Context, h backend return } - list := contexthandler.AuthHTTPHeaderListFromContext(ctx) - if list != nil { - for _, k := range list.Items { - h.DeleteHTTPHeader(k) - } + items := contexthandler.GetAuthHTTPHeaders(m.cfgJWTAuth, m.cfgAuthProxy) + for _, k := range items { + h.DeleteHTTPHeader(k) } } diff --git a/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware_test.go index 5ca047881e3..aa9bc3aa469 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware_test.go +++ b/pkg/services/pluginsintegration/clientmiddleware/clear_auth_headers_middleware_test.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/handlertest" - "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -16,397 +15,227 @@ import ( func TestClearAuthHeadersMiddleware(t *testing.T) { const otherHeader = "test" - t.Run("When no auth headers in reqContext", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/some/thing", nil) - require.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, "/some/thing", nil) + require.NoError(t, err) - req.Header.Set(otherHeader, "test") + t.Run("When requests are for a datasource", func(t *testing.T) { + cfg := setting.NewCfg() + cdt := handlertest.NewHandlerMiddlewareTest(t, + WithReqContext(req, &user.SignedInUser{}), + handlertest.WithMiddlewares(NewClearAuthHeadersMiddleware(&cfg.JWTAuth, &cfg.AuthProxy)), + ) - t.Run("And requests are for a datasource", func(t *testing.T) { - cdt := handlertest.NewHandlerMiddlewareTest(t, - WithReqContext(req, &user.SignedInUser{}), - handlertest.WithMiddlewares(NewClearAuthHeadersMiddleware()), - ) + pluginCtx := backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + } - pluginCtx := backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, - } - - t.Run("No auth headers to clear when calling QueryData", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.QueryDataReq) - require.Len(t, cdt.QueryDataReq.Headers, 1) - require.Empty(t, cdt.QueryDataReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling CallResource", func(t *testing.T) { - err = cdt.MiddlewareHandler.CallResource(req.Context(), &backend.CallResourceRequest{ - PluginContext: pluginCtx, - Headers: map[string][]string{otherHeader: {"test"}}, - }, nopCallResourceSender) - require.NoError(t, err) - require.NotNil(t, cdt.CallResourceReq) - require.Len(t, cdt.CallResourceReq.Headers, 1) - require.Equal(t, http.Header{http.CanonicalHeaderKey(otherHeader): {"test"}}, cdt.CallResourceReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling CheckHealth", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.CheckHealth(req.Context(), &backend.CheckHealthRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.CheckHealthReq) - require.Len(t, cdt.CheckHealthReq.Headers, 1) - require.Empty(t, cdt.CheckHealthReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling SubscribeStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.SubscribeStream(req.Context(), &backend.SubscribeStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.SubscribeStreamReq) - require.Len(t, cdt.SubscribeStreamReq.Headers, 1) - require.Empty(t, cdt.SubscribeStreamReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling PublishStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.PublishStream(req.Context(), &backend.PublishStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.PublishStreamReq) - require.Len(t, cdt.PublishStreamReq.Headers, 1) - require.Empty(t, cdt.PublishStreamReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling RunStream", func(t *testing.T) { - err = cdt.MiddlewareHandler.RunStream(req.Context(), &backend.RunStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }, &backend.StreamSender{}) - require.NoError(t, err) - require.NotNil(t, cdt.RunStreamReq) - require.Len(t, cdt.RunStreamReq.Headers, 1) - require.Empty(t, cdt.RunStreamReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling QueryData", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.QueryDataReq) + require.Len(t, cdt.QueryDataReq.Headers, 1) + require.Equal(t, "test", cdt.QueryDataReq.Headers[otherHeader]) + require.Empty(t, cdt.QueryDataReq.GetHTTPHeaders()) }) - t.Run("And requests are for an app", func(t *testing.T) { - cdt := handlertest.NewHandlerMiddlewareTest(t, - WithReqContext(req, &user.SignedInUser{}), - handlertest.WithMiddlewares(NewClearAuthHeadersMiddleware()), - ) + t.Run("Should clear auth headers when calling CallResource", func(t *testing.T) { + err = cdt.MiddlewareHandler.CallResource(req.Context(), &backend.CallResourceRequest{ + PluginContext: pluginCtx, + Headers: map[string][]string{ + otherHeader: {"test"}, + "Authorization": {"secret"}, + "X-Grafana-Device-Id": {"secret"}, + }, + }, nopCallResourceSender) + require.NoError(t, err) + require.NotNil(t, cdt.CallResourceReq) + require.Len(t, cdt.CallResourceReq.Headers, 1) + require.Equal(t, []string{"test"}, cdt.CallResourceReq.Headers[otherHeader]) + require.Equal(t, "test", cdt.CallResourceReq.GetHTTPHeader(otherHeader)) + }) - pluginCtx := backend.PluginContext{ - AppInstanceSettings: &backend.AppInstanceSettings{}, - } - - t.Run("No auth headers to clear when calling QueryData", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.QueryDataReq) - require.Len(t, cdt.QueryDataReq.Headers, 1) - require.Equal(t, "test", cdt.QueryDataReq.Headers[otherHeader]) - require.Empty(t, cdt.QueryDataReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling CheckHealth", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.CheckHealth(req.Context(), &backend.CheckHealthRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.CheckHealthReq) + require.Len(t, cdt.CheckHealthReq.Headers, 1) + require.Equal(t, "test", cdt.CheckHealthReq.Headers[otherHeader]) + require.Empty(t, cdt.CheckHealthReq.GetHTTPHeaders()) + }) - t.Run("No auth headers to clear when calling CallResource", func(t *testing.T) { - err = cdt.MiddlewareHandler.CallResource(req.Context(), &backend.CallResourceRequest{ - PluginContext: pluginCtx, - Headers: map[string][]string{otherHeader: {"test"}}, - }, nopCallResourceSender) - require.NoError(t, err) - require.NotNil(t, cdt.CallResourceReq) - require.Len(t, cdt.CallResourceReq.Headers, 1) - require.Equal(t, []string{"test"}, cdt.CallResourceReq.Headers[otherHeader]) - require.Equal(t, http.Header{http.CanonicalHeaderKey(otherHeader): {"test"}}, cdt.CallResourceReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling SubscribeStream", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.SubscribeStream(req.Context(), &backend.SubscribeStreamRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.SubscribeStreamReq) + require.Len(t, cdt.SubscribeStreamReq.Headers, 1) + require.Equal(t, "test", cdt.SubscribeStreamReq.Headers[otherHeader]) + require.Empty(t, cdt.SubscribeStreamReq.GetHTTPHeaders()) + }) - t.Run("No auth headers to clear when calling CheckHealth", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.CheckHealth(req.Context(), &backend.CheckHealthRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.CheckHealthReq) - require.Len(t, cdt.CheckHealthReq.Headers, 1) - require.Equal(t, "test", cdt.CheckHealthReq.Headers[otherHeader]) - require.Empty(t, cdt.CheckHealthReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling PublishStream", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.PublishStream(req.Context(), &backend.PublishStreamRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.PublishStreamReq) + require.Len(t, cdt.PublishStreamReq.Headers, 1) + require.Equal(t, "test", cdt.PublishStreamReq.Headers[otherHeader]) + require.Empty(t, cdt.PublishStreamReq.GetHTTPHeaders()) + }) - t.Run("No auth headers to clear when calling SubscribeStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.SubscribeStream(req.Context(), &backend.SubscribeStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.SubscribeStreamReq) - require.Len(t, cdt.SubscribeStreamReq.Headers, 1) - require.Equal(t, "test", cdt.SubscribeStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.SubscribeStreamReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling PublishStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.PublishStream(req.Context(), &backend.PublishStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }) - require.NoError(t, err) - require.NotNil(t, cdt.PublishStreamReq) - require.Len(t, cdt.PublishStreamReq.Headers, 1) - require.Equal(t, "test", cdt.PublishStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.PublishStreamReq.GetHTTPHeaders()) - }) - - t.Run("No auth headers to clear when calling RunStream", func(t *testing.T) { - err = cdt.MiddlewareHandler.RunStream(req.Context(), &backend.RunStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{otherHeader: "test"}, - }, &backend.StreamSender{}) - require.NoError(t, err) - require.NotNil(t, cdt.RunStreamReq) - require.Len(t, cdt.RunStreamReq.Headers, 1) - require.Equal(t, "test", cdt.RunStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.RunStreamReq.GetHTTPHeaders()) - }) + t.Run("Should clear auth headers when calling RunStream", func(t *testing.T) { + err = cdt.MiddlewareHandler.RunStream(req.Context(), &backend.RunStreamRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, + }, &backend.StreamSender{}) + require.NoError(t, err) + require.NotNil(t, cdt.RunStreamReq) + require.Len(t, cdt.RunStreamReq.Headers, 1) + require.Equal(t, "test", cdt.RunStreamReq.Headers[otherHeader]) + require.Empty(t, cdt.RunStreamReq.GetHTTPHeaders()) }) }) - t.Run("When auth headers in reqContext", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/some/thing", nil) - require.NoError(t, err) + t.Run("When requests are for an app", func(t *testing.T) { + cfg := setting.NewCfg() + cdt := handlertest.NewHandlerMiddlewareTest(t, + WithReqContext(req, &user.SignedInUser{}), + handlertest.WithMiddlewares(NewClearAuthHeadersMiddleware(&cfg.JWTAuth, &cfg.AuthProxy)), + ) - t.Run("And requests are for a datasource", func(t *testing.T) { - cdt := handlertest.NewHandlerMiddlewareTest(t, - WithReqContext(req, &user.SignedInUser{}), - handlertest.WithMiddlewares(NewClearAuthHeadersMiddleware()), - ) + req.Header.Set("Authorization", "val") - req := req.WithContext(contexthandler.WithAuthHTTPHeaders(req.Context(), setting.NewCfg())) + const otherHeader = "x-Other" + req.Header.Set(otherHeader, "test") - pluginCtx := backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, - } + pluginCtx := backend.PluginContext{ + AppInstanceSettings: &backend.AppInstanceSettings{}, + } - t.Run("Should clear auth headers when calling QueryData", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.QueryDataReq) - require.Len(t, cdt.QueryDataReq.Headers, 1) - require.Equal(t, "test", cdt.QueryDataReq.Headers[otherHeader]) - require.Empty(t, cdt.QueryDataReq.GetHTTPHeaders()) - }) - - t.Run("Should clear auth headers when calling CallResource", func(t *testing.T) { - err = cdt.MiddlewareHandler.CallResource(req.Context(), &backend.CallResourceRequest{ - PluginContext: pluginCtx, - Headers: map[string][]string{ - otherHeader: {"test"}, - "Authorization": {"secret"}, - "X-Grafana-Device-Id": {"secret"}, - }, - }, nopCallResourceSender) - require.NoError(t, err) - require.NotNil(t, cdt.CallResourceReq) - require.Len(t, cdt.CallResourceReq.Headers, 1) - require.Equal(t, []string{"test"}, cdt.CallResourceReq.Headers[otherHeader]) - require.Equal(t, "test", cdt.CallResourceReq.GetHTTPHeader(otherHeader)) - }) - - t.Run("Should clear auth headers when calling CheckHealth", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.CheckHealth(req.Context(), &backend.CheckHealthRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.CheckHealthReq) - require.Len(t, cdt.CheckHealthReq.Headers, 1) - require.Equal(t, "test", cdt.CheckHealthReq.Headers[otherHeader]) - require.Empty(t, cdt.CheckHealthReq.GetHTTPHeaders()) - }) - - t.Run("Should clear auth headers when calling SubscribeStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.SubscribeStream(req.Context(), &backend.SubscribeStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.SubscribeStreamReq) - require.Len(t, cdt.SubscribeStreamReq.Headers, 1) - require.Equal(t, "test", cdt.SubscribeStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.SubscribeStreamReq.GetHTTPHeaders()) - }) - - t.Run("Should clear auth headers when calling PublishStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.PublishStream(req.Context(), &backend.PublishStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.PublishStreamReq) - require.Len(t, cdt.PublishStreamReq.Headers, 1) - require.Equal(t, "test", cdt.PublishStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.PublishStreamReq.GetHTTPHeaders()) - }) - - t.Run("Should clear auth headers when calling RunStream", func(t *testing.T) { - err = cdt.MiddlewareHandler.RunStream(req.Context(), &backend.RunStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }, &backend.StreamSender{}) - require.NoError(t, err) - require.NotNil(t, cdt.RunStreamReq) - require.Len(t, cdt.RunStreamReq.Headers, 1) - require.Equal(t, "test", cdt.RunStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.RunStreamReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling QueryData", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.QueryDataReq) + require.Len(t, cdt.QueryDataReq.Headers, 1) + require.Equal(t, "test", cdt.QueryDataReq.Headers[otherHeader]) + require.Empty(t, cdt.QueryDataReq.GetHTTPHeaders()) }) - t.Run("And requests are for an app", func(t *testing.T) { - cdt := handlertest.NewHandlerMiddlewareTest(t, - WithReqContext(req, &user.SignedInUser{}), - handlertest.WithMiddlewares(NewClearAuthHeadersMiddleware()), - ) + t.Run("Should clear auth headers when calling CallResource", func(t *testing.T) { + err = cdt.MiddlewareHandler.CallResource(req.Context(), &backend.CallResourceRequest{ + PluginContext: pluginCtx, + Headers: map[string][]string{ + otherHeader: {"test"}, + "Authorization": {"secret"}, + "X-Grafana-Device-Id": {"secret"}, + }, + }, nopCallResourceSender) + require.NoError(t, err) + require.NotNil(t, cdt.CallResourceReq) + require.Len(t, cdt.CallResourceReq.Headers, 1) + require.Equal(t, []string{"test"}, cdt.CallResourceReq.Headers[otherHeader]) + require.Equal(t, "test", cdt.CallResourceReq.GetHTTPHeader(otherHeader)) + }) - req := req.WithContext(contexthandler.WithAuthHTTPHeaders(req.Context(), setting.NewCfg())) - req.Header.Set("Authorization", "val") - - const otherHeader = "x-Other" - req.Header.Set(otherHeader, "test") - - pluginCtx := backend.PluginContext{ - AppInstanceSettings: &backend.AppInstanceSettings{}, - } - - t.Run("Should clear auth headers when calling QueryData", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.QueryDataReq) - require.Len(t, cdt.QueryDataReq.Headers, 1) - require.Equal(t, "test", cdt.QueryDataReq.Headers[otherHeader]) - require.Empty(t, cdt.QueryDataReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling CheckHealth", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.CheckHealth(req.Context(), &backend.CheckHealthRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.CheckHealthReq) + require.Len(t, cdt.CheckHealthReq.Headers, 1) + require.Equal(t, "test", cdt.CheckHealthReq.Headers[otherHeader]) + require.Empty(t, cdt.CheckHealthReq.GetHTTPHeaders()) + }) - t.Run("Should clear auth headers when calling CallResource", func(t *testing.T) { - err = cdt.MiddlewareHandler.CallResource(req.Context(), &backend.CallResourceRequest{ - PluginContext: pluginCtx, - Headers: map[string][]string{ - otherHeader: {"test"}, - "Authorization": {"secret"}, - "X-Grafana-Device-Id": {"secret"}, - }, - }, nopCallResourceSender) - require.NoError(t, err) - require.NotNil(t, cdt.CallResourceReq) - require.Len(t, cdt.CallResourceReq.Headers, 1) - require.Equal(t, []string{"test"}, cdt.CallResourceReq.Headers[otherHeader]) - require.Equal(t, "test", cdt.CallResourceReq.GetHTTPHeader(otherHeader)) + t.Run("Should clear auth headers when calling SubscribeStream", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.SubscribeStream(req.Context(), &backend.SubscribeStreamRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.SubscribeStreamReq) + require.Len(t, cdt.SubscribeStreamReq.Headers, 1) + require.Equal(t, "test", cdt.SubscribeStreamReq.Headers[otherHeader]) + require.Empty(t, cdt.SubscribeStreamReq.GetHTTPHeaders()) + }) - t.Run("Should clear auth headers when calling CheckHealth", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.CheckHealth(req.Context(), &backend.CheckHealthRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.CheckHealthReq) - require.Len(t, cdt.CheckHealthReq.Headers, 1) - require.Equal(t, "test", cdt.CheckHealthReq.Headers[otherHeader]) - require.Empty(t, cdt.CheckHealthReq.GetHTTPHeaders()) + t.Run("Should clear auth headers when calling PublishStream", func(t *testing.T) { + _, err = cdt.MiddlewareHandler.PublishStream(req.Context(), &backend.PublishStreamRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, }) + require.NoError(t, err) + require.NotNil(t, cdt.PublishStreamReq) + require.Len(t, cdt.PublishStreamReq.Headers, 1) + require.Equal(t, "test", cdt.PublishStreamReq.Headers[otherHeader]) + require.Empty(t, cdt.PublishStreamReq.GetHTTPHeaders()) + }) - t.Run("Should clear auth headers when calling SubscribeStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.SubscribeStream(req.Context(), &backend.SubscribeStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.SubscribeStreamReq) - require.Len(t, cdt.SubscribeStreamReq.Headers, 1) - require.Equal(t, "test", cdt.SubscribeStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.SubscribeStreamReq.GetHTTPHeaders()) - }) - - t.Run("Should clear auth headers when calling PublishStream", func(t *testing.T) { - _, err = cdt.MiddlewareHandler.PublishStream(req.Context(), &backend.PublishStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }) - require.NoError(t, err) - require.NotNil(t, cdt.PublishStreamReq) - require.Len(t, cdt.PublishStreamReq.Headers, 1) - require.Equal(t, "test", cdt.PublishStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.PublishStreamReq.GetHTTPHeaders()) - }) - - t.Run("Should clear auth headers when calling RunStream", func(t *testing.T) { - err = cdt.MiddlewareHandler.RunStream(req.Context(), &backend.RunStreamRequest{ - PluginContext: pluginCtx, - Headers: map[string]string{ - otherHeader: "test", - "Authorization": "secret", - "X-Grafana-Device-Id": "secret", - }, - }, &backend.StreamSender{}) - require.NoError(t, err) - require.NotNil(t, cdt.RunStreamReq) - require.Len(t, cdt.RunStreamReq.Headers, 1) - require.Equal(t, "test", cdt.RunStreamReq.Headers[otherHeader]) - require.Empty(t, cdt.RunStreamReq.GetHTTPHeaders()) - }) + t.Run("Should clear auth headers when calling RunStream", func(t *testing.T) { + err = cdt.MiddlewareHandler.RunStream(req.Context(), &backend.RunStreamRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{ + otherHeader: "test", + "Authorization": "secret", + "X-Grafana-Device-Id": "secret", + }, + }, &backend.StreamSender{}) + require.NoError(t, err) + require.NotNil(t, cdt.RunStreamReq) + require.Len(t, cdt.RunStreamReq.Headers, 1) + require.Equal(t, "test", cdt.RunStreamReq.Headers[otherHeader]) + require.Empty(t, cdt.RunStreamReq.GetHTTPHeaders()) }) }) } diff --git a/pkg/services/pluginsintegration/installsync/syncer.go b/pkg/services/pluginsintegration/installsync/syncer.go index 1f056a6e85b..8639834610f 100644 --- a/pkg/services/pluginsintegration/installsync/syncer.go +++ b/pkg/services/pluginsintegration/installsync/syncer.go @@ -87,10 +87,12 @@ func ProvideSyncer( } func (s *syncer) Sync(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error { + //nolint:staticcheck // not yet migrated to OpenFeature if !s.featureToggles.IsEnabled(ctx, featuremgmt.FlagPluginInstallAPISync) { return nil } + //nolint:staticcheck // not yet migrated to OpenFeature if !s.featureToggles.IsEnabled(ctx, featuremgmt.FlagPluginStoreServiceLoading) { logging.DefaultLogger.Warn("pluginInstallAPISync is enabled, but pluginStoreServiceLoading is disabled. skipping plugin sync.") return nil diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index a510ead1910..9c0cf4d10c6 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -200,7 +200,7 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken middlewares = append(middlewares, clientmiddleware.NewTracingHeaderMiddleware(), - clientmiddleware.NewClearAuthHeadersMiddleware(), + clientmiddleware.NewClearAuthHeadersMiddleware(&cfg.JWTAuth, &cfg.AuthProxy), clientmiddleware.NewOAuthTokenMiddleware(oAuthTokenService), clientmiddleware.NewCookiesMiddleware(skipCookiesNames), clientmiddleware.NewCachingMiddleware(cachingServiceClient), diff --git a/pkg/services/pluginsintegration/plugintest/plugins_test.go b/pkg/services/pluginsintegration/plugintest/plugins_test.go index 25fff53a5fb..df4a0fd7b75 100644 --- a/pkg/services/pluginsintegration/plugintest/plugins_test.go +++ b/pkg/services/pluginsintegration/plugintest/plugins_test.go @@ -161,7 +161,7 @@ func TestIntegrationPluginManager(t *testing.T) { pr := prometheus.ProvideService(hcp) tmpo := tempo.ProvideService(hcp, tracer) td := testdatasource.ProvideService() - pg := postgres.ProvideService(cfg) + pg := postgres.ProvideService() my := mysql.ProvideService() ms := mssql.ProvideService(cfg) db := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: cfg}) diff --git a/pkg/services/preference/prefapi/api.go b/pkg/services/preference/prefapi/api.go index 1375bb27f28..6bf8057ac68 100644 --- a/pkg/services/preference/prefapi/api.go +++ b/pkg/services/preference/prefapi/api.go @@ -61,6 +61,7 @@ func UpdatePreferencesFor(ctx context.Context, Navbar: dtoCmd.Navbar, } + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(ctx, featuremgmt.FlagLocaleFormatPreference) { saveCmd.RegionalFormat = dtoCmd.RegionalFormat } @@ -101,6 +102,7 @@ func GetPreferencesFor(ctx context.Context, dto.Language = &preference.JSONData.Language } + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabled(ctx, featuremgmt.FlagLocaleFormatPreference) { if preference.JSONData.RegionalFormat != "" { dto.RegionalFormat = &preference.JSONData.RegionalFormat diff --git a/pkg/services/promtypemigration/migrator.go b/pkg/services/promtypemigration/migrator.go index abdc80fc7e7..2bccc6ea822 100644 --- a/pkg/services/promtypemigration/migrator.go +++ b/pkg/services/promtypemigration/migrator.go @@ -43,6 +43,7 @@ func ProvidePromTypeMigrationProvider( } func (s *PromTypeMigrationProviderImpl) Run(ctx context.Context) error { + //nolint:staticcheck // not yet migrated to OpenFeature if !s.features.IsEnabled(ctx, featuremgmt.FlagPrometheusTypeMigration) { return nil } diff --git a/pkg/services/provisioning/stubs.go b/pkg/services/provisioning/stubs.go index b40e66aac5c..8e8b2078fbc 100644 --- a/pkg/services/provisioning/stubs.go +++ b/pkg/services/provisioning/stubs.go @@ -19,15 +19,19 @@ func ProvideStubProvisioningService(cfg *setting.Cfg) (StubProvisioningService, } func NewStubProvisioning(path string) (StubProvisioningService, error) { - cfgs, err := dashboards.ReadDashboardConfig(filepath.Join(path, "dashboards")) - if err != nil { - return nil, err - } + logger := log.New("provisioning.stub") stub := &stubProvisioning{ path: make(map[string]string), allowUIUpdates: make(map[string]bool), - log: log.New("provisioning.stub"), + log: logger, } + + cfgs, err := dashboards.ReadDashboardConfig(filepath.Join(path, "dashboards")) + if err != nil { + logger.Warn("can't read dashboard provisioning files from directory", "path", filepath.Join(path, "dashboards"), "error", err) + return stub, nil + } + for _, cfg := range cfgs { stub.path[cfg.Name] = cfg.Options["path"].(string) stub.allowUIUpdates[cfg.Name] = cfg.AllowUIUpdates diff --git a/pkg/services/provisioning/stubs_test.go b/pkg/services/provisioning/stubs_test.go new file mode 100644 index 00000000000..374434d9f59 --- /dev/null +++ b/pkg/services/provisioning/stubs_test.go @@ -0,0 +1,449 @@ +package provisioning + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/setting" +) + +func TestNewStubProvisioning(t *testing.T) { + t.Run("should handle non-existent directory gracefully", func(t *testing.T) { + // This tests the bug fix - should not fail when directory doesn't exist + stub, err := NewStubProvisioning("/non-existent-path") + + require.NoError(t, err, "should not return error for non-existent directory") + require.NotNil(t, stub, "should return valid stub") + + // Should return resolved empty path for non-existent configs + // Note: filepath.Abs("") returns current directory + path := stub.GetDashboardProvisionerResolvedPath("any-name") + assert.NotEmpty(t, path, "returns resolved empty path") + + assert.False(t, stub.GetAllowUIUpdatesFromConfig("any-name")) + }) + + t.Run("should handle empty directory", func(t *testing.T) { + testPath := "./testdata/stub-configs-empty" + stub, err := NewStubProvisioning(testPath) + + require.NoError(t, err) + require.NotNil(t, stub) + + // Empty directory should result in empty maps + // Note: filepath.Abs("") returns current directory + path := stub.GetDashboardProvisionerResolvedPath("any-name") + assert.NotEmpty(t, path, "returns resolved empty path") + + assert.False(t, stub.GetAllowUIUpdatesFromConfig("any-name")) + }) + + t.Run("should successfully read single config", func(t *testing.T) { + testPath := "./testdata/stub-configs" + stub, err := NewStubProvisioning(testPath) + + require.NoError(t, err) + require.NotNil(t, stub) + + // Should have the test-dashboard config + path := stub.GetDashboardProvisionerResolvedPath("test-dashboard") + assert.NotEmpty(t, path) + + allowUpdates := stub.GetAllowUIUpdatesFromConfig("test-dashboard") + assert.True(t, allowUpdates, "test-dashboard should allow UI updates") + }) + + t.Run("should successfully read multiple configs", func(t *testing.T) { + testPath := "./testdata/stub-configs-multiple" + stub, err := NewStubProvisioning(testPath) + + require.NoError(t, err) + require.NotNil(t, stub) + + // Check editable-dashboard (allowUiUpdates: true) + allowUpdates := stub.GetAllowUIUpdatesFromConfig("editable-dashboard") + assert.True(t, allowUpdates, "editable-dashboard should allow UI updates") + + // Check readonly-dashboard (allowUiUpdates: false) + allowUpdates = stub.GetAllowUIUpdatesFromConfig("readonly-dashboard") + assert.False(t, allowUpdates, "readonly-dashboard should not allow UI updates") + + // Check default-dashboard (allowUiUpdates not specified, should default to false) + allowUpdates = stub.GetAllowUIUpdatesFromConfig("default-dashboard") + assert.False(t, allowUpdates, "default-dashboard should default to not allowing UI updates") + + // Verify paths are set correctly + path := stub.GetDashboardProvisionerResolvedPath("editable-dashboard") + assert.NotEmpty(t, path) + + path = stub.GetDashboardProvisionerResolvedPath("readonly-dashboard") + assert.NotEmpty(t, path) + }) + + t.Run("should handle invalid YAML gracefully", func(t *testing.T) { + // Create a temporary directory with invalid YAML + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Write invalid YAML + invalidYAML := `this is not valid yaml: [[[` + err = os.WriteFile(filepath.Join(dashboardsDir, "invalid.yaml"), []byte(invalidYAML), 0644) + require.NoError(t, err) + + // Should handle gracefully by returning empty stub + _, err = NewStubProvisioning(tmpDir) + require.NoError(t, err) + }) +} + +func TestGetAllowUIUpdatesFromConfig(t *testing.T) { + t.Run("should return true for config with allowUiUpdates: true", func(t *testing.T) { + testPath := "./testdata/stub-configs-multiple" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + result := stub.GetAllowUIUpdatesFromConfig("editable-dashboard") + assert.True(t, result) + }) + + t.Run("should return false for config with allowUiUpdates: false", func(t *testing.T) { + testPath := "./testdata/stub-configs-multiple" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + result := stub.GetAllowUIUpdatesFromConfig("readonly-dashboard") + assert.False(t, result) + }) + + t.Run("should return false for non-existent config name", func(t *testing.T) { + testPath := "./testdata/stub-configs" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + result := stub.GetAllowUIUpdatesFromConfig("non-existent-config") + assert.False(t, result, "should return false for non-existent config") + }) + + t.Run("should return false when initialized with non-existent directory", func(t *testing.T) { + stub, err := NewStubProvisioning("/non-existent-path") + require.NoError(t, err) + + result := stub.GetAllowUIUpdatesFromConfig("any-name") + assert.False(t, result) + }) +} + +func TestGetDashboardProvisionerResolvedPath(t *testing.T) { + t.Run("should resolve valid path", func(t *testing.T) { + testPath := "./testdata/stub-configs" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + path := stub.GetDashboardProvisionerResolvedPath("test-dashboard") + assert.NotEmpty(t, path) + }) + + t.Run("should handle non-existent config name", func(t *testing.T) { + testPath := "./testdata/stub-configs" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + path := stub.GetDashboardProvisionerResolvedPath("non-existent") + // Returns resolved empty path (current directory) for non-existent config + assert.NotEmpty(t, path, "returns resolved path even for non-existent config") + }) + + t.Run("should handle non-existent path in config", func(t *testing.T) { + testPath := "./testdata/stub-configs" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + // The path in config is /tmp/test-dashboards which likely doesn't exist + // Should still return a path (with warning logged) + path := stub.GetDashboardProvisionerResolvedPath("test-dashboard") + assert.NotEmpty(t, path) + }) + + t.Run("should resolve relative paths to absolute", func(t *testing.T) { + // Create a temporary directory with a config that has a relative path + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Create a subdirectory for the dashboard path + dashPath := filepath.Join(tmpDir, "my-dashboards") + err = os.MkdirAll(dashPath, 0750) + require.NoError(t, err) + + // Write config with absolute path (relative paths in options are not resolved correctly by the stub) + configContent := `apiVersion: 1 +providers: +- name: 'relative-path' + orgId: 1 + type: file + options: + path: ` + dashPath + ` +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config.yaml"), []byte(configContent), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + path := stub.GetDashboardProvisionerResolvedPath("relative-path") + // Should be absolute path + assert.True(t, filepath.IsAbs(path), "path should be absolute") + assert.Contains(t, path, "my-dashboards") + }) + + t.Run("should handle symlinks", func(t *testing.T) { + // Create a temporary directory structure with symlink + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Create actual directory + actualDir := filepath.Join(tmpDir, "actual-dashboards") + err = os.MkdirAll(actualDir, 0750) + require.NoError(t, err) + + // Create symlink + symlinkPath := filepath.Join(tmpDir, "linked-dashboards") + err = os.Symlink(actualDir, symlinkPath) + if err != nil { + t.Skip("Cannot create symlinks on this system") + } + + // Write config with symlink path + configContent := `apiVersion: 1 +providers: +- name: 'symlink-path' + orgId: 1 + type: file + options: + path: ` + symlinkPath + ` +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config.yaml"), []byte(configContent), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + path := stub.GetDashboardProvisionerResolvedPath("symlink-path") + // Should resolve symlink to actual path + assert.NotEmpty(t, path) + + // EvalSymlinks resolves to the real path, which might include /private prefix on macOS + // Just verify it contains the actual directory name + assert.Contains(t, path, "actual-dashboards", "should resolve symlink to actual directory") + }) + + t.Run("should fallback to original path on EvalSymlinks failure", func(t *testing.T) { + // This is harder to test directly, but we can verify the fallback logic exists + // by checking that even with a broken symlink, we get a path back + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Create config with a path that will fail EvalSymlinks + configContent := `apiVersion: 1 +providers: +- name: 'broken-path' + orgId: 1 + type: file + options: + path: /tmp/test-stub-dashboards +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config.yaml"), []byte(configContent), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + path := stub.GetDashboardProvisionerResolvedPath("broken-path") + assert.NotEmpty(t, path, "should return fallback path even if EvalSymlinks fails") + }) +} + +func TestProvideStubProvisioningService(t *testing.T) { + t.Run("should create stub from config", func(t *testing.T) { + cfg := &setting.Cfg{ + ProvisioningPath: "./testdata/stub-configs", + } + + stub, err := ProvideStubProvisioningService(cfg) + require.NoError(t, err) + require.NotNil(t, stub) + + // Verify it works + path := stub.GetDashboardProvisionerResolvedPath("test-dashboard") + assert.NotEmpty(t, path) + }) + + t.Run("should handle non-existent provisioning path", func(t *testing.T) { + cfg := &setting.Cfg{ + ProvisioningPath: "/non-existent-provisioning-path", + } + + stub, err := ProvideStubProvisioningService(cfg) + require.NoError(t, err, "should not fail with non-existent path") + require.NotNil(t, stub) + }) +} + +func TestStubProvisioningEdgeCases(t *testing.T) { + t.Run("should handle empty config name", func(t *testing.T) { + testPath := "./testdata/stub-configs" + stub, err := NewStubProvisioning(testPath) + require.NoError(t, err) + + // Empty config name returns resolved empty path (current directory) + path := stub.GetDashboardProvisionerResolvedPath("") + assert.NotEmpty(t, path) + + allowUpdates := stub.GetAllowUIUpdatesFromConfig("") + assert.False(t, allowUpdates) + }) + + t.Run("should handle special characters in config name", func(t *testing.T) { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + configContent := `apiVersion: 1 +providers: +- name: 'test-dashboard-with-special-chars-123' + orgId: 1 + type: file + allowUiUpdates: true + options: + path: /tmp/test +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config.yaml"), []byte(configContent), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + allowUpdates := stub.GetAllowUIUpdatesFromConfig("test-dashboard-with-special-chars-123") + assert.True(t, allowUpdates) + }) + + t.Run("should handle paths with spaces", func(t *testing.T) { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Create directory with spaces + pathWithSpaces := filepath.Join(tmpDir, "my dashboard folder") + err = os.MkdirAll(pathWithSpaces, 0750) + require.NoError(t, err) + + configContent := `apiVersion: 1 +providers: +- name: 'space-test' + orgId: 1 + type: file + options: + path: "` + pathWithSpaces + `" +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config.yaml"), []byte(configContent), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + path := stub.GetDashboardProvisionerResolvedPath("space-test") + assert.NotEmpty(t, path) + assert.Contains(t, path, "dashboard folder") + }) + + t.Run("should handle multiple YAML files in directory", func(t *testing.T) { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Create first config file + config1 := `apiVersion: 1 +providers: +- name: 'config1' + orgId: 1 + type: file + allowUiUpdates: true + options: + path: /tmp/config1 +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config1.yaml"), []byte(config1), 0644) + require.NoError(t, err) + + // Create second config file + config2 := `apiVersion: 1 +providers: +- name: 'config2' + orgId: 1 + type: file + allowUiUpdates: false + options: + path: /tmp/config2 +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config2.yaml"), []byte(config2), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + // Both configs should be loaded + assert.True(t, stub.GetAllowUIUpdatesFromConfig("config1")) + assert.False(t, stub.GetAllowUIUpdatesFromConfig("config2")) + + path1 := stub.GetDashboardProvisionerResolvedPath("config1") + path2 := stub.GetDashboardProvisionerResolvedPath("config2") + assert.NotEmpty(t, path1) + assert.NotEmpty(t, path2) + assert.NotEqual(t, path1, path2) + }) + + t.Run("should ignore non-YAML files", func(t *testing.T) { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "dashboards") + err := os.MkdirAll(dashboardsDir, 0750) + require.NoError(t, err) + + // Create YAML config + yamlConfig := `apiVersion: 1 +providers: +- name: 'yaml-config' + orgId: 1 + type: file + options: + path: /tmp/test +` + err = os.WriteFile(filepath.Join(dashboardsDir, "config.yaml"), []byte(yamlConfig), 0644) + require.NoError(t, err) + + // Create non-YAML files + err = os.WriteFile(filepath.Join(dashboardsDir, "readme.txt"), []byte("test"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dashboardsDir, "config.json"), []byte("{}"), 0644) + require.NoError(t, err) + + stub, err := NewStubProvisioning(tmpDir) + require.NoError(t, err) + + // Should only load the YAML config + path := stub.GetDashboardProvisionerResolvedPath("yaml-config") + assert.NotEmpty(t, path) + }) +} diff --git a/pkg/services/provisioning/testdata/stub-configs-multiple/dashboards/multiple.yaml b/pkg/services/provisioning/testdata/stub-configs-multiple/dashboards/multiple.yaml new file mode 100644 index 00000000000..e36fc293380 --- /dev/null +++ b/pkg/services/provisioning/testdata/stub-configs-multiple/dashboards/multiple.yaml @@ -0,0 +1,24 @@ +apiVersion: 1 + +providers: +- name: 'editable-dashboard' + orgId: 1 + folder: 'test-folder' + type: file + allowUiUpdates: true + options: + path: /tmp/editable-dashboards + +- name: 'readonly-dashboard' + orgId: 2 + folder: 'readonly-folder' + type: file + allowUiUpdates: false + options: + path: /tmp/readonly-dashboards + +- name: 'default-dashboard' + orgId: 1 + type: file + options: + path: /tmp/default-dashboards diff --git a/pkg/services/provisioning/testdata/stub-configs/dashboards/single.yaml b/pkg/services/provisioning/testdata/stub-configs/dashboards/single.yaml new file mode 100644 index 00000000000..cfbf0a3f1c5 --- /dev/null +++ b/pkg/services/provisioning/testdata/stub-configs/dashboards/single.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +providers: +- name: 'test-dashboard' + orgId: 1 + folder: '' + type: file + allowUiUpdates: true + options: + path: /tmp/test-dashboards diff --git a/pkg/services/rendering/auth.go b/pkg/services/rendering/auth.go index 450d99d241c..25364fce3d9 100644 --- a/pkg/services/rendering/auth.go +++ b/pkg/services/rendering/auth.go @@ -37,6 +37,7 @@ func (rs *RenderingService) GetRenderUser(ctx context.Context, key string) (*Ren var renderUser *RenderUser + //nolint:staticcheck // not yet migrated to OpenFeature if looksLikeJWT(key) && rs.features.IsEnabled(ctx, featuremgmt.FlagRenderAuthJWT) { from = "jwt" renderUser = rs.getRenderUserFromJWT(key) diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index ced2def7325..21c978d0528 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -122,6 +122,7 @@ func (s *SecretsService) registerUsageMetrics() { // Enabled / disabled usageMetrics["stats.encryption.envelope_encryption_enabled.count"] = 0 + //nolint:staticcheck // not yet migrated to OpenFeature if !s.features.IsEnabled(ctx, featuremgmt.FlagDisableEnvelopeEncryption) { usageMetrics["stats.encryption.envelope_encryption_enabled.count"] = 1 } @@ -167,6 +168,7 @@ func (s *SecretsService) Encrypt(ctx context.Context, payload []byte, opt secret defer span.End() // Use legacy encryption service if featuremgmt.FlagDisableEnvelopeEncryption toggle is on + //nolint:staticcheck // not yet migrated to OpenFeature if s.features.IsEnabled(ctx, featuremgmt.FlagDisableEnvelopeEncryption) { return s.enc.Encrypt(ctx, payload, s.cfg.SecretKey) } @@ -343,6 +345,7 @@ func (s *SecretsService) Decrypt(ctx context.Context, payload []byte) ([]byte, e // If encrypted with envelope encryption, the feature is disabled and // no provider is initialized, then we throw an error. + //nolint:staticcheck // not yet migrated to OpenFeature if s.encryptedWithEnvelopeEncryption(payload) && s.features.IsEnabled(ctx, featuremgmt.FlagDisableEnvelopeEncryption) && !s.providersInitialized() { @@ -480,6 +483,7 @@ func (s *SecretsService) RotateDataKeys(ctx context.Context) error { func (s *SecretsService) ReEncryptDataKeys(ctx context.Context) error { s.log.Info("Data keys re-encryption triggered") + //nolint:staticcheck // not yet migrated to OpenFeature if s.features.IsEnabled(ctx, featuremgmt.FlagDisableEnvelopeEncryption) { s.log.Info("Envelope encryption is not enabled but trying to init providers anyway...") diff --git a/pkg/services/store/kind/dashboard/dashboard.go b/pkg/services/store/kind/dashboard/dashboard.go index 656eefc15f3..88cbb4d1f45 100644 --- a/pkg/services/store/kind/dashboard/dashboard.go +++ b/pkg/services/store/kind/dashboard/dashboard.go @@ -276,6 +276,12 @@ func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup Datasour } for sub := iter.ReadObject(); sub != ""; sub = iter.ReadObject() { + // Skip all null values silently. + if iter.WhatIsNext() == jsoniter.NilValue { + iter.Skip() + continue + } + if sub == "list" { templatingListPath := templatingPath + ".list" if !checkAndSkipUnexpectedElement(iter, templatingListPath, lc, jsoniter.ArrayValue) { @@ -283,6 +289,12 @@ func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup Datasour } for ix := 0; iter.ReadArray(); ix++ { + // Skip all null elements silently. + if iter.WhatIsNext() == jsoniter.NilValue { + iter.Skip() + continue + } + tv := templateVariable{} templatingListElementPath := fmt.Sprintf("%s[%d]", templatingListPath, ix) @@ -308,7 +320,12 @@ func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup Datasour case "query": tv.query = iter.Read() case "current": - if !checkAndSkipUnexpectedElement(iter, templatingListElementPath+".current", lc, jsoniter.ObjectValue) { + if !checkAndSkipUnexpectedElement(iter, templatingListElementPath+".current", lc, jsoniter.ObjectValue, jsoniter.ArrayValue) { + continue + } + + if iter.WhatIsNext() == jsoniter.ArrayValue { + iter.Skip() continue } @@ -527,10 +544,18 @@ func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup, jsonPath st switch field { case "id": - if !checkAndSkipUnexpectedElement(iter, jsonPath+".id", lc, jsoniter.NumberValue) { + if !checkAndSkipUnexpectedElement(iter, jsonPath+".id", lc, jsoniter.NumberValue, jsoniter.StringValue) { continue } - panel.ID = iter.ReadInt64() + + if iter.WhatIsNext() == jsoniter.StringValue { + id, err := strconv.ParseInt(iter.ReadString(), 10, 64) + if err == nil { + panel.ID = id + } + } else { + panel.ID = iter.ReadInt64() + } case "type": if !checkAndSkipUnexpectedElement(iter, jsonPath+".type", lc, jsoniter.StringValue) { diff --git a/pkg/services/store/kind/dashboard/dashboard_test.go b/pkg/services/store/kind/dashboard/dashboard_test.go index 5e31a86f8bd..91e657df1a8 100644 --- a/pkg/services/store/kind/dashboard/dashboard_test.go +++ b/pkg/services/store/kind/dashboard/dashboard_test.go @@ -75,6 +75,7 @@ func TestReadDashboard(t *testing.T) { "k8s-wrapper", "k8s-wrapper-editable-string", "k8s-wrapper-tags-string", + "k8s-wrapper-with-parsing-errors", } devdash := "../../../../../devenv/dev-dashboards/" diff --git a/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-with-parsing-errors-info.json b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-with-parsing-errors-info.json new file mode 100644 index 00000000000..6451c3d805b --- /dev/null +++ b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-with-parsing-errors-info.json @@ -0,0 +1,74 @@ +{ + "id": 141, + "title": "pppp", + "tags": null, + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ], + "panels": [ + { + "id": 1, + "title": "green pie", + "libraryPanel": "a7975b7a-fb53-4ab7-951d-15810953b54f", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 2, + "title": "green pie", + "libraryPanel": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 7, + "title": "", + "type": "barchart", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 8, + "title": "", + "type": "graph", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 3, + "title": "collapsed row", + "type": "row", + "collapsed": [ + { + "id": 42, + "title": "blue pie", + "libraryPanel": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + } + ] + } + ], + "schemaVersion": 38, + "linkCount": 0, + "timeFrom": "now-6h", + "timeTo": "now", + "timezone": "" +} \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-with-parsing-errors.json b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-with-parsing-errors.json new file mode 100644 index 00000000000..73d2ce0ea22 --- /dev/null +++ b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-with-parsing-errors.json @@ -0,0 +1,133 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "adfbg6f", + "namespace": "default", + "uid": "b396894e-56bf-4a01-837b-64157912ca00", + "creationTimestamp": "2024-10-30T18:30:54Z", + "annotations": { + "grafana.app/createdBy": "user:be2g71ke8yoe8b", + "grafana.app/originHash": "Grafana v9.2.0 (NA)", + "grafana.app/originName": "UI", + "grafana.app/originPath": "/dashboard/new" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 141, + "links": [], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": "1", + "libraryPanel": { + "name": "green pie", + "uid": "a7975b7a-fb53-4ab7-951d-15810953b54f" + }, + "title": "green pie" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "libraryPanel": { + "name": "red pie", + "uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" + }, + "title": "green pie" + }, + { + "id": "7", + "type": "barchart" + }, + { + "id": 8, + "type": "graph" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 3, + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 42, + "libraryPanel": { + "name": "blue pie", + "uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, + "title": "blue pie" + } + ], + "title": "collapsed row", + "type": "row" + } + ], + "refresh": "", + "schemaVersion": 38, + "tags": [], + "templating": { + "list": null, + "list": [ + null, + { + "current": [] + }, + { + "current": { + "value": "default" + } + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "pppp", + "uid": "adfbg6f", + "version": 3, + "weekStart": "" + } +} diff --git a/pkg/setting/setting_cloud_migration.go b/pkg/setting/setting_cloud_migration.go index 87f549ca9cd..7c2138b262f 100644 --- a/pkg/setting/setting_cloud_migration.go +++ b/pkg/setting/setting_cloud_migration.go @@ -35,12 +35,14 @@ type CloudMigrationSettings struct { TokenExpiresAfter time.Duration FrontendPollInterval time.Duration + Enabled bool IsTarget bool IsDeveloperMode bool } func (cfg *Cfg) readCloudMigrationSettings() { cloudMigration := cfg.Raw.Section("cloud_migration") + cfg.CloudMigration.Enabled = cloudMigration.Key("enabled").MustBool(true) cfg.CloudMigration.IsTarget = cloudMigration.Key("is_target").MustBool(false) cfg.CloudMigration.GcomAPIToken = cloudMigration.Key("gcom_api_token").MustString("") cfg.CloudMigration.AuthAPIUrl = cloudMigration.Key("auth_api_url").MustString("") diff --git a/pkg/storage/secret/metadata/data/keeper_read_active.sql b/pkg/storage/secret/metadata/data/keeper_read_active.sql new file mode 100644 index 00000000000..437e5de5c26 --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_read_active.sql @@ -0,0 +1,20 @@ +SELECT + {{ .Ident "guid" }}, + {{ .Ident "name" }}, + {{ .Ident "namespace" }}, + {{ .Ident "annotations" }}, + {{ .Ident "labels" }}, + {{ .Ident "created" }}, + {{ .Ident "created_by" }}, + {{ .Ident "updated" }}, + {{ .Ident "updated_by" }}, + {{ .Ident "description" }}, + {{ .Ident "type" }}, + {{ .Ident "payload" }} +FROM + {{ .Ident "secret_keeper" }} +WHERE + {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "active" }} = true +LIMIT 1 +; diff --git a/pkg/storage/secret/metadata/data/keeper_set_as_active.sql b/pkg/storage/secret/metadata/data/keeper_set_as_active.sql new file mode 100644 index 00000000000..3c03610d304 --- /dev/null +++ b/pkg/storage/secret/metadata/data/keeper_set_as_active.sql @@ -0,0 +1,5 @@ +UPDATE {{ .Ident "secret_keeper" }} +SET {{ .Ident "active" }} = ({{ .Ident "name" }} = {{ .Arg .Name }}) +WHERE + {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +; diff --git a/pkg/storage/secret/metadata/data/secure_value_get_latest_version.sql b/pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql similarity index 72% rename from pkg/storage/secret/metadata/data/secure_value_get_latest_version.sql rename to pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql index 90319d469eb..1c804c2d031 100644 --- a/pkg/storage/secret/metadata/data/secure_value_get_latest_version.sql +++ b/pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql @@ -1,5 +1,7 @@ SELECT - {{ .Ident "version" }} + {{ .Ident "created" }}, + {{ .Ident "version" }}, + {{ .Ident "active" }} FROM {{ .Ident "secret_secure_value" }} WHERE diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index ea9d851999d..4c0ebabb5e8 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -135,7 +135,7 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, return "", fmt.Errorf("failed to authorize decryption with reason %v (%w)", reason, contracts.ErrDecryptNotAuthorized) } - keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Spec.Keeper, contracts.ReadOpts{}) + keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Status.Keeper, contracts.ReadOpts{}) if err != nil { return "", fmt.Errorf("failed to read keeper config metadata storage: %v (%w)", err, contracts.ErrDecryptFailed) } diff --git a/pkg/storage/secret/metadata/keeper_model.go b/pkg/storage/secret/metadata/keeper_model.go index 8fd43dc35e0..4831ee25a2e 100644 --- a/pkg/storage/secret/metadata/keeper_model.go +++ b/pkg/storage/secret/metadata/keeper_model.go @@ -280,3 +280,18 @@ func extractSecureValues(kp *secretv1beta1.Keeper) map[string]struct{} { return nil } + +func getKeeperConfig(keeper *secretv1beta1.Keeper) secretv1beta1.KeeperConfig { + switch keeper.Spec.GetType() { + case secretv1beta1.AWSKeeperType: + return keeper.Spec.Aws + case secretv1beta1.AzureKeeperType: + return keeper.Spec.Azure + case secretv1beta1.GCPKeeperType: + return keeper.Spec.Gcp + case secretv1beta1.HashiCorpKeeperType: + return keeper.Spec.HashiCorpVault + default: + return nil + } +} diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go index 38c27cd1873..6036bfe2502 100644 --- a/pkg/storage/secret/metadata/keeper_store.go +++ b/pkg/storage/secret/metadata/keeper_store.go @@ -2,6 +2,7 @@ package metadata import ( "context" + "errors" "fmt" "strconv" "time" @@ -186,7 +187,7 @@ func (s *keeperMetadataStorage) read(ctx context.Context, namespace, name string defer func() { _ = res.Close() }() if !res.Next() { - return nil, contracts.ErrKeeperNotFound + return nil, fmt.Errorf("keeper=%s: %w", name, contracts.ErrKeeperNotFound) } var keeper keeperDB @@ -568,9 +569,10 @@ func (s *keeperMetadataStorage) validateSecureValueReferences(ctx context.Contex return nil } -func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace string, name *string, opts contracts.ReadOpts) (_ secretv1beta1.KeeperConfig, getErr error) { +func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace string, name string, opts contracts.ReadOpts) (_ secretv1beta1.KeeperConfig, getErr error) { ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.GetKeeperConfig", trace.WithAttributes( attribute.String("namespace", namespace), + attribute.String("name", name), attribute.Bool("isForUpdate", opts.ForUpdate), )) start := time.Now() @@ -581,6 +583,7 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s args := []any{ "namespace", namespace, + "name", name, "isForUpdate", strconv.FormatBool(opts.ForUpdate), } @@ -597,14 +600,12 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s }() // Check if keeper is the systemwide one. - if name == nil { + if name == contracts.SystemKeeperName { return &secretv1beta1.SystemKeeperConfig{}, nil } - span.SetAttributes(attribute.String("name", *name)) - // Load keeper config from metadata store, or TODO: keeper cache. - kp, err := s.read(ctx, namespace, *name, opts) + kp, err := s.read(ctx, namespace, name, opts) if err != nil { return nil, err } @@ -614,3 +615,109 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s // TODO: this would be a good place to check if credentials are secure values and load them. return keeperConfig, nil } + +func (s *keeperMetadataStorage) SetAsActive(ctx context.Context, namespace xkube.Namespace, name string) error { + req := setKeeperAsActive{ + SQLTemplate: sqltemplate.New(s.dialect), + Namespace: namespace.String(), + Name: name, + } + + query, err := sqltemplate.Execute(sqlKeeperSetAsActive, req) + if err != nil { + return fmt.Errorf("template %q: %w", sqlKeeperSetAsActive.Name(), err) + } + + // Check keeper exists. No need to worry about time of check to time of use + // since trying to activate a just deleted keeper will result in all + // keepers being inactive and defaulting to the system keeper. + if _, err := s.read(ctx, namespace.String(), name, contracts.ReadOpts{}); err != nil { + return fmt.Errorf("reading keeper before setting as active: %w", err) + } + + _, err = s.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("setting keeper as active %q: %w", query, err) + } + + return nil +} + +func (s *keeperMetadataStorage) GetActiveKeeper(ctx context.Context, namespace string) (keeper *secretv1beta1.Keeper, readErr error) { + start := time.Now() + ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.GetActiveKeeper", trace.WithAttributes( + attribute.String("namespace", namespace), + )) + defer span.End() + + defer func() { + success := readErr == nil + + args := []any{ + "namespace", namespace, + } + + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.GetActiveKeeper failed") + span.RecordError(readErr) + args = append(args, "error", readErr) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.GetActiveKeeper", args...) + + s.metrics.KeeperMetadataGetDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + + req := &readActiveKeeper{ + SQLTemplate: sqltemplate.New(s.dialect), + Namespace: namespace, + } + + query, err := sqltemplate.Execute(sqlKeeperReadActive, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlKeeperReadActive.Name(), err) + } + + res, err := s.db.QueryContext(ctx, query, req.GetArgs()...) + if err != nil { + return nil, fmt.Errorf("executing query to fetch active keeper in namespace %s: %w", namespace, err) + } + defer func() { _ = res.Close() }() + + if !res.Next() { + return nil, contracts.ErrKeeperNotFound + } + + var keeperDB keeperDB + err = res.Scan( + &keeperDB.GUID, &keeperDB.Name, &keeperDB.Namespace, &keeperDB.Annotations, &keeperDB.Labels, &keeperDB.Created, + &keeperDB.CreatedBy, &keeperDB.Updated, &keeperDB.UpdatedBy, &keeperDB.Description, &keeperDB.Type, &keeperDB.Payload, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan keeper row: %w", err) + } + if err := res.Err(); err != nil { + return nil, fmt.Errorf("read rows error: %w", err) + } + + keeper, readErr = keeperDB.toKubernetes() + if readErr != nil { + return keeper, fmt.Errorf("converting from keeperDB to kubernetes struct: %w", err) + } + + return keeper, nil +} + +func (s *keeperMetadataStorage) GetActiveKeeperConfig(ctx context.Context, namespace string) (string, secretv1beta1.KeeperConfig, error) { + keeper, err := s.GetActiveKeeper(ctx, namespace) + if err != nil { + // When there are not active keepers, default to the system keeper + if errors.Is(err, contracts.ErrKeeperNotFound) { + return contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}, nil + } + return "", nil, fmt.Errorf("fetching active keeper from db: %w", err) + } + + return keeper.Name, getKeeperConfig(keeper), nil +} diff --git a/pkg/storage/secret/metadata/keeper_store_test.go b/pkg/storage/secret/metadata/keeper_store_test.go index f21fbdaa575..a38e0ec85b3 100644 --- a/pkg/storage/secret/metadata/keeper_store_test.go +++ b/pkg/storage/secret/metadata/keeper_store_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace/noop" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" @@ -40,7 +41,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { keeperMetadataStorage := initStorage(t) // get system keeper config - keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, nil, contracts.ReadOpts{}) + keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, contracts.SystemKeeperName, contracts.ReadOpts{}) require.NoError(t, err) require.IsType(t, &secretv1beta1.SystemKeeperConfig{}, keeperConfig) }) @@ -55,7 +56,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { _, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") require.NoError(t, err) - keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, &defaultKeeperName, contracts.ReadOpts{}) + keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, defaultKeeperName, contracts.ReadOpts{}) require.NoError(t, err) require.NotNil(t, keeperConfig) require.NotEmpty(t, keeperConfig.Type()) @@ -105,7 +106,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { require.NoError(t, err) // we are able to get it - keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) + keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, keeperTest, contracts.ReadOpts{}) require.NoError(t, err) require.NotNil(t, keeperConfig) require.NotEmpty(t, keeperConfig.Type()) @@ -115,7 +116,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { require.NoError(t, delErr) // and we shouldn't be able to get it again - _, getErr := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) + _, getErr := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, keeperTest, contracts.ReadOpts{}) require.Errorf(t, getErr, "keeper not found") }) @@ -162,7 +163,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { require.NoError(t, err) // Validate updated values - updatedConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) + updatedConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, keeperTest, contracts.ReadOpts{}) require.NoError(t, err) require.NotNil(t, updatedConfig) require.NotEmpty(t, updatedConfig.Type()) @@ -260,7 +261,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { _, err := keeperMetadataStorage.Read(ctx, "ns", "non-existent", contracts.ReadOpts{}) require.Error(t, err) - require.Equal(t, contracts.ErrKeeperNotFound, err) + require.ErrorIs(t, err, contracts.ErrKeeperNotFound) }) t.Run("update keeper with different namespace", func(t *testing.T) { @@ -329,6 +330,40 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { }) } +func Test_KeeperMetadataStorage_SetAsActive(t *testing.T) { + t.Parallel() + + keeperMetadataStorage := initStorage(t) + + k1, err := keeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: v1.ObjectMeta{Namespace: "ns1", Name: "k1"}, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + k2, err := keeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: v1.ObjectMeta{Namespace: "ns1", Name: "k2"}, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + require.NoError(t, keeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name)) + keeperName, _, err := keeperMetadataStorage.GetActiveKeeperConfig(t.Context(), k1.Namespace) + require.NoError(t, err) + require.Equal(t, k1.Name, keeperName) + + require.NoError(t, keeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name)) + keeperName, _, err = keeperMetadataStorage.GetActiveKeeperConfig(t.Context(), k2.Namespace) + require.NoError(t, err) + require.Equal(t, k2.Name, keeperName) +} + func initStorage(t *testing.T) contracts.KeeperMetadataStorage { testDB := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New())) tracer := noop.NewTracerProvider().Tracer("test") diff --git a/pkg/storage/secret/metadata/query.go b/pkg/storage/secret/metadata/query.go index d71b2a508e2..e8f84855e7f 100644 --- a/pkg/storage/secret/metadata/query.go +++ b/pkg/storage/secret/metadata/query.go @@ -15,11 +15,13 @@ var ( sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `data/*.sql`)) // The SQL Commands - sqlKeeperCreate = mustTemplate("keeper_create.sql") - sqlKeeperRead = mustTemplate("keeper_read.sql") - sqlKeeperUpdate = mustTemplate("keeper_update.sql") - sqlKeeperList = mustTemplate("keeper_list.sql") - sqlKeeperDelete = mustTemplate("keeper_delete.sql") + sqlKeeperCreate = mustTemplate("keeper_create.sql") + sqlKeeperRead = mustTemplate("keeper_read.sql") + sqlKeeperReadActive = mustTemplate("keeper_read_active.sql") + sqlKeeperUpdate = mustTemplate("keeper_update.sql") + sqlKeeperList = mustTemplate("keeper_list.sql") + sqlKeeperDelete = mustTemplate("keeper_delete.sql") + sqlKeeperSetAsActive = mustTemplate("keeper_set_as_active.sql") sqlKeeperListByName = mustTemplate("keeper_listByName.sql") sqlSecureValueListByName = mustTemplate("secure_value_listByName.sql") @@ -32,9 +34,9 @@ var ( sqlSecureValueLeaseInactive = mustTemplate("secure_value_lease_inactive.sql") sqlSecureValueListByLeaseToken = mustTemplate("secure_value_list_by_lease_token.sql") - sqlGetLatestSecureValueVersion = mustTemplate("secure_value_get_latest_version.sql") - sqlSecureValueSetVersionToActive = mustTemplate("secure_value_set_version_to_active.sql") - sqlSecureValueSetVersionToInactive = mustTemplate("secure_value_set_version_to_inactive.sql") + sqlGetLatestSecureValueVersionAndCreatedAt = mustTemplate("secure_value_get_latest_version_and_created_at.sql") + sqlSecureValueSetVersionToActive = mustTemplate("secure_value_set_version_to_active.sql") + sqlSecureValueSetVersionToInactive = mustTemplate("secure_value_set_version_to_inactive.sql") ) func mustTemplate(filename string) *template.Template { @@ -48,6 +50,18 @@ func mustTemplate(filename string) *template.Template { /**-- Keeper Queries --**/ /************************/ +// Set as active +type setKeeperAsActive struct { + sqltemplate.SQLTemplate + Namespace string + Name string +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r setKeeperAsActive) Validate() error { + return nil // TODO +} + // Create type createKeeper struct { sqltemplate.SQLTemplate @@ -72,6 +86,17 @@ func (r readKeeper) Validate() error { return nil // TODO } +// Read active keeper +type readActiveKeeper struct { + sqltemplate.SQLTemplate + Namespace string +} + +// Validate is only used if we use `dbutil` from `unifiedstorage` +func (r readActiveKeeper) Validate() error { + return nil // TODO +} + // Update type updateKeeper struct { sqltemplate.SQLTemplate @@ -146,13 +171,13 @@ func (r readSecureValue) Validate() error { return nil // TODO } -type getLatestSecureValueVersion struct { +type getLatestSecureValueVersionAndCreatedAt struct { sqltemplate.SQLTemplate Namespace string Name string } -func (r getLatestSecureValueVersion) Validate() error { +func (r getLatestSecureValueVersionAndCreatedAt) Validate() error { return nil } diff --git a/pkg/storage/secret/metadata/query_test.go b/pkg/storage/secret/metadata/query_test.go index 23f12d1927c..108b9a6a1cd 100644 --- a/pkg/storage/secret/metadata/query_test.go +++ b/pkg/storage/secret/metadata/query_test.go @@ -13,6 +13,15 @@ func TestKeeperQueries(t *testing.T) { mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ RootDir: "testdata", Templates: map[*template.Template][]mocks.TemplateTestCase{ + sqlKeeperSetAsActive: { + { + Name: "keeper set as active", + Data: &setKeeperAsActive{ + SQLTemplate: mocks.NewTestingSQLTemplate(), Name: "name", + Namespace: "ns", + }, + }, + }, sqlKeeperCreate: { { Name: "create", @@ -54,6 +63,15 @@ func TestKeeperQueries(t *testing.T) { }, }, }, + sqlKeeperReadActive: { + { + Name: "read active", + Data: &readActiveKeeper{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + }, + }, + }, sqlKeeperRead: { { Name: "read", @@ -123,10 +141,10 @@ func TestSecureValueQueries(t *testing.T) { mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ RootDir: "testdata", Templates: map[*template.Template][]mocks.TemplateTestCase{ - sqlGetLatestSecureValueVersion: { + sqlGetLatestSecureValueVersionAndCreatedAt: { { Name: "get latest secure value version", - Data: &getLatestSecureValueVersion{ + Data: &getLatestSecureValueVersionAndCreatedAt{ SQLTemplate: mocks.NewTestingSQLTemplate(), Name: "name", Namespace: "ns", diff --git a/pkg/storage/secret/metadata/secure_value_model.go b/pkg/storage/secret/metadata/secure_value_model.go index bed3a9ae053..81ed2eb2849 100644 --- a/pkg/storage/secret/metadata/secure_value_model.go +++ b/pkg/storage/secret/metadata/secure_value_model.go @@ -79,7 +79,7 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) { } if sv.Keeper.Valid { - resource.Spec.Keeper = &sv.Keeper.String + resource.Status.Keeper = sv.Keeper.String } if sv.Ref.Valid { resource.Spec.Ref = &sv.Ref.String @@ -122,25 +122,23 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) { } // toCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted. -func toCreateRow(now time.Time, sv *secretv1beta1.SecureValue, actorUID string) (*secureValueDB, error) { - row, err := toRow(sv, "") +func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (*secureValueDB, error) { + row, err := toRow(keeper, sv, "") if err != nil { return nil, fmt.Errorf("failed to convert SecureValue to secureValueDB: %w", err) } - timestamp := now.UTC().Unix() - row.GUID = uuid.New().String() - row.Created = timestamp + row.Created = createdAt row.CreatedBy = actorUID - row.Updated = timestamp + row.Updated = updatedAt row.UpdatedBy = actorUID return row, nil } // toRow maps a Kubernetes resource into a DB row. -func toRow(sv *secretv1beta1.SecureValue, externalID string) (*secureValueDB, error) { +func toRow(keeper string, sv *secretv1beta1.SecureValue, externalID string) (*secureValueDB, error) { var annotations string if len(sv.Annotations) > 0 { cleanedAnnotations := xkube.CleanAnnotations(sv.Annotations) @@ -237,7 +235,7 @@ func toRow(sv *secretv1beta1.SecureValue, externalID string) (*secureValueDB, er Version: sv.Status.Version, Description: sv.Spec.Description, - Keeper: toNullString(sv.Spec.Keeper), + Keeper: toNullString(&keeper), Decrypters: toNullString(decrypters), Ref: toNullString(sv.Spec.Ref), ExternalID: externalID, diff --git a/pkg/storage/secret/metadata/secure_value_store.go b/pkg/storage/secret/metadata/secure_value_store.go index eec70a40991..d3a137ea611 100644 --- a/pkg/storage/secret/metadata/secure_value_store.go +++ b/pkg/storage/secret/metadata/secure_value_store.go @@ -47,13 +47,14 @@ type secureValueMetadataStorage struct { tracer trace.Tracer } -func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, svmCreateErr error) { +func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, svmCreateErr error) { start := s.clock.Now() name := sv.GetName() namespace := sv.GetNamespace() ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.Create", trace.WithAttributes( attribute.String("name", name), attribute.String("namespace", namespace), + attribute.String("keeper", keeper), attribute.String("actorUID", actorUID), )) defer span.End() @@ -64,6 +65,7 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet args := []any{ "name", name, "namespace", namespace, + "keeper", keeper, "actorUID", actorUID, } @@ -83,41 +85,14 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet var row *secureValueDB err := s.db.Transaction(ctx, func(ctx context.Context) error { - if sv.Spec.Keeper != nil { - // Validate before inserting that the chosen `keeper` exists. - - // -- This is a copy of KeeperMetadataStore.read, which is not public at the moment, and is not defined in contract.KeeperMetadataStorage - req := &readKeeper{ - SQLTemplate: sqltemplate.New(s.dialect), - Namespace: sv.Namespace, - Name: *sv.Spec.Keeper, - IsForUpdate: true, - } - - query, err := sqltemplate.Execute(sqlKeeperRead, req) - if err != nil { - return fmt.Errorf("execute template %q: %w", sqlKeeperRead.Name(), err) - } - - res, err := s.db.QueryContext(ctx, query, req.GetArgs()...) - if err != nil { - return fmt.Errorf("getting row: %w", err) - } - defer func() { _ = res.Close() }() - - if !res.Next() { - return contracts.ErrKeeperNotFound - } - } - - latestVersion, err := s.getLatestVersion(ctx, xkube.Namespace(sv.Namespace), sv.Name) + latest, err := s.getLatestVersionAndCreatedAt(ctx, xkube.Namespace(sv.Namespace), sv.Name) if err != nil { return fmt.Errorf("fetching latest secure value version: %w", err) } version := int64(1) - if latestVersion != nil { - version = *latestVersion + 1 + if latest.version > 0 { + version = latest.version + 1 } // Some other concurrent request may have created the version we're trying to create, @@ -127,7 +102,15 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet for { sv.Status.Version = version - row, err = toCreateRow(s.clock.Now(), sv, actorUID) + now := s.clock.Now().UTC().Unix() + + createdAt := now + if latest.createdAt > 0 { + createdAt = latest.createdAt + } + updatedAt := now + + row, err = toCreateRow(createdAt, updatedAt, keeper, sv, actorUID) if err != nil { return fmt.Errorf("to create row: %w", err) } @@ -178,44 +161,60 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet return createdSecureValue, nil } -func (s *secureValueMetadataStorage) getLatestVersion(ctx context.Context, namespace xkube.Namespace, name string) (*int64, error) { - ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersion", trace.WithAttributes( +type versionAndCreatedAt struct { + createdAt int64 + version int64 +} + +func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreatedAt, error) { + ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreatedAt", trace.WithAttributes( attribute.String("name", name), attribute.String("namespace", namespace.String()), )) defer span.End() - req := getLatestSecureValueVersion{ + req := getLatestSecureValueVersionAndCreatedAt{ SQLTemplate: sqltemplate.New(s.dialect), Namespace: namespace.String(), Name: name, } - q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersion, req) + q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersionAndCreatedAt, req) if err != nil { - return nil, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersion.Name(), err) + return versionAndCreatedAt{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err) } rows, err := s.db.QueryContext(ctx, q, req.GetArgs()...) if err != nil { - return nil, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err) + return versionAndCreatedAt{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err) } defer func() { _ = rows.Close() }() if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error executing query: %w", err) + return versionAndCreatedAt{}, fmt.Errorf("error executing query: %w", err) } if !rows.Next() { - return nil, nil + return versionAndCreatedAt{}, nil } - var version int64 - if err := rows.Scan(&version); err != nil { - return nil, fmt.Errorf("scanning version from returned rows: %w", err) + var ( + createdAt int64 + version int64 + active bool + ) + if err := rows.Scan(&createdAt, &version, &active); err != nil { + return versionAndCreatedAt{}, fmt.Errorf("scanning version from returned rows: %w", err) } - return &version, nil + if !active { + createdAt = 0 + } + + return versionAndCreatedAt{ + createdAt: createdAt, + version: version, + }, nil } func (s *secureValueMetadataStorage) readActiveVersion(ctx context.Context, namespace xkube.Namespace, name string, opts contracts.ReadOpts) (secureValueDB, error) { diff --git a/pkg/storage/secret/metadata/secure_value_store_test.go b/pkg/storage/secret/metadata/secure_value_store_test.go index 8b7fbc837fc..f7551e9e484 100644 --- a/pkg/storage/secret/metadata/secure_value_store_test.go +++ b/pkg/storage/secret/metadata/secure_value_store_test.go @@ -63,20 +63,20 @@ func Test_SecureValueMetadataStorage_CreateAndRead(t *testing.T) { Spec: secretv1beta1.SecureValueSpec{ Description: "test description", Value: ptr.To(secretv1beta1.NewExposedSecureValue("test-value")), - Keeper: &keeperName, }, + Status: secretv1beta1.SecureValueStatus{Keeper: keeperName}, } testSecureValue.Name = "sv-test" testSecureValue.Namespace = "default" // Create the secure value - createdSecureValue, err := secureValueStorage.Create(ctx, testSecureValue, "testuser") + createdSecureValue, err := secureValueStorage.Create(ctx, keeperName, testSecureValue, "testuser") require.NoError(t, err) require.NotNil(t, createdSecureValue) require.Equal(t, "sv-test", createdSecureValue.Name) require.Equal(t, "default", createdSecureValue.Namespace) require.Equal(t, "test description", createdSecureValue.Spec.Description) - require.Equal(t, keeperName, *createdSecureValue.Spec.Keeper) + require.Equal(t, keeperName, createdSecureValue.Status.Keeper) require.NoError(t, secureValueStorage.SetVersionToActive(ctx, xkube.Namespace(createdSecureValue.Namespace), createdSecureValue.Name, createdSecureValue.Status.Version)) @@ -87,7 +87,7 @@ func Test_SecureValueMetadataStorage_CreateAndRead(t *testing.T) { require.Equal(t, "sv-test", readSecureValue.Name) require.Equal(t, "default", readSecureValue.Namespace) require.Equal(t, "test description", readSecureValue.Spec.Description) - require.Equal(t, keeperName, *readSecureValue.Spec.Keeper) + require.Equal(t, keeperName, readSecureValue.Status.Keeper) // List secure values and verify our value is in the list secureValues, err := secureValueStorage.List(ctx, xkube.Namespace("default")) @@ -101,7 +101,7 @@ func Test_SecureValueMetadataStorage_CreateAndRead(t *testing.T) { found = true require.Equal(t, "default", sv.Namespace) require.Equal(t, "test description", sv.Spec.Description) - require.Equal(t, keeperName, *sv.Spec.Keeper) + require.Equal(t, keeperName, sv.Status.Keeper) break } } @@ -117,14 +117,14 @@ func Test_SecureValueMetadataStorage_CreateAndRead(t *testing.T) { Spec: secretv1beta1.SecureValueSpec{ Description: "test description 2", Value: ptr.To(secretv1beta1.NewExposedSecureValue("test-value-2")), - Keeper: &keeperName, }, + Status: secretv1beta1.SecureValueStatus{Keeper: keeperName}, } testSecureValue.Name = "sv-test-2" testSecureValue.Namespace = "default" // Create the secure value - createdSecureValue, err := secureValueStorage.Create(ctx, testSecureValue, "testuser") + createdSecureValue, err := secureValueStorage.Create(ctx, keeperName, testSecureValue, "testuser") require.NoError(t, err) require.NotNil(t, createdSecureValue) @@ -199,8 +199,8 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { t.Repeat(map[string]func(*rapid.T){ "create": func(t *rapid.T) { sv := anySecureValueGen.Draw(t, "sv") - modelCreatedSv, modelErr := model.create(sut.Clock.Now(), deepCopy(sv)) - createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(deepCopy(sv))) + modelCreatedSv, modelErr := model.create(sut.Clock.Now(), sv.DeepCopy()) + createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -211,7 +211,7 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { }, "delete": func(t *rapid.T) { ns := namespaceGen.Draw(t, "ns") - name := nameGen.Draw(t, "name") + name := secureValueNameGen.Draw(t, "name") modelSv, modelErr := model.delete(ns, name) sv, err := sut.DeleteSv(t.Context(), ns, name) if err != nil || modelErr != nil { diff --git a/pkg/storage/secret/metadata/secure_value_test.go b/pkg/storage/secret/metadata/secure_value_test.go index 3225117a3c7..0eba9bbf9ce 100644 --- a/pkg/storage/secret/metadata/secure_value_test.go +++ b/pkg/storage/secret/metadata/secure_value_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/mitchellh/copystructure" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -26,9 +25,16 @@ type modelSecureValue struct { leaseCreated time.Time } +type modelKeeper struct { + namespace string + name string + active bool +} + // A simplified model of the grafana secrets manager type model struct { secureValues []*modelSecureValue + keepers []*modelKeeper } func newModel() *model { @@ -73,24 +79,98 @@ func (m *model) readActiveVersion(namespace, name string) *modelSecureValue { } func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, error) { - modelSv := &modelSecureValue{SecureValue: sv, active: false, created: now} + keeper := m.getActiveKeeper(sv.Namespace) + sv = sv.DeepCopy() + + // Preserve the original creation time if this secure value already exists + created := now + if sv := m.readActiveVersion(sv.Namespace, sv.Name); sv != nil { + created = sv.created + } + + modelSv := &modelSecureValue{SecureValue: sv, active: false, created: created} modelSv.Status.Version = m.getNewVersionNumber(modelSv.Namespace, modelSv.Name) modelSv.Status.ExternalID = fmt.Sprintf("%d", modelSv.Status.Version) + modelSv.Status.Keeper = keeper.name m.secureValues = append(m.secureValues, modelSv) m.setVersionToActive(modelSv.Namespace, modelSv.Name, modelSv.Status.Version) return modelSv.SecureValue, nil } +func (m *model) getActiveKeeper(namespace string) *modelKeeper { + for _, k := range m.keepers { + if k.namespace == namespace && k.active { + return k + } + } + + // Default to the system keeper when there are no active keepers in the namespace + return &modelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true} +} + +func (m *model) keeperExists(namespace, name string) bool { + return m.findKeeper(namespace, name) != nil +} + +func (m *model) findKeeper(namespace, name string) *modelKeeper { + // The system keeper is not in the list of keepers + if name == contracts.SystemKeeperName { + return &modelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true} + } + for _, k := range m.keepers { + if k.namespace == namespace && k.name == name { + return k + } + } + return nil +} + +func (m *model) createKeeper(keeper *secretv1beta1.Keeper) (*secretv1beta1.Keeper, error) { + if m.keeperExists(keeper.Namespace, keeper.Name) { + return nil, contracts.ErrKeeperAlreadyExists + } + + m.keepers = append(m.keepers, &modelKeeper{namespace: keeper.Namespace, name: keeper.Name}) + + return keeper.DeepCopy(), nil +} + +func (m *model) setKeeperAsActive(namespace, keeperName string) error { + keeper := m.findKeeper(namespace, keeperName) + if keeper == nil { + return contracts.ErrKeeperNotFound + } + // Set the keeper as active + keeper.active = true + + // Set every other keeper in the namespace as inactive + for _, k := range m.keepers { + if k.namespace == namespace && k.name != keeperName { + k.active = false + } + } + + return nil +} + func (m *model) update(now time.Time, newSecureValue *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, bool, error) { + sv := m.readActiveVersion(newSecureValue.Namespace, newSecureValue.Name) + if sv == nil { + return nil, false, contracts.ErrSecureValueNotFound + } + + // If the keeper doesn't exist, return an error + if !m.keeperExists(sv.Namespace, sv.Status.Keeper) { + return nil, false, contracts.ErrKeeperNotFound + } + // If the payload doesn't contain a value, get the value from current version if newSecureValue.Spec.Value == nil { - sv := m.readActiveVersion(newSecureValue.Namespace, newSecureValue.Name) - if sv == nil { - return nil, false, contracts.ErrSecureValueNotFound - } newSecureValue.Spec.Value = sv.Spec.Value } + createdSv, err := m.create(now, newSecureValue) + return createdSv, true, err } @@ -122,7 +202,7 @@ func (m *model) decrypt(decrypter, namespace, name string) (map[string]decrypt.D v.active { if slices.ContainsFunc(v.Spec.Decrypters, func(d string) bool { return d == decrypter }) { return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultValue(deepCopy(v).Spec.Value), + name: decrypt.NewDecryptResultValue(v.DeepCopy().Spec.Value), }, nil } @@ -161,13 +241,14 @@ func (m *model) leaseInactiveSecureValues(now time.Time, minAge, leaseTTL time.D } var ( - decryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) - nameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) - namespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) - anySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + decryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) + secureValueNameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) + keeperNameGen = rapid.SampledFrom([]string{"k1", "k2", "k3", "k4", "k5"}) + namespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) + anySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { return &secretv1beta1.SecureValue{ ObjectMeta: metav1.ObjectMeta{ - Name: nameGen.Draw(t, "name"), + Name: secureValueNameGen.Draw(t, "name"), Namespace: namespaceGen.Draw(t, "ns"), }, Spec: secretv1beta1.SecureValueSpec{ @@ -191,10 +272,37 @@ var ( decryptGen = rapid.Custom(func(t *rapid.T) decryptInput { return decryptInput{ namespace: namespaceGen.Draw(t, "ns"), - name: nameGen.Draw(t, "name"), + name: secureValueNameGen.Draw(t, "name"), decrypter: decryptersGen.Draw(t, "decrypter"), } }) + anyKeeperGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.Keeper { + spec := secretv1beta1.KeeperSpec{ + Description: rapid.String().Draw(t, "description"), + } + + keeperType := rapid.SampledFrom([]string{"isAwsKeeper", "isAzureKeeper", "isGcpKeeper", "isVaultKeeper"}).Draw(t, "keeperType") + switch keeperType { + case "isAwsKeeper": + spec.Aws = &secretv1beta1.KeeperAWSConfig{} + case "isAzureKeeper": + spec.Azure = &secretv1beta1.KeeperAzureConfig{} + case "isGcpKeeper": + spec.Gcp = &secretv1beta1.KeeperGCPConfig{} + case "isVaultKeeper": + spec.HashiCorpVault = &secretv1beta1.KeeperHashiCorpConfig{} + default: + panic(fmt.Sprintf("unhandled keeper type '%+v', did you forget a switch case?", keeperType)) + } + + return &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: keeperNameGen.Draw(t, "name"), + Namespace: namespaceGen.Draw(t, "ns"), + }, + Spec: spec, + } + }) ) type decryptInput struct { @@ -226,14 +334,14 @@ func TestModel(t *testing.T) { now := time.Now() // Create a secure value - sv1, err := m.create(now, deepCopy(sv)) + sv1, err := m.create(now, sv.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv1.Namespace) require.Equal(t, sv.Name, sv1.Name) require.EqualValues(t, 1, sv1.Status.Version) // Create a new version of a secure value - sv2, err := m.create(now, deepCopy(sv)) + sv2, err := m.create(now, sv.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv2.Namespace) require.Equal(t, sv.Name, sv2.Name) @@ -247,30 +355,29 @@ func TestModel(t *testing.T) { now := time.Now() - sv1, err := m.create(now, deepCopy(sv)) + sv1, err := m.create(now, sv.DeepCopy()) require.NoError(t, err) // Create a new version of a secure value by updating it - sv2, _, err := m.update(now, deepCopy(sv1)) + sv2, _, err := m.update(now, sv1.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv2.Namespace) require.Equal(t, sv.Name, sv2.Name) require.EqualValues(t, 2, sv2.Status.Version) // Try updating a secure value that doesn't exist without specifying a value for it - sv3 := deepCopy(sv2) + sv3 := sv2.DeepCopy() sv3.Name = "i_dont_exist" sv3.Spec.Value = nil _, _, err = m.update(now, sv3) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) // Updating a value that doesn't exist creates a new version - sv4 := deepCopy(sv3) + sv4 := sv3.DeepCopy() sv4.Name = "i_dont_exist" sv4.Spec.Value = ptr.To(secretv1beta1.NewExposedSecureValue("sv4")) - sv4, _, err = m.update(now, sv4) - require.NoError(t, err) - require.EqualValues(t, 1, sv4.Status.Version) + _, _, err = m.update(now, sv4) + require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) t.Run("deleting a secure value", func(t *testing.T) { @@ -279,7 +386,7 @@ func TestModel(t *testing.T) { m := newModel() now := time.Now() - sv1, err := m.create(now, deepCopy(sv)) + sv1, err := m.create(now, sv.DeepCopy()) require.NoError(t, err) // Deleting a secure value @@ -306,7 +413,7 @@ func TestModel(t *testing.T) { require.Equal(t, 0, len(list.Items)) // Create a secure value - sv1, err := m.create(now, deepCopy(sv)) + sv1, err := m.create(now, sv.DeepCopy()) require.NoError(t, err) // 1 secure value exists and it should be returned @@ -333,7 +440,7 @@ func TestModel(t *testing.T) { // Create a secure value secret := "v1" - sv1, err := m.create(now, deepCopy(sv)) + sv1, err := m.create(now, sv.DeepCopy()) require.NoError(t, err) // Decrypt the just created secure value @@ -358,9 +465,9 @@ func TestStateMachine(t *testing.T) { "create": func(t *rapid.T) { sv := anySecureValueGen.Draw(t, "sv") - modelCreatedSv, modelErr := model.create(sut.Clock.Now(), deepCopy(sv)) + modelCreatedSv, modelErr := model.create(sut.Clock.Now(), sv.DeepCopy()) - createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(deepCopy(sv))) + createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -371,8 +478,8 @@ func TestStateMachine(t *testing.T) { }, "update": func(t *rapid.T) { sv := updateSecureValueGen.Draw(t, "sv") - modelCreatedSv, _, modelErr := model.update(sut.Clock.Now(), deepCopy(sv)) - createdSv, err := sut.UpdateSv(t.Context(), deepCopy(sv)) + modelCreatedSv, _, modelErr := model.update(sut.Clock.Now(), sv.DeepCopy()) + createdSv, err := sut.UpdateSv(t.Context(), sv.DeepCopy()) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -412,7 +519,8 @@ func TestStateMachine(t *testing.T) { if !slices.ContainsFunc(list.Items, func(v2 secretv1beta1.SecureValue) bool { return v2.Namespace == v1.Namespace && v2.Name == v1.Name && v2.Status.Version == v1.Status.Version }) { - t.Fatalf("expected sut to return secure value ns=%+v name=%+v version=%+v in the result", v1.Namespace, v1.Name, v1.Status.Version) + t.Fatalf("expected sut to return secure value ns=%+v name=%+v version=%+v in the result", + v1.Namespace, v1.Name, v1.Status.Version) } } }, @@ -439,8 +547,28 @@ func TestStateMachine(t *testing.T) { require.Equal(t, len(modelResult), len(result)) for name := range modelResult { - require.Equal(t, modelResult[name].Value(), result[name].Value()) require.Equal(t, modelResult[name].Error(), result[name].Error()) + require.Equal(t, modelResult[name].Value(), result[name].Value()) + } + }, + "createKeeper": func(t *rapid.T) { + input := anyKeeperGen.Draw(t, "keeper") + modelKeeper, modelErr := model.createKeeper(input) + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), input, "actor-uid") + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelKeeper.Name, keeper.Name) + }, + "setKeeperAsActive": func(t *rapid.T) { + namespace := namespaceGen.Draw(t, "namespace") + keeper := keeperNameGen.Draw(t, "keeper") + modelErr := model.setKeeperAsActive(namespace, keeper) + err := sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), keeper) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return } }, }) @@ -471,12 +599,67 @@ func TestSecureValueServiceExampleBased(t *testing.T) { require.Equal(t, 1, len(result)) require.ErrorIs(t, result[sv.Name].Error(), contracts.ErrDecryptNotFound) }) -} -func deepCopy[T any](sv T) T { - copied, err := copystructure.Copy(sv) - if err != nil { - panic(fmt.Sprintf("failed to copy secure value: %v", err)) - } - return copied.(T) + t.Run("should be able to use secrets that were created with a keeper that's inactive", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + // - Create a secret with k1 + k1, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "n1", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name)) + + value := secretv1beta1.NewExposedSecureValue("v1") + sv1, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(&secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{Namespace: k1.Namespace, Name: "s1"}, + Spec: secretv1beta1.SecureValueSpec{ + Description: "desc", + Value: &value, + }, + })) + require.NoError(t, err) + require.Equal(t, k1.Name, sv1.Status.Keeper) + + // - Set a new keeper as active + k2, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "n1", + Name: "k2", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name)) + + // - Read secure value created with inactive keeper + readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(sv1.Namespace), sv1.Name) + require.NoError(t, err) + require.Equal(t, sv1.Namespace, readSv.Namespace) + require.Equal(t, sv1.Name, readSv.Name) + require.Equal(t, k1.Name, readSv.Status.Keeper) + + // - Update secure value created with inactive keeper + newSv1 := sv1.DeepCopy() + newSv1.Spec.Description = "updated desc" + updatedSv, _, err := sut.SecureValueService.Update(t.Context(), newSv1, "actor-uid") + require.NoError(t, err) + require.Equal(t, sv1.Namespace, updatedSv.Namespace) + require.Equal(t, sv1.Name, updatedSv.Name) + require.Equal(t, k1.Name, updatedSv.Status.Keeper) + require.Equal(t, newSv1.Spec.Description, updatedSv.Spec.Description) + }) } diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_read_active-read active.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_read_active-read active.sql new file mode 100755 index 00000000000..3225cb1a9fe --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_read_active-read active.sql @@ -0,0 +1,20 @@ +SELECT + `guid`, + `name`, + `namespace`, + `annotations`, + `labels`, + `created`, + `created_by`, + `updated`, + `updated_by`, + `description`, + `type`, + `payload` +FROM + `secret_keeper` +WHERE + `namespace` = 'ns' AND + `active` = true +LIMIT 1 +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--keeper_set_as_active-keeper set as active.sql b/pkg/storage/secret/metadata/testdata/mysql--keeper_set_as_active-keeper set as active.sql new file mode 100755 index 00000000000..48d9b82f3fc --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/mysql--keeper_set_as_active-keeper set as active.sql @@ -0,0 +1,5 @@ +UPDATE `secret_keeper` +SET `active` = (`name` = 'name') +WHERE + `namespace` = 'ns' +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version-get latest secure value version.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql similarity index 76% rename from pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version-get latest secure value version.sql rename to pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql index 8118fc81489..661c6474221 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version-get latest secure value version.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql @@ -1,5 +1,7 @@ SELECT - `version` + `created`, + `version`, + `active` FROM `secret_secure_value` WHERE diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_read_active-read active.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_read_active-read active.sql new file mode 100755 index 00000000000..cd17093b053 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_read_active-read active.sql @@ -0,0 +1,20 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE + "namespace" = 'ns' AND + "active" = true +LIMIT 1 +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--keeper_set_as_active-keeper set as active.sql b/pkg/storage/secret/metadata/testdata/postgres--keeper_set_as_active-keeper set as active.sql new file mode 100755 index 00000000000..f88a830d557 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/postgres--keeper_set_as_active-keeper set as active.sql @@ -0,0 +1,5 @@ +UPDATE "secret_keeper" +SET "active" = ("name" = 'name') +WHERE + "namespace" = 'ns' +; diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version-get latest secure value version.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql similarity index 76% rename from pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version-get latest secure value version.sql rename to pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql index 1549114582d..e1c3834ecbb 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version-get latest secure value version.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql @@ -1,5 +1,7 @@ SELECT - "version" + "created", + "version", + "active" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_read_active-read active.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_read_active-read active.sql new file mode 100755 index 00000000000..cd17093b053 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_read_active-read active.sql @@ -0,0 +1,20 @@ +SELECT + "guid", + "name", + "namespace", + "annotations", + "labels", + "created", + "created_by", + "updated", + "updated_by", + "description", + "type", + "payload" +FROM + "secret_keeper" +WHERE + "namespace" = 'ns' AND + "active" = true +LIMIT 1 +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--keeper_set_as_active-keeper set as active.sql b/pkg/storage/secret/metadata/testdata/sqlite--keeper_set_as_active-keeper set as active.sql new file mode 100755 index 00000000000..f88a830d557 --- /dev/null +++ b/pkg/storage/secret/metadata/testdata/sqlite--keeper_set_as_active-keeper set as active.sql @@ -0,0 +1,5 @@ +UPDATE "secret_keeper" +SET "active" = ("name" = 'name') +WHERE + "namespace" = 'ns' +; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version-get latest secure value version.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql similarity index 76% rename from pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version-get latest secure value version.sql rename to pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql index 1549114582d..e1c3834ecbb 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version-get latest secure value version.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql @@ -1,5 +1,7 @@ SELECT - "version" + "created", + "version", + "active" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/migrator/migrator.go b/pkg/storage/secret/migrator/migrator.go index b08fb766e96..215020561a2 100644 --- a/pkg/storage/secret/migrator/migrator.go +++ b/pkg/storage/secret/migrator/migrator.go @@ -77,7 +77,7 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { } tables = append(tables, secureValueTable) - tables = append(tables, migrator.Table{ + keeperTable := migrator.Table{ Name: TableNameKeeper, Columns: []*migrator.Column{ // Kubernetes Metadata @@ -100,7 +100,8 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { Indices: []*migrator.Index{ {Cols: []string{"namespace", "name"}, Type: migrator.UniqueIndex}, }, - }) + } + tables = append(tables, keeperTable) dataKeyTable := migrator.Table{ Name: TableNameDataKey, @@ -211,4 +212,16 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { mg.AddMigration("add data_key_id index to "+TableNameEncryptedValue, migrator.NewAddIndexMigration(encryptedValueTable, &migrator.Index{ Cols: []string{"data_key_id"}, })) + mg.AddMigration("add active column to "+TableNameKeeper, migrator.NewAddColumnMigration(keeperTable, &migrator.Column{ + Name: "active", + Type: migrator.DB_Bool, + Nullable: false, + Default: "false", + })) + mg.AddMigration("add active column index to "+TableNameKeeper, migrator.NewAddIndexMigration(keeperTable, &migrator.Index{ + Cols: []string{"namespace", "name", "active"}, + })) + mg.AddMigration("set secret_secure_value.keeper to 'system' where keeper is null in "+TableNameSecureValue, migrator.NewRawSQLMigration( + fmt.Sprintf("UPDATE %s SET keeper = '%s' WHERE keeper IS NULL", TableNameSecureValue, contracts.SystemKeeperName), + )) } diff --git a/pkg/storage/unified/migrations/migrator.go b/pkg/storage/unified/migrations/migrator.go index bdbb69f98a1..d95aa59ace7 100644 --- a/pkg/storage/unified/migrations/migrator.go +++ b/pkg/storage/unified/migrations/migrator.go @@ -144,7 +144,6 @@ func newUnifiedMigrator( } } -// migrate function -- works for a single kind type migratorFunc = func(ctx context.Context, orgId int64, opts legacy.MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*legacy.BlobStoreInfo, error) func (m *unifiedMigration) Migrate(ctx context.Context, opts legacy.MigrateOptions) (*resourcepb.BulkResponse, error) { diff --git a/pkg/storage/unified/migrations/resource_migration.go b/pkg/storage/unified/migrations/resource_migration.go index 663bf030f67..281ee1c85e3 100644 --- a/pkg/storage/unified/migrations/resource_migration.go +++ b/pkg/storage/unified/migrations/resource_migration.go @@ -16,65 +16,35 @@ import ( ) // ValidationFunc is a function that validates migration results. -// It receives the database session, migration response, and logger for reporting. -// Return an error if validation fails, nil if validation passes or is skipped. -type ValidationFunc func(sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error + +type Validator interface { + Name() string + Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error +} // ResourceMigration handles migration of specific resource types from legacy to unified storage. -// It implements migrator.CodeMigration and provides a generic, extensible way to migrate any -// resource type by: -// -// 1. Iterating through all organizations -// 2. For each org, delegating to LegacyMigrator to read from legacy and write to unified storage -// 3. Validating migration results using the provided validation function (if any) -// -// To add a new resource type migration, simply create a new ResourceMigration instance in -// service.go with the appropriate schema.GroupResource specifications and optional validation function. type ResourceMigration struct { migrator.MigrationBase - migrator UnifiedMigrator - resources []schema.GroupResource - migrationID string - validationFunc ValidationFunc // Optional: custom validation logic for this migration - log log.Logger + migrator UnifiedMigrator + resources []schema.GroupResource + migrationID string + validators []Validator // Optional: custom validation logic for this migration + log log.Logger } // NewResourceMigration creates a new migration for the specified resources. -// This is the primary way to register new resource migrations. -// -// Parameters: -// - legacyMigrator: handles reading from legacy storage and writing to unified storage -// - resources: list of GroupResource to migrate -// - migrationID: unique identifier for this migration -// - validationFunc: optional validation function to verify migration results. -// If nil, no validation will be performed. -// -// Example with legacy table count validation: -// -// NewResourceMigration( -// migrator, -// []schema.GroupResource{{Group: "playlist.grafana.app", Resource: "playlists"}}, -// "playlists", -// NewLegacyTableCountValidator(map[string]LegacyTableInfo{ -// "playlist.grafana.app/playlists": {Table: "playlist", WhereClause: "org_id = ?"}, -// }), -// ) -// -// Example without validation: -// -// NewResourceMigration(migrator, resources, "new-resource", nil) func NewResourceMigration( migrator UnifiedMigrator, resources []schema.GroupResource, migrationID string, - validationFunc ValidationFunc, + validators []Validator, ) *ResourceMigration { return &ResourceMigration{ - migrator: migrator, - resources: resources, - migrationID: migrationID, - validationFunc: validationFunc, - log: log.New("storage.unified.resource_migration." + migrationID), + migrator: migrator, + resources: resources, + migrationID: migrationID, + validators: validators, + log: log.New("storage.unified.resource_migration." + migrationID), } } @@ -139,7 +109,7 @@ func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session, } // Validate the migration results - if err := m.validateMigration(sess, response); err != nil { + if err := m.validateMigration(migrationCtx, sess, response); err != nil { m.log.Error("Migration validation failed", "org_id", org.ID, "error", err, "duration", time.Since(startTime)) return fmt.Errorf("migration validation failed for org %d (%s): %w", org.ID, org.Name, err) } @@ -154,99 +124,22 @@ func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session, return nil } -// validateMigration calls the custom validation function if provided -func (m *ResourceMigration) validateMigration(sess *xorm.Session, response *resourcepb.BulkResponse) error { - if m.validationFunc == nil { - m.log.Debug("No validation function provided, skipping validation") +// validateMigration runs all validators in sequence +func (m *ResourceMigration) validateMigration(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse) error { + if len(m.validators) == 0 { + m.log.Debug("No validators provided, skipping validation") return nil } - return m.validationFunc(sess, response, m.log) -} - -// LegacyTableInfo defines how to map a unified storage resource to its legacy table -type LegacyTableInfo struct { - Table string // Legacy table name (e.g., "dashboard", "playlist") - WhereClause string // WHERE clause template with org_id parameter (e.g., "org_id = ? and is_folder = false") -} - -// NewLegacyTableCountValidator creates a ValidationFunc that validates migration by comparing -// counts between legacy tables and unified storage. -// -// This is a helper for the common case of validating that all items from legacy tables -// were successfully migrated to unified storage. -// -// Parameters: -// - legacyTableMap: maps "group/resource" keys to LegacyTableInfo for validation. -// Only resources with mappings will be validated. -// -// Example: -// -// validator := NewLegacyTableCountValidator(map[string]LegacyTableInfo{ -// "dashboard.grafana.app/dashboards": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = false"}, -// "folder.grafana.app/folders": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = true"}, -// }) -func NewLegacyTableCountValidator(legacyTableMap map[string]LegacyTableInfo) ValidationFunc { - return func(sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error { - // Check for rejected items - if len(response.Rejected) > 0 { - log.Warn("Migration had rejected items", "count", len(response.Rejected)) - for i, rejected := range response.Rejected { - if i < 10 { // Log first 10 rejected items - log.Warn("Rejected item", - "namespace", rejected.Key.Namespace, - "group", rejected.Key.Group, - "resource", rejected.Key.Resource, - "name", rejected.Key.Name, - "reason", rejected.Error) - } - } - // Rejections are not fatal - they may be expected for invalid data + for _, validator := range m.validators { + m.log.Debug("Running validator", "name", validator.Name(), "total", len(m.validators)) + if err := validator.Validate(ctx, sess, response, m.log); err != nil { + return fmt.Errorf("validator %s failed: %w", validator.Name(), err) } - - // Validate counts for each resource type - for _, summary := range response.Summary { - key := fmt.Sprintf("%s/%s", summary.Group, summary.Resource) - tableInfo, ok := legacyTableMap[key] - if !ok { - log.Debug("No legacy table mapping for resource, skipping count validation", - "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), - "namespace", summary.Namespace) - continue - } - - // Get legacy count - orgID, err := ParseOrgIDFromNamespace(summary.Namespace) - if err != nil { - return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err) - } - - legacyCount, err := sess.Table(tableInfo.Table).Where(tableInfo.WhereClause, orgID).Count() - if err != nil { - return fmt.Errorf("failed to count %s: %w", tableInfo.Table, err) - } - - // Account for rejected items in validation - expectedCount := summary.Count + int64(len(response.Rejected)) - - log.Info("Count validation", - "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), - "namespace", summary.Namespace, - "legacy_count", legacyCount, - "unified_count", summary.Count, - "rejected", len(response.Rejected), - "history", summary.History) - - // Validate that we migrated all items (allowing for rejected items) - if legacyCount > expectedCount { - return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d", - summary.Resource, summary.Group, summary.Namespace, - legacyCount, summary.Count, len(response.Rejected)) - } - } - - return nil } + + m.log.Debug("All validators passed", "count", len(m.validators)) + return nil } func ParseOrgIDFromNamespace(namespace string) (int64, error) { diff --git a/pkg/storage/unified/migrations/service.go b/pkg/storage/unified/migrations/service.go index 5bcb2fdf8c2..1a78f837066 100644 --- a/pkg/storage/unified/migrations/service.go +++ b/pkg/storage/unified/migrations/service.go @@ -11,6 +11,7 @@ import ( sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/migrations/contract" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" "k8s.io/apimachinery/pkg/runtime/schema" @@ -24,29 +25,28 @@ type UnifiedStorageMigrationServiceImpl struct { cfg *setting.Cfg sqlStore db.DB kv kvstore.KVStore + client resource.ResourceClient } var _ contract.UnifiedStorageMigrationService = (*UnifiedStorageMigrationServiceImpl)(nil) // ProvideUnifiedStorageMigrationService is a Wire provider that creates the migration service. -// The service implements registry.BackgroundService and runs migrations during server startup. func ProvideUnifiedStorageMigrationService( migrator UnifiedMigrator, cfg *setting.Cfg, sqlStore db.DB, kv kvstore.KVStore, + client resource.ResourceClient, ) contract.UnifiedStorageMigrationService { return &UnifiedStorageMigrationServiceImpl{ migrator: migrator, cfg: cfg, sqlStore: sqlStore, kv: kv, + client: client, } } -// Run executes unified storage migrations as a background service. -// This blocks until migrations complete. If migrations fail, an error is returned -// which will prevent Grafana from starting. func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error { // TODO: temporary skip migrations in test environments to prevent integration test timeouts. if os.Getenv("GRAFANA_TEST_DB") != "" { @@ -61,18 +61,15 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error { // TODO: Re-enable once migrations are ready // TODO: add guarantee that this only runs once - // return RegisterMigrations(p.migrator, p.cfg, p.sqlStore) + // return RegisterMigrations(p.migrator, p.cfg, p.sqlStore, p.client) return nil } -// RegisterMigrations initializes and registers all unified storage migrations. -// This function is the entry point for all data migrations from legacy storage -// to unified storage. It returns an error if migrations fail, preventing Grafana -// from starting with inconsistent data. func RegisterMigrations( migrator UnifiedMigrator, cfg *setting.Cfg, sqlStore db.DB, + client resource.ResourceClient, ) error { ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations") defer span.End() @@ -84,8 +81,7 @@ func RegisterMigrations( } // Register resource migrations - // To add a new resource type, simply add another migration here with the appropriate resources - registerResourceMigrations(mg, migrator) + registerDashboardAndFolderMigration(mg, migrator, client) // Run all registered migrations (blocking) sec := cfg.Raw.Section("database") @@ -99,20 +95,31 @@ func RegisterMigrations( return nil } -// registerResourceMigrations registers all unified storage resource migrations. -// Add new resource types here by creating additional ResourceMigration instances. -func registerResourceMigrations(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator) { +func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + folders := schema.GroupResource{Group: "folder.grafana.app", Resource: "folders"} + dashboards := schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"} + + folderCountValidator := NewCountValidator( + client, + folders, + "dashboard", + "org_id = ? and is_folder = true", + ) + + dashboardCountValidator := NewCountValidator( + client, + dashboards, + "dashboard", + "org_id = ? and is_folder = false", + ) + + folderTreeValidator := NewFolderTreeValidator(client, folders) + dashboardsAndFolders := NewResourceMigration( migrator, - []schema.GroupResource{ - {Group: "folder.grafana.app", Resource: "folders"}, - {Group: "dashboard.grafana.app", Resource: "dashboards"}, - }, + []schema.GroupResource{folders, dashboards}, "folders-dashboards", - NewLegacyTableCountValidator(map[string]LegacyTableInfo{ - "folder.grafana.app/folders": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = true"}, - "dashboard.grafana.app/dashboards": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = false"}, - }), + []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, ) mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) } diff --git a/pkg/storage/unified/migrations/validator.go b/pkg/storage/unified/migrations/validator.go new file mode 100644 index 00000000000..7a973976402 --- /dev/null +++ b/pkg/storage/unified/migrations/validator.go @@ -0,0 +1,350 @@ +package migrations + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/util/xorm" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func filterResponse(response *resourcepb.BulkResponse, resources []schema.GroupResource) *resourcepb.BulkResponse { + if len(resources) == 0 { + return response + } + + // Create a resource lookup map for efficient filtering + resourceMap := make(map[string]bool) + for _, res := range resources { + key := fmt.Sprintf("%s/%s", res.Group, res.Resource) + resourceMap[key] = true + } + + // Filter summaries to only include matching resources + var filteredSummaries []*resourcepb.BulkResponse_Summary + for _, summary := range response.Summary { + key := fmt.Sprintf("%s/%s", summary.Group, summary.Resource) + if resourceMap[key] { + filteredSummaries = append(filteredSummaries, summary) + } + } + + // Filter rejected items to only include matching resources + var filteredRejected []*resourcepb.BulkResponse_Rejected + for _, rejected := range response.Rejected { + if rejected.Key != nil { + key := fmt.Sprintf("%s/%s", rejected.Key.Group, rejected.Key.Resource) + if resourceMap[key] { + filteredRejected = append(filteredRejected, rejected) + } + } + } + + // Create filtered response, preserving original processed count for percentage calculations + return &resourcepb.BulkResponse{ + Error: response.Error, + Processed: response.Processed, + Summary: filteredSummaries, + Rejected: filteredRejected, + } +} + +type CountValidator struct { + name string + client resourcepb.ResourceIndexClient + resource schema.GroupResource + table string + whereClause string +} + +func NewCountValidator( + client resourcepb.ResourceIndexClient, + resource schema.GroupResource, + table string, + whereClause string, +) Validator { + return &CountValidator{ + name: "CountValidator", + client: client, + resource: resource, + table: table, + whereClause: whereClause, + } +} + +func (v *CountValidator) Name() string { + return v.name +} + +func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error { + // Filter response to only include the configured resource + response = filterResponse(response, []schema.GroupResource{v.resource}) + if len(response.Rejected) > 0 { + log.Warn("Migration had rejected items", "count", len(response.Rejected)) + for i, rejected := range response.Rejected { + if i < 10 { // Log first 10 rejected items + log.Warn("Rejected item", + "namespace", rejected.Key.Namespace, + "group", rejected.Key.Group, + "resource", rejected.Key.Resource, + "name", rejected.Key.Name, + "reason", rejected.Error) + } + } + // Rejections are not fatal - they may be expected for invalid data + } + + // Should have at most one summary after filtering + if len(response.Summary) == 0 { + log.Debug("No summaries found for resource, skipping count validation", + "resource", fmt.Sprintf("%s.%s", v.resource.Resource, v.resource.Group)) + return nil + } + + if len(response.Summary) > 1 { + return fmt.Errorf("expected at most 1 summary after filtering, got %d", len(response.Summary)) + } + + summary := response.Summary[0] + + // Get legacy count from database + orgID, err := ParseOrgIDFromNamespace(summary.Namespace) + if err != nil { + return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err) + } + + legacyCount, err := sess.Table(v.table).Where(v.whereClause, orgID).Count() + if err != nil { + return fmt.Errorf("failed to count %s: %w", v.table, err) + } + + // Get unified storage count using GetStats API + statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{ + Namespace: summary.Namespace, + Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)}, + }) + if err != nil { + return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w", + summary.Group, summary.Resource, summary.Namespace, err) + } + + // Find the count for this specific resource type + var unifiedCount int64 + for _, stat := range statsResp.Stats { + if stat.Group == summary.Group && stat.Resource == summary.Resource { + unifiedCount = stat.Count + break + } + } + + // Account for rejected items in validation + expectedCount := unifiedCount + int64(len(response.Rejected)) + + log.Info("Count validation", + "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), + "namespace", summary.Namespace, + "legacy_count", legacyCount, + "unified_count", unifiedCount, + "migration_summary_count", summary.Count, + "rejected", len(response.Rejected), + "history", summary.History) + + // Validate that we migrated all items (allowing for rejected items) + if legacyCount > expectedCount { + return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d", + summary.Resource, summary.Group, summary.Namespace, + legacyCount, unifiedCount, len(response.Rejected)) + } + + return nil +} + +type FolderTreeValidator struct { + name string + client resourcepb.ResourceIndexClient + resource schema.GroupResource +} + +func NewFolderTreeValidator( + client resourcepb.ResourceIndexClient, + resource schema.GroupResource, +) Validator { + return &FolderTreeValidator{ + name: "FolderTreeValidator", + client: client, + resource: resource, + } +} + +type legacyFolder struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + FolderUID string `xorm:"folder_uid"` + Title string `xorm:"title"` +} + +func (v *FolderTreeValidator) Name() string { + return v.name +} + +func (v *FolderTreeValidator) Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error { + // Filter response to only include the configured resource (folders) + response = filterResponse(response, []schema.GroupResource{v.resource}) + + // Should have at most one summary after filtering + if len(response.Summary) == 0 { + log.Debug("No summaries found for folders, skipping folder tree validation") + return nil + } + + if len(response.Summary) > 1 { + return fmt.Errorf("expected at most 1 summary after filtering, got %d", len(response.Summary)) + } + + summary := response.Summary[0] + + // Get orgID from namespace + orgID, err := ParseOrgIDFromNamespace(summary.Namespace) + if err != nil { + return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err) + } + + // Build legacy folder parent map + legacyParentMap, err := v.buildLegacyFolderParentMap(sess, orgID, log) + if err != nil { + return fmt.Errorf("failed to build legacy folder parent map: %w", err) + } + + // Build unified storage folder parent map + unifiedParentMap, err := v.buildUnifiedFolderParentMap(ctx, summary.Namespace, log) + if err != nil { + return fmt.Errorf("failed to build unified folder parent map: %w", err) + } + + // Compare the two maps + mismatches := []string{} + for uid, legacyParent := range legacyParentMap { + unifiedParent, exists := unifiedParentMap[uid] + if !exists { + // Folder exists in legacy but not in unified - might be rejected, skip + log.Debug("Folder exists in legacy but not in unified storage", + "uid", uid, + "legacy_parent", legacyParent) + continue + } + + if legacyParent != unifiedParent { + mismatch := fmt.Sprintf("folder %s: legacy parent=%s, unified parent=%s", + uid, legacyParent, unifiedParent) + mismatches = append(mismatches, mismatch) + log.Warn("Folder parent mismatch", + "uid", uid, + "legacy_parent", legacyParent, + "unified_parent", unifiedParent) + } + } + + // Check for folders in unified but not in legacy (shouldn't happen) + for uid := range unifiedParentMap { + if _, exists := legacyParentMap[uid]; !exists { + mismatch := fmt.Sprintf("folder %s exists in unified but not in legacy", uid) + mismatches = append(mismatches, mismatch) + log.Warn("Folder exists in unified but not in legacy", "uid", uid) + } + } + + if len(mismatches) > 0 { + log.Error("Folder tree structure validation failed", + "mismatch_count", len(mismatches), + "total_legacy_folders", len(legacyParentMap), + "total_unified_folders", len(unifiedParentMap)) + return fmt.Errorf("folder tree structure mismatch: %d folders have incorrect parents", len(mismatches)) + } + + log.Info("Folder tree structure validation passed", + "folder_count", len(legacyParentMap), + "namespace", summary.Namespace) + + return nil +} + +func (v *FolderTreeValidator) buildLegacyFolderParentMap(sess *xorm.Session, orgID int64, log log.Logger) (map[string]string, error) { + // Query all folders for this org + var folders []legacyFolder + err := sess.Table("dashboard"). + Cols("id", "uid", "folder_uid", "title"). + Where("org_id = ? AND is_folder = ?", orgID, true). + Find(&folders) + if err != nil { + return nil, fmt.Errorf("failed to query legacy folders: %w", err) + } + + parentMap := make(map[string]string) + for _, folder := range folders { + parentMap[folder.UID] = folder.FolderUID + } + + if len(parentMap) == 0 { + log.Debug("No legacy folders found for org", "org_id", orgID) + return make(map[string]string), nil + } + + log.Debug("Built legacy folder parent map", + "folder_count", len(parentMap), + "org_id", orgID) + + return parentMap, nil +} + +func (v *FolderTreeValidator) buildUnifiedFolderParentMap(ctx context.Context, namespace string, log log.Logger) (map[string]string, error) { + // Search for all folders in this namespace + searchResp, err := v.client.Search(ctx, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: namespace, + Group: v.resource.Group, + Resource: v.resource.Resource, + }, + }, + Limit: 100000, // Large limit to get all folders + }) + if err != nil { + return nil, fmt.Errorf("failed to search folders in unified storage: %w", err) + } + + if searchResp.Results == nil { + return make(map[string]string), nil + } + + parentMap := make(map[string]string) + for _, row := range searchResp.Results.Rows { + if row.Key == nil { + continue + } + + folderUID := row.Key.Name + parentUID := "" + + folderColIdx := -1 + for i, col := range searchResp.Results.Columns { + if col.Name == "folder" { + folderColIdx = i + break + } + } + + if folderColIdx >= 0 && folderColIdx < len(row.Cells) { + parentUID = string(row.Cells[folderColIdx]) + } + + parentMap[folderUID] = parentUID + } + + log.Debug("Built unified folder parent map", + "folder_count", len(parentMap), + "namespace", namespace) + + return parentMap, nil +} diff --git a/pkg/storage/unified/proto/search.proto b/pkg/storage/unified/proto/search.proto index d206060daf4..5018c97c9db 100644 --- a/pkg/storage/unified/proto/search.proto +++ b/pkg/storage/unified/proto/search.proto @@ -13,6 +13,8 @@ service ResourceIndex { // Get the resource stats rpc GetStats(ResourceStatsRequest) returns (ResourceStatsResponse); + + rpc RebuildIndexes(RebuildIndexesRequest) returns (RebuildIndexesResponse); } // Get statistics across multiple resources @@ -138,3 +140,23 @@ message ResourceSearchResponse { // Facet results map facet = 7; } + +message RebuildIndexesRequest { + // Namespace (tenant) must be the same as all keys' namespace + string namespace = 1; + + // List of ResourceKeys (Namespace + Group + Resource) + repeated ResourceKey keys = 2; +} + +message RebuildIndexesResponse { + // Total count of rebuilt indexes + int64 rebuildCount = 1; + + // Result message + string details = 2; + + // Error details + ErrorResult error = 3; +} + diff --git a/pkg/storage/unified/resource/access.go b/pkg/storage/unified/resource/access.go index 94cd5b4a801..58a9d8b8b76 100644 --- a/pkg/storage/unified/resource/access.go +++ b/pkg/storage/unified/resource/access.go @@ -3,7 +3,6 @@ package resource import ( "context" "fmt" - "log/slog" "sync" "time" @@ -14,6 +13,8 @@ import ( "go.opentelemetry.io/otel/trace/noop" claims "github.com/grafana/authlib/types" + + "github.com/grafana/grafana/pkg/infra/log" ) type groupResource map[string]map[string]interface{} @@ -75,7 +76,7 @@ type authzLimitedClient struct { client claims.AccessClient // allowlist is a map of group to resources that are compatible with RBAC. allowlist groupResource - logger *slog.Logger + logger log.Logger tracer trace.Tracer metrics *accessMetrics } @@ -87,7 +88,7 @@ type AuthzOptions struct { // NewAuthzLimitedClient creates a new authzLimitedClient. func NewAuthzLimitedClient(client claims.AccessClient, opts AuthzOptions) claims.AccessClient { - logger := slog.Default().With("logger", "limited-authz-client") + logger := log.New("limited-authz-client") if opts.Tracer == nil { opts.Tracer = noop.NewTracerProvider().Tracer("limited-authz-client") } @@ -147,7 +148,7 @@ func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req c } resp, err := c.client.Check(ctx, id, req, folder) if err != nil { - c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "error", err, "duration", time.Since(t), "traceid", trace.SpanContextFromContext(ctx).TraceID().String()) + c.logger.FromContext(ctx).Error("Check", "group", req.Group, "resource", req.Resource, "error", err, "duration", time.Since(t)) c.metrics.errorsTotal.WithLabelValues(req.Group, req.Resource, req.Verb).Inc() span.SetStatus(codes.Error, fmt.Sprintf("check failed: %v", err)) span.RecordError(err) @@ -197,7 +198,7 @@ func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req } checker, zookie, err := c.client.Compile(ctx, id, req) if err != nil { - c.logger.Error("Compile", "group", req.Group, "resource", req.Resource, "error", err, "traceid", trace.SpanContextFromContext(ctx).TraceID().String()) + c.logger.FromContext(ctx).Error("Compile", "group", req.Group, "resource", req.Resource, "error", err) c.metrics.errorsTotal.WithLabelValues(req.Group, req.Resource, req.Verb).Inc() span.SetStatus(codes.Error, fmt.Sprintf("compile failed: %v", err)) span.RecordError(err) diff --git a/pkg/storage/unified/resource/client.go b/pkg/storage/unified/resource/client.go index e7d43a7bbcc..b2742f71dc9 100644 --- a/pkg/storage/unified/resource/client.go +++ b/pkg/storage/unified/resource/client.go @@ -7,7 +7,6 @@ import ( "crypto/rand" "crypto/tls" "fmt" - "log/slog" "net/http" "github.com/fullstorydev/grpchan" @@ -24,6 +23,7 @@ import ( "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/log" authnGrpcUtils "github.com/grafana/grafana/pkg/services/authn/grpcutils" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" @@ -158,7 +158,7 @@ func NewRemoteResourceClient(tracer trace.Tracer, conn grpc.ClientConnInterface, return newResourceClient(cc, cci), nil } -var authLogger = slog.Default().With("logger", "resource-client-auth-interceptor") +var authLogger = log.New("resource-client-auth-interceptor") func idTokenExtractor(ctx context.Context) (string, error) { if identity.IsServiceIdentity(ctx) { @@ -175,7 +175,7 @@ func idTokenExtractor(ctx context.Context) (string, error) { } if !types.IsIdentityType(info.GetIdentityType(), types.TypeAccessPolicy) { - authLogger.Warn( + authLogger.FromContext(ctx).Warn( "calling resource store as the service without id token or marking it as the service identity", "subject", info.GetSubject(), "uid", info.GetUID(), diff --git a/pkg/storage/unified/resource/client_mock.go b/pkg/storage/unified/resource/client_mock.go index 23688b097ec..fcc7392880e 100644 --- a/pkg/storage/unified/resource/client_mock.go +++ b/pkg/storage/unified/resource/client_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package resource @@ -838,6 +838,80 @@ func (_c *MockResourceClient_Read_Call) RunAndReturn(run func(context.Context, * return _c } +// RebuildIndexes provides a mock function with given fields: ctx, in, opts +func (_m *MockResourceClient) RebuildIndexes(ctx context.Context, in *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, in) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for RebuildIndexes") + } + + var r0 *resourcepb.RebuildIndexesResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *resourcepb.RebuildIndexesRequest, ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error)); ok { + return rf(ctx, in, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, *resourcepb.RebuildIndexesRequest, ...grpc.CallOption) *resourcepb.RebuildIndexesResponse); ok { + r0 = rf(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*resourcepb.RebuildIndexesResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *resourcepb.RebuildIndexesRequest, ...grpc.CallOption) error); ok { + r1 = rf(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockResourceClient_RebuildIndexes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RebuildIndexes' +type MockResourceClient_RebuildIndexes_Call struct { + *mock.Call +} + +// RebuildIndexes is a helper method to define mock.On call +// - ctx context.Context +// - in *resourcepb.RebuildIndexesRequest +// - opts ...grpc.CallOption +func (_e *MockResourceClient_Expecter) RebuildIndexes(ctx interface{}, in interface{}, opts ...interface{}) *MockResourceClient_RebuildIndexes_Call { + return &MockResourceClient_RebuildIndexes_Call{Call: _e.mock.On("RebuildIndexes", + append([]interface{}{ctx, in}, opts...)...)} +} + +func (_c *MockResourceClient_RebuildIndexes_Call) Run(run func(ctx context.Context, in *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption)) *MockResourceClient_RebuildIndexes_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]grpc.CallOption, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(grpc.CallOption) + } + } + run(args[0].(context.Context), args[1].(*resourcepb.RebuildIndexesRequest), variadicArgs...) + }) + return _c +} + +func (_c *MockResourceClient_RebuildIndexes_Call) Return(_a0 *resourcepb.RebuildIndexesResponse, _a1 error) *MockResourceClient_RebuildIndexes_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockResourceClient_RebuildIndexes_Call) RunAndReturn(run func(context.Context, *resourcepb.RebuildIndexesRequest, ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error)) *MockResourceClient_RebuildIndexes_Call { + _c.Call.Return(run) + return _c +} + // Search provides a mock function with given fields: ctx, in, opts func (_m *MockResourceClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { _va := make([]interface{}, len(opts)) diff --git a/pkg/storage/unified/resource/grpc/authenticator.go b/pkg/storage/unified/resource/grpc/authenticator.go index 270835a85ef..6cc601565b7 100644 --- a/pkg/storage/unified/resource/grpc/authenticator.go +++ b/pkg/storage/unified/resource/grpc/authenticator.go @@ -2,7 +2,6 @@ package grpc import ( "context" - "log/slog" "strconv" "google.golang.org/grpc" @@ -14,6 +13,7 @@ import ( "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/log" ) const ( @@ -25,7 +25,7 @@ const ( mdOrgRole = "grafana-org-role" ) -var logger = slog.Default().With("logger", "legacy.grpc.Authenticator") +var logger = log.New("legacy.grpc.Authenticator") // This is in a package we can no import // var _ interceptors.Authenticator = (*Authenticator)(nil) diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 2752c59650d..bca5fb98491 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -4,7 +4,6 @@ import ( "cmp" "context" "fmt" - "log/slog" "slices" "strings" "sync" @@ -23,6 +22,7 @@ import ( dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/debouncer" @@ -129,7 +129,7 @@ type SearchBackend interface { // This supports indexing+search regardless of implementation type searchSupport struct { - log *slog.Logger + log log.Logger storage StorageBackend search SearchBackend indexMetrics *BleveIndexMetrics @@ -184,7 +184,7 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.A access: access, storage: storage, search: opts.Backend, - log: slog.Default().With("logger", "resource-search"), + log: log.New("resource-search"), initWorkers: opts.InitWorkerThreads, rebuildWorkers: opts.IndexRebuildWorkers, initMinSize: opts.InitMinCount, @@ -234,6 +234,9 @@ func combineRebuildRequests(a, b rebuildRequest) (c rebuildRequest, ok bool) { ret.lastImportTime = b.lastImportTime } + // Combine complete channels + ret.completeChannels = append(a.completeChannels, b.completeChannels...) + return ret, true } @@ -295,14 +298,6 @@ func (s *searchSupport) ListManagedObjects(ctx context.Context, req *resourcepb. return rsp, nil } -func (s *searchSupport) logWithTraceID(ctx context.Context) *slog.Logger { - l := s.log - if traceID := tracing.TraceIDFromContext(ctx, false); traceID != "" { - l = l.With("traceID", traceID) - } - return l -} - func (s *searchSupport) logStats(ctx context.Context, stats *SearchStats, span trace.Span, params ...any) { elapsed := time.Since(stats.startTime) @@ -319,7 +314,7 @@ func (s *searchSupport) logStats(ctx context.Context, stats *SearchStats, span t } args = append(args, params...) - s.logWithTraceID(ctx).Debug("Search stats", args...) + s.log.FromContext(ctx).Debug("Search stats", args...) if span != nil { attrs := make([]attribute.KeyValue, 0, len(args)/2) @@ -520,6 +515,52 @@ func (s *searchSupport) GetStats(ctx context.Context, req *resourcepb.ResourceSt return rsp, nil } +func (s *searchSupport) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest) (*resourcepb.RebuildIndexesResponse, error) { + ctx, span := tracer.Start(ctx, "resource.searchSupport.RebuildIndexes") + defer span.End() + + filterKeys := make([]NamespacedResource, len(req.Keys)) + for _, key := range req.Keys { + if req.Namespace != key.Namespace { + return &resourcepb.RebuildIndexesResponse{ + Error: NewBadRequestError("key namespace does not match request namespace"), + }, nil + } + filterKeys = append(filterKeys, NamespacedResource{ + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }) + } + + importTimes, err := s.getLastImportTimes(ctx) + if err != nil { + return &resourcepb.RebuildIndexesResponse{ + Error: AsErrorResult(err), + }, nil + } + + completeChs := s.findIndexesToRebuild(importTimes, filterKeys, time.Now()) + rebuildCount := len(completeChs) + for _, ch := range completeChs { + select { + case <-ch: + continue + case <-ctx.Done(): // request was done before all indexes rebuilt + return &resourcepb.RebuildIndexesResponse{ + RebuildCount: int64(rebuildCount), + Details: fmt.Sprintf("returning before all index rebuilds completed for %d indexes", rebuildCount), + }, nil + } + } + + // All rebuilds completed successfully + return &resourcepb.RebuildIndexesResponse{ + RebuildCount: int64(rebuildCount), + Details: fmt.Sprintf("completed %d index rebuilds", rebuildCount), + }, nil +} + func (s *searchSupport) buildIndexes(ctx context.Context) (int, error) { totalBatchesIndexed := 0 group := errgroup.Group{} @@ -610,16 +651,23 @@ func (s *searchSupport) runPeriodicScanForIndexesToRebuild(ctx context.Context) if err != nil { s.log.Error("failed to get import times", "error", err) } - s.findIndexesToRebuild(importTimes, time.Now()) + s.findIndexesToRebuild(importTimes, nil, time.Now()) } } } -func (s *searchSupport) findIndexesToRebuild(lastImportTimes map[NamespacedResource]time.Time, now time.Time) { +func (s *searchSupport) findIndexesToRebuild(lastImportTimes map[NamespacedResource]time.Time, filterKeys []NamespacedResource, now time.Time) []chan struct{} { // Check all open indexes and see if any of them need to be rebuilt. // This is done periodically to make sure that the indexes are up to date. - keys := s.search.GetOpenIndexes() + var keys []NamespacedResource + if filterKeys != nil { + keys = filterKeys + } else { + keys = s.search.GetOpenIndexes() + } + + var completeChs []chan struct{} for _, key := range keys { idx := s.search.GetIndex(key) if idx == nil { @@ -646,18 +694,17 @@ func (s *searchSupport) findIndexesToRebuild(lastImportTimes map[NamespacedResou } if shouldRebuildIndex(bi, s.minBuildVersion, minBuildTime, lastImportTime, nil) { - s.rebuildQueue.Add(rebuildRequest{ - NamespacedResource: key, - minBuildTime: minBuildTime, - minBuildVersion: s.minBuildVersion, - lastImportTime: lastImportTime, - }) + completeCh := make(chan struct{}) + completeChs = append(completeChs, completeCh) + rebuildReq := newRebuildRequest(key, minBuildTime, lastImportTime, s.minBuildVersion, completeCh) + s.rebuildQueue.Add(rebuildReq) if s.indexMetrics != nil { s.indexMetrics.RebuildQueueLength.Set(float64(s.rebuildQueue.Len())) } } } + return completeChs } func (s *searchSupport) getLastImportTimes(ctx context.Context) (map[NamespacedResource]time.Time, error) { @@ -696,7 +743,13 @@ func (s *searchSupport) rebuildIndex(ctx context.Context, req rebuildRequest) { ctx, span := tracer.Start(ctx, "resource.searchSupport.rebuildIndex") defer span.End() - l := s.log.With("namespace", req.Namespace, "group", req.Group, "resource", req.Resource) + l := s.log.New("namespace", req.Namespace, "group", req.Group, "resource", req.Resource) + + defer func() { + for _, ch := range req.completeChannels { + close(ch) + } + }() idx := s.search.GetIndex(req.NamespacedResource) if idx == nil { @@ -752,7 +805,7 @@ func (s *searchSupport) rebuildIndex(ctx context.Context, req rebuildRequest) { } } -func shouldRebuildIndex(buildInfo IndexBuildInfo, minBuildVersion *semver.Version, minBuildTime time.Time, lastImportTime time.Time, rebuildLogger *slog.Logger) bool { +func shouldRebuildIndex(buildInfo IndexBuildInfo, minBuildVersion *semver.Version, minBuildTime time.Time, lastImportTime time.Time, rebuildLogger log.Logger) bool { if !minBuildTime.IsZero() { if buildInfo.BuildTime.IsZero() || buildInfo.BuildTime.Before(minBuildTime) { if rebuildLogger != nil { @@ -790,6 +843,22 @@ type rebuildRequest struct { minBuildTime time.Time // if not zero, rebuild index if it has been built before this timestamp lastImportTime time.Time // if not zero, rebuild index if it has been built before this timestamp. minBuildVersion *semver.Version // if not nil, rebuild index with build version older than this. + + completeChannels []chan<- struct{} // signal rebuild index is complete +} + +func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time.Time, minBuildVersion *semver.Version, completeCh chan<- struct{}) rebuildRequest { + var completeChannels []chan<- struct{} // setup a list as requests can be combined + if completeCh != nil { + completeChannels = []chan<- struct{}{completeCh} + } + return rebuildRequest{ + NamespacedResource: key, + minBuildTime: minBuildTime, + minBuildVersion: minBuildVersion, + lastImportTime: lastImportTime, + completeChannels: completeChannels, + } } func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, key NamespacedResource, reason string) (ResourceIndex, error) { @@ -873,7 +942,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats if s.indexMetrics != nil { s.indexMetrics.SearchUpdateWaitTime.WithLabelValues(reason).Observe(elapsed.Seconds()) } - s.logWithTraceID(ctx).Debug("Index updated before search", "namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "reason", reason, "duration", elapsed, "rv", rv) + s.log.FromContext(ctx).Debug("Index updated before search", "namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "reason", reason, "duration", elapsed, "rv", rv) span.AddEvent("Index updated") return idx, nil @@ -890,7 +959,7 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size attribute.Int64("size", size), ) - logger := s.logWithTraceID(ctx).With("namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource) + logger := s.log.New("namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource) builder, err := s.builders.get(ctx, nsr) if err != nil { diff --git a/pkg/storage/unified/resource/search_client.go b/pkg/storage/unified/resource/search_client.go index c4320114ba5..cfe0b0e594f 100644 --- a/pkg/storage/unified/resource/search_client.go +++ b/pkg/storage/unified/resource/search_client.go @@ -211,6 +211,11 @@ func (s *searchWrapper) Search(ctx context.Context, in *resourcepb.ResourceSearc return client.Search(ctx, in, opts...) } +func (s *searchWrapper) RebuildIndexes(ctx context.Context, in *resourcepb.RebuildIndexesRequest, + opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) { + return s.unifiedClient.RebuildIndexes(ctx, in, opts...) +} + // compareSearchResults compares legacy and unified search results and logs/metrics the outcome func (s *searchWrapper) compareSearchResults(legacyResponse, unifiedResponse *resourcepb.ResourceSearchResponse, requestKey *resourcepb.ResourceKey) { if legacyResponse == nil || unifiedResponse == nil { diff --git a/pkg/storage/unified/resource/search_client_test.go b/pkg/storage/unified/resource/search_client_test.go index 4b0a73895ec..f7676ad981e 100644 --- a/pkg/storage/unified/resource/search_client_test.go +++ b/pkg/storage/unified/resource/search_client_test.go @@ -112,6 +112,11 @@ func (m *MockResourceIndexClient) GetStats(ctx context.Context, in *resourcepb.R return args.Get(0).(*resourcepb.ResourceStatsResponse), args.Error(1) } +func (m *MockResourceIndexClient) RebuildIndexes(ctx context.Context, in *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) { + args := m.Called(ctx, in, opts) + return args.Get(0).(*resourcepb.RebuildIndexesResponse), args.Error(1) +} + func setupTestSearchClient(t *testing.T) (schema.GroupResource, *MockResourceIndexClient, *MockResourceIndexClient, featuremgmt.FeatureToggles) { t.Helper() gr := schema.GroupResource{Group: "test", Resource: "items"} diff --git a/pkg/storage/unified/resource/search_server_distributor.go b/pkg/storage/unified/resource/search_server_distributor.go index 1363c2452f3..cceb0845b11 100644 --- a/pkg/storage/unified/resource/search_server_distributor.go +++ b/pkg/storage/unified/resource/search_server_distributor.go @@ -2,8 +2,12 @@ package resource import ( "context" + "errors" + "fmt" "hash/fnv" "math/rand" + "sync" + "sync/atomic" "time" "github.com/grafana/dskit/ring" @@ -114,6 +118,100 @@ func (ds *distributorServer) GetStats(ctx context.Context, r *resourcepb.Resourc return client.GetStats(ctx, r) } +func (ds *distributorServer) RebuildIndexes(ctx context.Context, r *resourcepb.RebuildIndexesRequest) (*resourcepb.RebuildIndexesResponse, error) { + ctx, span := ds.tracing.Start(ctx, "distributor.RebuildIndexes") + defer span.End() + + // validate input + for _, key := range r.Keys { + if r.Namespace != key.Namespace { + return &resourcepb.RebuildIndexesResponse{ + Error: NewBadRequestError("key namespace does not match request namespace"), + }, nil + } + } + + // distribute the request to all search pods to minimize risk of stale index + // it will not rebuild on those which don't have the index open + rs, err := ds.ring.GetAllHealthy(searchRingRead) + if err != nil { + return nil, fmt.Errorf("failed to get all healthy instances from the ring") + } + + err = grpc.SetHeader(ctx, metadata.Pairs("proxied-instance-id", "all")) + if err != nil { + ds.log.Debug("error setting grpc header", "err", err) + } + + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + md = make(metadata.MD) + } + rCtx := userutils.InjectOrgID(metadata.NewOutgoingContext(ctx, md), r.Namespace) + + var wg sync.WaitGroup + var totalRebuildCount atomic.Int64 + detailsCh := make(chan string, len(rs.Instances)) + errorCh := make(chan error, len(rs.Instances)) + + for _, inst := range rs.Instances { + wg.Add(1) + go func() { + defer wg.Done() + + client, err := ds.clientPool.GetClientForInstance(inst) + if err != nil { + errorCh <- fmt.Errorf("instance %s: failed to get client, %w", inst.Id, err) + return + } + + rsp, err := client.(*RingClient).Client.RebuildIndexes(rCtx, r) + if err != nil { + errorCh <- fmt.Errorf("instance %s: failed to distribute rebuild index request, %w", inst.Id, err) + return + } + + if rsp.Error != nil { + errorCh <- fmt.Errorf("instance %s: rebuild index request returned the error %s", inst.Id, rsp.Error.Message) + return + } + + if rsp.Details != "" { + detailsCh <- fmt.Sprintf("{instance: %s, details: %s}", inst.Id, rsp.Details) + } + + totalRebuildCount.Add(rsp.RebuildCount) + }() + } + + wg.Wait() + close(errorCh) + close(detailsCh) + + errs := make([]error, 0, len(errorCh)) + for err := range errorCh { + ds.log.Error("rebuild indexes call failed with %w", err) + errs = append(errs, err) + } + + var details string + for d := range detailsCh { + if len(details) > 0 { + details += ", " + } + details += d + } + + response := &resourcepb.RebuildIndexesResponse{ + RebuildCount: totalRebuildCount.Load(), + Details: details, + } + if len(errs) > 0 { + response.Error = AsErrorResult(errors.Join(errs...)) + } + return response, nil +} + func (ds *distributorServer) CountManagedObjects(ctx context.Context, r *resourcepb.CountManagedObjectsRequest) (*resourcepb.CountManagedObjectsResponse, error) { ctx, span := ds.tracing.Start(ctx, "distributor.CountManagedObjects") defer span.End() diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index 215a88cd2cd..154608a4eee 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -10,6 +10,8 @@ import ( "time" "github.com/Masterminds/semver" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/grafana/authlib/types" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -85,7 +87,8 @@ func (m *MockDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb // mockStorageBackend implements StorageBackend for testing type mockStorageBackend struct { - resourceStats []ResourceStats + resourceStats []ResourceStats + lastImportTimes []ResourceLastImportTime } func (m *mockStorageBackend) GetResourceStats(ctx context.Context, nsr NamespacedResource, minCount int) ([]ResourceStats, error) { @@ -127,7 +130,11 @@ func (m *mockStorageBackend) ListModifiedSince(ctx context.Context, key Namespac func (m *mockStorageBackend) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[ResourceLastImportTime, error] { return func(yield func(ResourceLastImportTime, error) bool) { - yield(ResourceLastImportTime{}, errors.New("not implemented")) + for _, ti := range m.lastImportTimes { + if !yield(ti, nil) { + return + } + } } } @@ -605,14 +612,14 @@ func TestFindIndexesForRebuild(t *testing.T) { {Namespace: "resource-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: lastImportTime, } - support.findIndexesToRebuild(importTimes, now) + support.findIndexesToRebuild(importTimes, nil, now) require.Equal(t, 7, support.rebuildQueue.Len()) now5m := now.Add(5 * time.Minute) // Running findIndexesToRebuild again should not add any new indexes to the rebuild queue, and all existing // ones should be "combined" with new ones (this will "bump" minBuildTime) - support.findIndexesToRebuild(importTimes, now5m) + support.findIndexesToRebuild(importTimes, nil, now5m) require.Equal(t, 7, support.rebuildQueue.Len()) // Values that we expect to find in rebuild requests. @@ -621,7 +628,7 @@ func TestFindIndexesForRebuild(t *testing.T) { minBuildTimeDashboard := now5m.Add(-1 * time.Hour) vals := support.rebuildQueue.Elements() - require.ElementsMatch(t, vals, []rebuildRequest{ + expected := []rebuildRequest{ {NamespacedResource: NamespacedResource{Namespace: "resource-2h-v5", Group: "group", Resource: "folder"}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTime}, {NamespacedResource: NamespacedResource{Namespace: "resource-10h-v5", Group: "group", Resource: "folder"}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTime}, {NamespacedResource: NamespacedResource{Namespace: "resource-10h-v6", Group: "group", Resource: "folder"}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTime}, @@ -631,7 +638,10 @@ func TestFindIndexesForRebuild(t *testing.T) { {NamespacedResource: NamespacedResource{Namespace: "resource-2h-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTimeDashboard}, {NamespacedResource: NamespacedResource{Namespace: "resource-recently-imported", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTimeDashboard, lastImportTime: lastImportTime}, - }) + } + if diff := cmp.Diff(expected, vals, cmpopts.IgnoreFields(rebuildRequest{}, "completeChannels"), cmp.AllowUnexported(rebuildRequest{})); diff != "" { + t.Errorf("rebuildQueue mismatch (-want +got):\n%s", diff) + } } func TestRebuildIndexes(t *testing.T) { @@ -748,3 +758,81 @@ func checkRebuildIndex(t *testing.T, support *searchSupport, req rebuildRequest, require.Nil(t, idxAfter, "index should not exist after rebuildIndex") } } + +func TestRebuildIndexesForResource(t *testing.T) { + key := NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"} + + storage := &mockStorageBackend{ + resourceStats: []ResourceStats{ + {NamespacedResource: key, Count: 50, ResourceVersion: 11111111}, + }, + lastImportTimes: []ResourceLastImportTime{{ + NamespacedResource: key, + LastImportTime: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + }}, + } + + search := &mockSearchBackend{} + supplier := &TestDocumentBuilderSupplier{ + GroupsResources: map[string]string{ + "group": "resource", + }, + } + + opts := SearchOptions{ + Backend: search, + Resources: supplier, + InitMinCount: 1, + } + + support, err := newSearchSupport(opts, storage, nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, support) + + err = support.init(t.Context()) + require.NoError(t, err) + + require.Equal(t, 0, support.rebuildQueue.Len()) + + // invalid request + rebuildReq := &resourcepb.RebuildIndexesRequest{ + Namespace: "some-other-namespace", + Keys: []*resourcepb.ResourceKey{{ + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }}} + rsp, err := support.RebuildIndexes(t.Context(), rebuildReq) + require.NoError(t, err) + require.Equal(t, "key namespace does not match request namespace", rsp.Error.Message) + + rebuildReq.Namespace = key.Namespace + + // cached index info + search.cache[key] = &MockResourceIndex{ + buildInfo: IndexBuildInfo{BuildVersion: semver.MustParse("5.0.0"), BuildTime: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)}, + } + + // old import time will not be rebuilt + storage.lastImportTimes = []ResourceLastImportTime{{ + NamespacedResource: key, + LastImportTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + }} + rsp, err = support.RebuildIndexes(t.Context(), rebuildReq) + require.NoError(t, err) + require.Equal(t, int64(0), rsp.RebuildCount) + require.Equal(t, 0, support.rebuildQueue.Len()) + + // recent import time gets added to rebuild queue and processed + storage.lastImportTimes = []ResourceLastImportTime{{ + NamespacedResource: key, + LastImportTime: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + }} + + rsp, err = support.RebuildIndexes(t.Context(), rebuildReq) + require.NoError(t, err) + require.Equal(t, int64(1), rsp.RebuildCount) + + // rebuild waited for rebuild queue to process + require.Equal(t, 0, support.rebuildQueue.Len()) +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index bf805cc0a6a..4c0adddf3c8 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "iter" - "log/slog" "net/http" "sync" "sync/atomic" @@ -24,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/validation" + "github.com/grafana/grafana/pkg/infra/log" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/scheduler" @@ -320,7 +320,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { } } - logger := slog.Default().With("logger", "resource-server") + logger := log.New("resource-server") // Make this cancelable ctx, cancel := context.WithCancel(context.Background()) @@ -366,7 +366,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { var _ ResourceServer = &server{} type server struct { - log *slog.Logger + log log.Logger backend StorageBackend blob BlobSupport secure secrets.InlineSecureValueSupport @@ -470,14 +470,15 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *resour return nil, AsErrorResult(err) } + l := s.log.FromContext(ctx) if obj.GetUID() == "" { // TODO! once https://github.com/grafana/grafana/pull/96086 is deployed everywhere // return nil, NewBadRequestError("object is missing UID") - s.log.Error("object is missing UID", "key", key) + l.Error("object is missing UID", "key", key) } if obj.GetResourceVersion() != "" { - s.log.Error("object must not include a resource version", "key", key) + l.Error("object must not include a resource version", "key", key) } // Make sure the command labels are not saved @@ -694,7 +695,7 @@ func (s *server) create(ctx context.Context, user claims.AuthInfo, req *resource if err != nil { rsp.Error = AsErrorResult(err) } - s.log.Debug("server.WriteEvent", "type", event.Type, "rv", rsp.ResourceVersion, "previousRV", event.PreviousRV, "group", event.Key.Group, "namespace", event.Key.Namespace, "name", event.Key.Name, "resource", event.Key.Resource) + s.log.FromContext(ctx).Debug("server.WriteEvent", "type", event.Type, "rv", rsp.ResourceVersion, "previousRV", event.PreviousRV, "group", event.Key.Group, "namespace", event.Key.Namespace, "name", event.Key.Name, "resource", event.Key.Resource) return rsp, nil } @@ -1540,3 +1541,11 @@ func (s *server) runInQueue(ctx context.Context, tenantID string, runnable func( return queueCtx.Err() // Timed out or canceled while waiting for execution. } } + +func (s *server) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest) (*resourcepb.RebuildIndexesResponse, error) { + if s.search == nil { + return nil, fmt.Errorf("search index not configured") + } + + return s.search.RebuildIndexes(ctx, req) +} diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index 38df20e8151..504c49616f9 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "log/slog" "net/http" "strings" "sync" @@ -581,7 +580,7 @@ func newTestServerWithQueue(t *testing.T, maxSizePerTenant int, numWorkers int) MaxRetries: 2, MinBackoff: 10 * time.Millisecond, }, - log: slog.Default(), + log: log.NewNopLogger(), } return s, q } @@ -589,7 +588,7 @@ func newTestServerWithQueue(t *testing.T, maxSizePerTenant int, numWorkers int) func TestArtificialDelayAfterSuccessfulOperation(t *testing.T) { s := &server{ artificialSuccessfulWriteDelay: 1 * time.Millisecond, - log: slog.Default(), + log: log.NewNopLogger(), } check := func(t *testing.T, expectedSleep bool, res responseWithErrorResult, err error) { diff --git a/pkg/storage/unified/resourcepb/search.pb.go b/pkg/storage/unified/resourcepb/search.pb.go index c10b2f2fe53..91f5aadfa97 100644 --- a/pkg/storage/unified/resourcepb/search.pb.go +++ b/pkg/storage/unified/resourcepb/search.pb.go @@ -386,6 +386,123 @@ func (x *ResourceSearchResponse) GetFacet() map[string]*ResourceSearchResponse_F return nil } +type RebuildIndexesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Namespace (tenant) + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + // List of ResourceKeys (Namespace + Group + Resource) + Keys []*ResourceKey `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RebuildIndexesRequest) Reset() { + *x = RebuildIndexesRequest{} + mi := &file_search_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RebuildIndexesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RebuildIndexesRequest) ProtoMessage() {} + +func (x *RebuildIndexesRequest) ProtoReflect() protoreflect.Message { + mi := &file_search_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RebuildIndexesRequest.ProtoReflect.Descriptor instead. +func (*RebuildIndexesRequest) Descriptor() ([]byte, []int) { + return file_search_proto_rawDescGZIP(), []int{4} +} + +func (x *RebuildIndexesRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *RebuildIndexesRequest) GetKeys() []*ResourceKey { + if x != nil { + return x.Keys + } + return nil +} + +type RebuildIndexesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Total count of rebuilt indexes + RebuildCount int64 `protobuf:"varint,1,opt,name=rebuildCount,proto3" json:"rebuildCount,omitempty"` + // Result message + Details string `protobuf:"bytes,2,opt,name=details,proto3" json:"details,omitempty"` + // Error details + Error *ErrorResult `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RebuildIndexesResponse) Reset() { + *x = RebuildIndexesResponse{} + mi := &file_search_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RebuildIndexesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RebuildIndexesResponse) ProtoMessage() {} + +func (x *RebuildIndexesResponse) ProtoReflect() protoreflect.Message { + mi := &file_search_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RebuildIndexesResponse.ProtoReflect.Descriptor instead. +func (*RebuildIndexesResponse) Descriptor() ([]byte, []int) { + return file_search_proto_rawDescGZIP(), []int{5} +} + +func (x *RebuildIndexesResponse) GetRebuildCount() int64 { + if x != nil { + return x.RebuildCount + } + return 0 +} + +func (x *RebuildIndexesResponse) GetDetails() string { + if x != nil { + return x.Details + } + return "" +} + +func (x *RebuildIndexesResponse) GetError() *ErrorResult { + if x != nil { + return x.Error + } + return nil +} + type ResourceStatsResponse_Stats struct { state protoimpl.MessageState `protogen:"open.v1"` // Resource group @@ -400,7 +517,7 @@ type ResourceStatsResponse_Stats struct { func (x *ResourceStatsResponse_Stats) Reset() { *x = ResourceStatsResponse_Stats{} - mi := &file_search_proto_msgTypes[4] + mi := &file_search_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -412,7 +529,7 @@ func (x *ResourceStatsResponse_Stats) String() string { func (*ResourceStatsResponse_Stats) ProtoMessage() {} func (x *ResourceStatsResponse_Stats) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[4] + mi := &file_search_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -459,7 +576,7 @@ type ResourceSearchRequest_Sort struct { func (x *ResourceSearchRequest_Sort) Reset() { *x = ResourceSearchRequest_Sort{} - mi := &file_search_proto_msgTypes[5] + mi := &file_search_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -471,7 +588,7 @@ func (x *ResourceSearchRequest_Sort) String() string { func (*ResourceSearchRequest_Sort) ProtoMessage() {} func (x *ResourceSearchRequest_Sort) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[5] + mi := &file_search_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -511,7 +628,7 @@ type ResourceSearchRequest_Facet struct { func (x *ResourceSearchRequest_Facet) Reset() { *x = ResourceSearchRequest_Facet{} - mi := &file_search_proto_msgTypes[6] + mi := &file_search_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -523,7 +640,7 @@ func (x *ResourceSearchRequest_Facet) String() string { func (*ResourceSearchRequest_Facet) ProtoMessage() {} func (x *ResourceSearchRequest_Facet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[6] + mi := &file_search_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -568,7 +685,7 @@ type ResourceSearchResponse_Facet struct { func (x *ResourceSearchResponse_Facet) Reset() { *x = ResourceSearchResponse_Facet{} - mi := &file_search_proto_msgTypes[8] + mi := &file_search_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -580,7 +697,7 @@ func (x *ResourceSearchResponse_Facet) String() string { func (*ResourceSearchResponse_Facet) ProtoMessage() {} func (x *ResourceSearchResponse_Facet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[8] + mi := &file_search_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -634,7 +751,7 @@ type ResourceSearchResponse_TermFacet struct { func (x *ResourceSearchResponse_TermFacet) Reset() { *x = ResourceSearchResponse_TermFacet{} - mi := &file_search_proto_msgTypes[9] + mi := &file_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -646,7 +763,7 @@ func (x *ResourceSearchResponse_TermFacet) String() string { func (*ResourceSearchResponse_TermFacet) ProtoMessage() {} func (x *ResourceSearchResponse_TermFacet) ProtoReflect() protoreflect.Message { - mi := &file_search_proto_msgTypes[9] + mi := &file_search_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -780,22 +897,42 @@ var file_search_proto_rawDesc = string([]byte{ 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, - 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, - 0xa9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, - 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, - 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3b, 0x5a, 0x39, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, - 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, - 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0x60, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, + 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x04, 0x6b, 0x65, 0x79, + 0x73, 0x22, 0x83, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, + 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x32, 0xfe, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, + 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, + 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, + 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -810,47 +947,53 @@ func file_search_proto_rawDescGZIP() []byte { return file_search_proto_rawDescData } -var file_search_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_search_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_search_proto_goTypes = []any{ (*ResourceStatsRequest)(nil), // 0: resource.ResourceStatsRequest (*ResourceStatsResponse)(nil), // 1: resource.ResourceStatsResponse (*ResourceSearchRequest)(nil), // 2: resource.ResourceSearchRequest (*ResourceSearchResponse)(nil), // 3: resource.ResourceSearchResponse - (*ResourceStatsResponse_Stats)(nil), // 4: resource.ResourceStatsResponse.Stats - (*ResourceSearchRequest_Sort)(nil), // 5: resource.ResourceSearchRequest.Sort - (*ResourceSearchRequest_Facet)(nil), // 6: resource.ResourceSearchRequest.Facet - nil, // 7: resource.ResourceSearchRequest.FacetEntry - (*ResourceSearchResponse_Facet)(nil), // 8: resource.ResourceSearchResponse.Facet - (*ResourceSearchResponse_TermFacet)(nil), // 9: resource.ResourceSearchResponse.TermFacet - nil, // 10: resource.ResourceSearchResponse.FacetEntry - (*ErrorResult)(nil), // 11: resource.ErrorResult - (*ListOptions)(nil), // 12: resource.ListOptions - (*ResourceKey)(nil), // 13: resource.ResourceKey - (*ResourceTable)(nil), // 14: resource.ResourceTable + (*RebuildIndexesRequest)(nil), // 4: resource.RebuildIndexesRequest + (*RebuildIndexesResponse)(nil), // 5: resource.RebuildIndexesResponse + (*ResourceStatsResponse_Stats)(nil), // 6: resource.ResourceStatsResponse.Stats + (*ResourceSearchRequest_Sort)(nil), // 7: resource.ResourceSearchRequest.Sort + (*ResourceSearchRequest_Facet)(nil), // 8: resource.ResourceSearchRequest.Facet + nil, // 9: resource.ResourceSearchRequest.FacetEntry + (*ResourceSearchResponse_Facet)(nil), // 10: resource.ResourceSearchResponse.Facet + (*ResourceSearchResponse_TermFacet)(nil), // 11: resource.ResourceSearchResponse.TermFacet + nil, // 12: resource.ResourceSearchResponse.FacetEntry + (*ErrorResult)(nil), // 13: resource.ErrorResult + (*ListOptions)(nil), // 14: resource.ListOptions + (*ResourceKey)(nil), // 15: resource.ResourceKey + (*ResourceTable)(nil), // 16: resource.ResourceTable } var file_search_proto_depIdxs = []int32{ - 11, // 0: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult - 4, // 1: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats - 12, // 2: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions - 13, // 3: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey - 5, // 4: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort - 7, // 5: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry - 11, // 6: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult - 13, // 7: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey - 14, // 8: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable - 10, // 9: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry - 6, // 10: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet - 9, // 11: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet - 8, // 12: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet - 2, // 13: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest - 0, // 14: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest - 3, // 15: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse - 1, // 16: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse - 15, // [15:17] is the sub-list for method output_type - 13, // [13:15] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 13, // 0: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult + 6, // 1: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats + 14, // 2: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions + 15, // 3: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey + 7, // 4: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort + 9, // 5: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry + 13, // 6: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult + 15, // 7: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey + 16, // 8: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable + 12, // 9: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry + 15, // 10: resource.RebuildIndexesRequest.keys:type_name -> resource.ResourceKey + 13, // 11: resource.RebuildIndexesResponse.error:type_name -> resource.ErrorResult + 8, // 12: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet + 11, // 13: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet + 10, // 14: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet + 2, // 15: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest + 0, // 16: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest + 4, // 17: resource.ResourceIndex.RebuildIndexes:input_type -> resource.RebuildIndexesRequest + 3, // 18: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse + 1, // 19: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse + 5, // 20: resource.ResourceIndex.RebuildIndexes:output_type -> resource.RebuildIndexesResponse + 18, // [18:21] is the sub-list for method output_type + 15, // [15:18] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_search_proto_init() } @@ -865,7 +1008,7 @@ func file_search_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_search_proto_rawDesc), len(file_search_proto_rawDesc)), NumEnums: 0, - NumMessages: 11, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/storage/unified/resourcepb/search_grpc.pb.go b/pkg/storage/unified/resourcepb/search_grpc.pb.go index 2fa3f16c611..d69cbd14e38 100644 --- a/pkg/storage/unified/resourcepb/search_grpc.pb.go +++ b/pkg/storage/unified/resourcepb/search_grpc.pb.go @@ -19,8 +19,9 @@ import ( const _ = grpc.SupportPackageIsVersion8 const ( - ResourceIndex_Search_FullMethodName = "/resource.ResourceIndex/Search" - ResourceIndex_GetStats_FullMethodName = "/resource.ResourceIndex/GetStats" + ResourceIndex_Search_FullMethodName = "/resource.ResourceIndex/Search" + ResourceIndex_GetStats_FullMethodName = "/resource.ResourceIndex/GetStats" + ResourceIndex_RebuildIndexes_FullMethodName = "/resource.ResourceIndex/RebuildIndexes" ) // ResourceIndexClient is the client API for ResourceIndex service. @@ -33,6 +34,7 @@ type ResourceIndexClient interface { Search(ctx context.Context, in *ResourceSearchRequest, opts ...grpc.CallOption) (*ResourceSearchResponse, error) // Get the resource stats GetStats(ctx context.Context, in *ResourceStatsRequest, opts ...grpc.CallOption) (*ResourceStatsResponse, error) + RebuildIndexes(ctx context.Context, in *RebuildIndexesRequest, opts ...grpc.CallOption) (*RebuildIndexesResponse, error) } type resourceIndexClient struct { @@ -63,6 +65,16 @@ func (c *resourceIndexClient) GetStats(ctx context.Context, in *ResourceStatsReq return out, nil } +func (c *resourceIndexClient) RebuildIndexes(ctx context.Context, in *RebuildIndexesRequest, opts ...grpc.CallOption) (*RebuildIndexesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RebuildIndexesResponse) + err := c.cc.Invoke(ctx, ResourceIndex_RebuildIndexes_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ResourceIndexServer is the server API for ResourceIndex service. // All implementations should embed UnimplementedResourceIndexServer // for forward compatibility @@ -73,6 +85,7 @@ type ResourceIndexServer interface { Search(context.Context, *ResourceSearchRequest) (*ResourceSearchResponse, error) // Get the resource stats GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) + RebuildIndexes(context.Context, *RebuildIndexesRequest) (*RebuildIndexesResponse, error) } // UnimplementedResourceIndexServer should be embedded to have forward compatible implementations. @@ -85,6 +98,9 @@ func (UnimplementedResourceIndexServer) Search(context.Context, *ResourceSearchR func (UnimplementedResourceIndexServer) GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented") } +func (UnimplementedResourceIndexServer) RebuildIndexes(context.Context, *RebuildIndexesRequest) (*RebuildIndexesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RebuildIndexes not implemented") +} // UnsafeResourceIndexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ResourceIndexServer will @@ -133,6 +149,24 @@ func _ResourceIndex_GetStats_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _ResourceIndex_RebuildIndexes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RebuildIndexesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceIndexServer).RebuildIndexes(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceIndex_RebuildIndexes_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceIndexServer).RebuildIndexes(ctx, req.(*RebuildIndexesRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ResourceIndex_ServiceDesc is the grpc.ServiceDesc for ResourceIndex service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -148,6 +182,10 @@ var ResourceIndex_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetStats", Handler: _ResourceIndex_GetStats_Handler, }, + { + MethodName: "RebuildIndexes", + Handler: _ResourceIndex_RebuildIndexes_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "search.proto", diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 836c491b3c7..36c68183203 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "math" "os" "path/filepath" @@ -71,7 +70,7 @@ type BleveOptions struct { BuildVersion string - Logger *slog.Logger + Logger log.Logger // Minimum time between index updates. IndexMinUpdateInterval time.Duration @@ -84,7 +83,7 @@ type BleveOptions struct { type bleveBackend struct { tracer trace.Tracer - log *slog.Logger + log log.Logger opts BleveOptions // set from opts.OwnsIndex, always non-nil @@ -125,9 +124,9 @@ func NewBleveBackend(opts BleveOptions, tracer trace.Tracer, indexMetrics *resou } } - log := opts.Logger - if log == nil { - log = slog.Default().With("logger", "bleve-backend") + l := opts.Logger + if l == nil { + l = log.New("bleve-backend") } ownFn := opts.OwnsIndex @@ -137,7 +136,7 @@ func NewBleveBackend(opts BleveOptions, tracer trace.Tracer, indexMetrics *resou } be := &bleveBackend{ - log: log, + log: l, tracer: tracer, cache: map[resource.NamespacedResource]*bleveIndex{}, opts: opts, @@ -381,7 +380,7 @@ func (b *bleveBackend) BuildIndex( return nil, err } - logWithDetails := b.log.With("namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "size", size, "reason", indexBuildReason) + logWithDetails := b.log.FromContext(ctx).New("namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "size", size, "reason", indexBuildReason) // Close the newly created/opened index by default. closeIndex := true @@ -462,7 +461,7 @@ func (b *bleveBackend) BuildIndex( } // Batch all the changes - idx := b.newBleveIndex(key, index, newIndexType, fields, allFields, standardSearchFields, updater, b.log.With("namespace", key.Namespace, "group", key.Group, "resource", key.Resource)) + idx := b.newBleveIndex(key, index, newIndexType, fields, allFields, standardSearchFields, updater, b.log.New("namespace", key.Namespace, "group", key.Group, "resource", key.Resource)) if build { if b.indexMetrics != nil { @@ -714,7 +713,7 @@ type bleveIndex struct { // The values returned with all allFields []*resourcepb.ResourceTableColumnDefinition tracing trace.Tracer - logger *slog.Logger + logger log.Logger updaterFn resource.UpdateFn minUpdateInterval time.Duration @@ -741,7 +740,7 @@ func (b *bleveBackend) newBleveIndex( allFields []*resourcepb.ResourceTableColumnDefinition, standardSearchFields resource.SearchableDocumentFields, updaterFn resource.UpdateFn, - logger *slog.Logger, + logger log.Logger, ) *bleveIndex { bi := &bleveIndex{ key: key, diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index b303d11c6e8..4c66fc4c1b5 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "math" "os" "path/filepath" @@ -26,7 +25,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/store/kind/dashboard" @@ -772,7 +771,7 @@ func setupBleveBackend(t *testing.T, options ...setupOption) (*bleveBackend, pro opts := BleveOptions{ FileThreshold: defaultFileThreshold, IndexCacheTTL: defaultIndexCacheTTL, - Logger: slog.New(logtest.NewNopHandler(t)), + Logger: log.NewNopLogger(), BuildVersion: buildVersion, } for _, opt := range options { diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index d3dae729fd4..8de43f318b0 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -994,7 +994,9 @@ func (b *backend) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[reso b.lastImportTimeDeletionTime.Store(now) } - rows, err := dbutil.QueryRows(ctx, b.db, sqlResourceLastImportTimeQuery, &sqlResourceLastImportTimeQueryRequest{SQLTemplate: sqltemplate.New(b.dialect)}) + rows, err := dbutil.QueryRows(ctx, b.db, sqlResourceLastImportTimeQuery, &sqlResourceLastImportTimeQueryRequest{ + SQLTemplate: sqltemplate.New(b.dialect), + }) if err != nil { return func(yield func(resource.ResourceLastImportTime, error) bool) { yield(resource.ResourceLastImportTime{}, err) @@ -1037,3 +1039,7 @@ func (b *backend) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[reso } } } + +func (b *backend) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest) (*resourcepb.RebuildIndexesResponse, error) { + return nil, fmt.Errorf("rebuild indexes not supported by unistore sql backend") +} diff --git a/pkg/tsdb/grafana-postgresql-datasource/locker.go b/pkg/tsdb/grafana-postgresql-datasource/locker.go deleted file mode 100644 index 796c37c7415..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/locker.go +++ /dev/null @@ -1,85 +0,0 @@ -package postgres - -import ( - "fmt" - "sync" -) - -// locker is a named reader/writer mutual exclusion lock. -// The lock for each particular key can be held by an arbitrary number of readers or a single writer. -type locker struct { - locks map[any]*sync.RWMutex - locksRW *sync.RWMutex -} - -func newLocker() *locker { - return &locker{ - locks: make(map[any]*sync.RWMutex), - locksRW: new(sync.RWMutex), - } -} - -// Lock locks named rw mutex with specified key for writing. -// If the lock with the same key is already locked for reading or writing, -// Lock blocks until the lock is available. -func (lkr *locker) Lock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - lk = lkr.newLock(key) - } - lk.Lock() -} - -// Unlock unlocks named rw mutex with specified key for writing. It is a run-time error if rw is -// not locked for writing on entry to Unlock. -func (lkr *locker) Unlock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - panic(fmt.Errorf("lock for key '%s' not initialized", key)) - } - lk.Unlock() -} - -// RLock locks named rw mutex with specified key for reading. -// -// It should not be used for recursive read locking for the same key; a blocked Lock -// call excludes new readers from acquiring the lock. See the -// documentation on the golang RWMutex type. -func (lkr *locker) RLock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - lk = lkr.newLock(key) - } - lk.RLock() -} - -// RUnlock undoes a single RLock call for specified key; -// it does not affect other simultaneous readers of locker for specified key. -// It is a run-time error if locker for specified key is not locked for reading -func (lkr *locker) RUnlock(key any) { - lk, ok := lkr.getLock(key) - if !ok { - panic(fmt.Errorf("lock for key '%s' not initialized", key)) - } - lk.RUnlock() -} - -func (lkr *locker) newLock(key any) *sync.RWMutex { - lkr.locksRW.Lock() - defer lkr.locksRW.Unlock() - - if lk, ok := lkr.locks[key]; ok { - return lk - } - lk := new(sync.RWMutex) - lkr.locks[key] = lk - return lk -} - -func (lkr *locker) getLock(key any) (*sync.RWMutex, bool) { - lkr.locksRW.RLock() - defer lkr.locksRW.RUnlock() - - lock, ok := lkr.locks[key] - return lock, ok -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/locker_test.go b/pkg/tsdb/grafana-postgresql-datasource/locker_test.go deleted file mode 100644 index 9b0eca32f99..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/locker_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package postgres - -import ( - "sync" - "testing" - "time" - - "github.com/grafana/grafana/pkg/util/testutil" - "github.com/stretchr/testify/require" -) - -func TestIntegrationLocker(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - const notUpdated = "not_updated" - const atThread1 = "at_thread_1" - const atThread2 = "at_thread_2" - t.Run("Should lock for same keys", func(t *testing.T) { - updated := notUpdated - locker := newLocker() - locker.Lock(1) - var wg sync.WaitGroup - wg.Add(1) - defer func() { - locker.Unlock(1) - wg.Wait() - }() - - go func() { - locker.RLock(1) - defer func() { - locker.RUnlock(1) - wg.Done() - }() - require.Equal(t, atThread1, updated, "Value should be updated in different thread") - updated = atThread2 - }() - time.Sleep(time.Millisecond * 10) - require.Equal(t, notUpdated, updated, "Value should not be updated in different thread") - updated = atThread1 - }) - - t.Run("Should not lock for different keys", func(t *testing.T) { - updated := notUpdated - locker := newLocker() - locker.Lock(1) - defer locker.Unlock(1) - var wg sync.WaitGroup - wg.Add(1) - go func() { - locker.RLock(2) - defer func() { - locker.RUnlock(2) - wg.Done() - }() - require.Equal(t, notUpdated, updated, "Value should not be updated in different thread") - updated = atThread2 - }() - wg.Wait() - require.Equal(t, atThread2, updated, "Value should be updated in different thread") - updated = atThread1 - }) -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgx/handler_checkhealth.go b/pkg/tsdb/grafana-postgresql-datasource/pgx/handler_checkhealth.go deleted file mode 100644 index 74bc6923eac..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/pgx/handler_checkhealth.go +++ /dev/null @@ -1,117 +0,0 @@ -package pgx - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net" - "strings" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" -) - -func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - err := e.Ping(ctx) - if err != nil { - logCheckHealthError(ctx, e.dsInfo, err) - if strings.EqualFold(req.PluginContext.User.Role, "Admin") { - return ErrToHealthCheckResult(err) - } - errResponse := &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: e.TransformQueryError(e.log, err).Error(), - } - return errResponse, nil - } - return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil -} - -// ErrToHealthCheckResult converts error into user friendly health check message -// This should be called with non nil error. If the err parameter is empty, we will send Internal Server Error -func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) { - if err == nil { - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Internal Server Error"}, nil - } - res := &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()} - details := map[string]string{ - "verboseMessage": err.Error(), - "errorDetailsLink": "https://grafana.com/docs/grafana/latest/datasources/postgres", - } - var opErr *net.OpError - if errors.As(err, &opErr) { - res.Message = "Network error: Failed to connect to the server" - if opErr != nil && opErr.Err != nil { - errMessage := opErr.Err.Error() - if strings.HasSuffix(opErr.Err.Error(), "no such host") { - errMessage = "no such host" - } - if strings.HasSuffix(opErr.Err.Error(), "unknown port") { - errMessage = "unknown port" - } - if strings.HasSuffix(opErr.Err.Error(), "invalid port") { - errMessage = "invalid port" - } - if strings.HasSuffix(opErr.Err.Error(), "missing port in address") { - errMessage = "missing port in address" - } - if strings.HasSuffix(opErr.Err.Error(), "invalid syntax") { - errMessage = "invalid syntax found in the address" - } - res.Message += fmt.Sprintf(". Error message: %s", errMessage) - } - } - - if errors.Is(err, sqleng.ErrParsingPostgresURL) { - res.Message = fmt.Sprintf("Connection string error: %s", sqleng.ErrParsingPostgresURL.Error()) - if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { - details["verboseMessage"] = unwrappedErr.Error() - } - } - detailBytes, marshalErr := json.Marshal(details) - if marshalErr != nil { - return res, nil - } - res.JSONDetails = detailBytes - return res, nil -} - -func logCheckHealthError(ctx context.Context, dsInfo sqleng.DataSourceInfo, err error) { - logger := log.DefaultLogger.FromContext(ctx) - configSummary := map[string]any{ - "config_url_length": len(dsInfo.URL), - "config_user_length": len(dsInfo.User), - "config_database_length": len(dsInfo.Database), - "config_json_data_database_length": len(dsInfo.JsonData.Database), - "config_max_open_conns": dsInfo.JsonData.MaxOpenConns, - "config_max_idle_conns": dsInfo.JsonData.MaxIdleConns, - "config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime, - "config_conn_timeout": dsInfo.JsonData.ConnectionTimeout, - "config_timescaledb": dsInfo.JsonData.Timescaledb, - "config_ssl_mode": dsInfo.JsonData.Mode, - "config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod, - "config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify, - "config_timezone": dsInfo.JsonData.Timezone, - "config_time_interval": dsInfo.JsonData.TimeInterval, - "config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy, - "config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords, - "config_authentication_type": dsInfo.JsonData.AuthenticationType, - "config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile), - "config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile), - "config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile), - "config_encrypt_length": len(dsInfo.JsonData.Encrypt), - "config_server_name_length": len(dsInfo.JsonData.Servername), - "config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]), - "config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]), - "config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), - "config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), - } - configSummaryJSON, marshalError := json.Marshal(configSummary) - if marshalError != nil { - logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error") - return - } - logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJSON)) -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgx/handler_checkhealth_test.go b/pkg/tsdb/grafana-postgresql-datasource/pgx/handler_checkhealth_test.go deleted file mode 100644 index 16536368622..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/pgx/handler_checkhealth_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package pgx - -import ( - "errors" - "fmt" - "net" - "testing" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestErrToHealthCheckResult(t *testing.T) { - tests := []struct { - name string - err error - want *backend.CheckHealthResult - }{ - { - name: "without error", - want: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Internal Server Error"}, - }, - { - name: "network error", - err: errors.Join(errors.New("foo"), &net.OpError{Op: "read", Net: "tcp", Err: errors.New("some op")}), - want: &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "Network error: Failed to connect to the server. Error message: some op", - JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/postgres","verboseMessage":"foo\nread tcp: some op"}`), - }, - }, - { - name: "regular error", - err: errors.New("internal server error"), - want: &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "internal server error", - JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/postgres","verboseMessage":"internal server error"}`), - }, - }, - { - name: "invalid port specifier error", - err: fmt.Errorf("%w %q: %w", sqleng.ErrParsingPostgresURL, `"foo.bar.co"`, errors.New(`strconv.Atoi: parsing "foo.bar.co": invalid syntax`)), - want: &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "Connection string error: error parsing postgres url", - JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/postgres","verboseMessage":"error parsing postgres url \"\\\"foo.bar.co\\\"\": strconv.Atoi: parsing \"foo.bar.co\": invalid syntax"}`), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ErrToHealthCheckResult(tt.err) - require.Nil(t, err) - assert.Equal(t, string(tt.want.JSONDetails), string(got.JSONDetails)) - require.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgx/sql_engine.go b/pkg/tsdb/grafana-postgresql-datasource/pgx/sql_engine.go deleted file mode 100644 index 8f8af632f13..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/pgx/sql_engine.go +++ /dev/null @@ -1,836 +0,0 @@ -package pgx - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net" - "runtime/debug" - "strconv" - "strings" - "sync" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" -) - -// MetaKeyExecutedQueryString is the key where the executed query should get stored -const MetaKeyExecutedQueryString = "executedQueryString" - -// SQLMacroEngine interpolates macros into sql. It takes in the Query to have access to query context and -// timeRange to be able to generate queries that use from and to. -type SQLMacroEngine interface { - Interpolate(query *backend.DataQuery, timeRange backend.TimeRange, sql string) (string, error) -} - -// SqlQueryResultTransformer transforms a query result row to RowValues with proper types. -type SqlQueryResultTransformer interface { - // TransformQueryError transforms a query error. - TransformQueryError(logger log.Logger, err error) error - GetConverterList() []sqlutil.StringConverter -} - -type JsonData struct { - MaxOpenConns int `json:"maxOpenConns"` - MaxIdleConns int `json:"maxIdleConns"` - ConnMaxLifetime int `json:"connMaxLifetime"` - ConnectionTimeout int `json:"connectionTimeout"` - Timescaledb bool `json:"timescaledb"` - Mode string `json:"sslmode"` - ConfigurationMethod string `json:"tlsConfigurationMethod"` - TlsSkipVerify bool `json:"tlsSkipVerify"` - RootCertFile string `json:"sslRootCertFile"` - CertFile string `json:"sslCertFile"` - CertKeyFile string `json:"sslKeyFile"` - Timezone string `json:"timezone"` - Encrypt string `json:"encrypt"` - Servername string `json:"servername"` - TimeInterval string `json:"timeInterval"` - Database string `json:"database"` - SecureDSProxy bool `json:"enableSecureSocksProxy"` - SecureDSProxyUsername string `json:"secureSocksProxyUsername"` - AllowCleartextPasswords bool `json:"allowCleartextPasswords"` - AuthenticationType string `json:"authenticationType"` -} - -type DataPluginConfiguration struct { - DSInfo sqleng.DataSourceInfo - TimeColumnNames []string - MetricColumnTypes []string - RowLimit int64 -} - -type DataSourceHandler struct { - macroEngine SQLMacroEngine - queryResultTransformer SqlQueryResultTransformer - timeColumnNames []string - metricColumnTypes []string - log log.Logger - dsInfo sqleng.DataSourceInfo - rowLimit int64 - userError string - pool *pgxpool.Pool -} - -type QueryJson struct { - RawSql string `json:"rawSql"` - Fill bool `json:"fill"` - FillInterval float64 `json:"fillInterval"` - FillMode string `json:"fillMode"` - FillValue float64 `json:"fillValue"` - Format string `json:"format"` -} - -func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) error { - // OpError is the error type usually returned by functions in the net - // package. It describes the operation, network type, and address of - // an error. We log this error rather than return it to the client - // for security purposes. - var opErr *net.OpError - if errors.As(err, &opErr) { - return fmt.Errorf("failed to connect to server - %s", e.userError) - } - - return e.queryResultTransformer.TransformQueryError(logger, err) -} - -func NewQueryDataHandler(userFacingDefaultError string, p *pgxpool.Pool, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, - macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) { - queryDataHandler := DataSourceHandler{ - queryResultTransformer: queryResultTransformer, - macroEngine: macroEngine, - timeColumnNames: []string{"time"}, - log: log, - dsInfo: config.DSInfo, - rowLimit: config.RowLimit, - userError: userFacingDefaultError, - } - - if len(config.TimeColumnNames) > 0 { - queryDataHandler.timeColumnNames = config.TimeColumnNames - } - - if len(config.MetricColumnTypes) > 0 { - queryDataHandler.metricColumnTypes = config.MetricColumnTypes - } - - queryDataHandler.pool = p - return &queryDataHandler, nil -} - -type DBDataResponse struct { - dataResponse backend.DataResponse - refID string -} - -func (e *DataSourceHandler) Dispose() { - e.log.Debug("Disposing DB...") - - if e.pool != nil { - e.pool.Close() - } - - e.log.Debug("DB disposed") -} - -func (e *DataSourceHandler) Ping(ctx context.Context) error { - return e.pool.Ping(ctx) -} - -func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - result := backend.NewQueryDataResponse() - ch := make(chan DBDataResponse, len(req.Queries)) - var wg sync.WaitGroup - // Execute each query in a goroutine and wait for them to finish afterwards - for _, query := range req.Queries { - queryjson := QueryJson{ - Fill: false, - Format: "time_series", - } - err := json.Unmarshal(query.JSON, &queryjson) - if err != nil { - return nil, backend.DownstreamErrorf("error unmarshal query json: %s", err.Error()) - } - - // the fill-params are only stored inside this function, during query-interpolation. we do not support - // sending them in "from the outside" - if queryjson.Fill || queryjson.FillInterval != 0.0 || queryjson.FillMode != "" || queryjson.FillValue != 0.0 { - return nil, backend.DownstreamErrorf("query fill-parameters not supported") - } - - if queryjson.RawSql == "" { - continue - } - - wg.Add(1) - go e.executeQuery(ctx, query, &wg, ch, queryjson) - } - - wg.Wait() - - // Read results from channels - close(ch) - result.Responses = make(map[string]backend.DataResponse) - for queryResult := range ch { - result.Responses[queryResult.refID] = queryResult.dataResponse - } - - return result, nil -} - -func (e *DataSourceHandler) handleQueryError(frameErr string, err error, query string, source backend.ErrorSource, ch chan DBDataResponse, queryResult DBDataResponse) { - var emptyFrame data.Frame - emptyFrame.SetMeta(&data.FrameMeta{ExecutedQueryString: query}) - if isDownstreamError(err) { - source = backend.ErrorSourceDownstream - } - queryResult.dataResponse.Error = fmt.Errorf("%s: %w", frameErr, err) - queryResult.dataResponse.ErrorSource = source - queryResult.dataResponse.Frames = data.Frames{&emptyFrame} - ch <- queryResult -} - -func (e *DataSourceHandler) handlePanic(logger log.Logger, queryResult *DBDataResponse, ch chan DBDataResponse) { - if r := recover(); r != nil { - logger.Error("ExecuteQuery panic", "error", r, "stack", string(debug.Stack())) - if theErr, ok := r.(error); ok { - queryResult.dataResponse.Error = theErr - queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin - } else if theErrString, ok := r.(string); ok { - queryResult.dataResponse.Error = errors.New(theErrString) - queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin - } else { - queryResult.dataResponse.Error = fmt.Errorf("unexpected error - %s", e.userError) - queryResult.dataResponse.ErrorSource = backend.ErrorSourceDownstream - } - ch <- *queryResult - } -} - -// Interpolate provides global macros/substitutions for all sql datasources. -var Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { - interval := query.Interval - - sql = strings.ReplaceAll(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10)) - sql = strings.ReplaceAll(sql, "$__interval", gtime.FormatInterval(interval)) - sql = strings.ReplaceAll(sql, "$__unixEpochFrom()", fmt.Sprintf("%d", timeRange.From.UTC().Unix())) - sql = strings.ReplaceAll(sql, "$__unixEpochTo()", fmt.Sprintf("%d", timeRange.To.UTC().Unix())) - - return sql -} - -func (e *DataSourceHandler) execQuery(ctx context.Context, query string) ([]*pgconn.Result, error) { - c, err := e.pool.Acquire(ctx) - if err != nil { - return nil, backend.DownstreamErrorf("failed to acquire connection: %w", err) - } - defer c.Release() - - mrr := c.Conn().PgConn().Exec(ctx, query) - // Close returns the first error that occurred during the MultiResultReader's use. We will log that later. - defer mrr.Close() //nolint:errcheck - return mrr.ReadAll() -} - -func (e *DataSourceHandler) executeQuery(queryContext context.Context, query backend.DataQuery, wg *sync.WaitGroup, - ch chan DBDataResponse, queryJSON QueryJson) { - defer wg.Done() - queryResult := DBDataResponse{ - dataResponse: backend.DataResponse{}, - refID: query.RefID, - } - - logger := e.log.FromContext(queryContext) - defer e.handlePanic(logger, &queryResult, ch) - - if queryJSON.RawSql == "" { - panic("Query model property rawSql should not be empty at this point") - } - - // global substitutions - interpolatedQuery := Interpolate(query, query.TimeRange, e.dsInfo.JsonData.TimeInterval, queryJSON.RawSql) - - // data source specific substitutions - interpolatedQuery, err := e.macroEngine.Interpolate(&query, query.TimeRange, interpolatedQuery) - if err != nil { - e.handleQueryError("interpolation failed", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - results, err := e.execQuery(queryContext, interpolatedQuery) - if err != nil { - e.handleQueryError("db query error", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - qm, err := e.newProcessCfg(queryContext, query, results, interpolatedQuery) - if err != nil { - e.handleQueryError("failed to get configurations", err, interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - frame, err := convertResultsToFrame(results, e.rowLimit) - if err != nil { - e.handleQueryError("convert frame from rows error", err, interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - e.processFrame(frame, qm, queryResult, ch, logger) -} - -// dataQueryFormat is the type of query. -type dataQueryFormat string - -const ( - // dataQueryFormatTable identifies a table query (default). - dataQueryFormatTable dataQueryFormat = "table" - // dataQueryFormatSeries identifies a time series query. - dataQueryFormatSeries dataQueryFormat = "time_series" -) - -type dataQueryModel struct { - InterpolatedQuery string // property not set until after Interpolate() - Format dataQueryFormat - TimeRange backend.TimeRange - FillMissing *data.FillMissing // property not set until after Interpolate() - Interval time.Duration - columnNames []string - columnTypes []string - timeIndex int - timeEndIndex int - metricIndex int - metricPrefix bool - queryContext context.Context -} - -func convertSQLTimeColumnsToEpochMS(frame *data.Frame, qm *dataQueryModel) error { - if qm.timeIndex != -1 { - if err := convertSQLTimeColumnToEpochMS(frame, qm.timeIndex); err != nil { - return fmt.Errorf("%v: %w", "failed to convert time column", err) - } - } - - if qm.timeEndIndex != -1 { - if err := convertSQLTimeColumnToEpochMS(frame, qm.timeEndIndex); err != nil { - return fmt.Errorf("%v: %w", "failed to convert timeend column", err) - } - } - - return nil -} - -func (e *DataSourceHandler) processFrame(frame *data.Frame, qm *dataQueryModel, queryResult DBDataResponse, ch chan DBDataResponse, logger log.Logger) { - if frame.Meta == nil { - frame.Meta = &data.FrameMeta{} - } - frame.Meta.ExecutedQueryString = qm.InterpolatedQuery - - // If no rows were returned, clear any previously set `Fields` with a single empty `data.Field` slice. - // Then assign `queryResult.dataResponse.Frames` the current single frame with that single empty Field. - // This assures 1) our visualization doesn't display unwanted empty fields, and also that 2) - // additionally-needed frame data stays intact and is correctly passed to our visulization. - if frame.Rows() == 0 { - frame.Fields = []*data.Field{} - queryResult.dataResponse.Frames = data.Frames{frame} - ch <- queryResult - return - } - - if err := convertSQLTimeColumnsToEpochMS(frame, qm); err != nil { - e.handleQueryError("converting time columns failed", err, qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - if qm.Format == dataQueryFormatSeries { - // time series has to have time column - if qm.timeIndex == -1 { - e.handleQueryError("db has no time column", errors.New("time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it"), qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - // Make sure to name the time field 'Time' to be backward compatible with Grafana pre-v8. - frame.Fields[qm.timeIndex].Name = data.TimeSeriesTimeFieldName - - for i := range qm.columnNames { - if i == qm.timeIndex || i == qm.metricIndex { - continue - } - - if t := frame.Fields[i].Type(); t == data.FieldTypeString || t == data.FieldTypeNullableString { - continue - } - - var err error - if frame, err = convertSQLValueColumnToFloat(frame, i); err != nil { - e.handleQueryError("convert value to float failed", err, qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - } - - tsSchema := frame.TimeSeriesSchema() - if tsSchema.Type == data.TimeSeriesTypeLong { - var err error - originalData := frame - frame, err = data.LongToWide(frame, qm.FillMissing) - if err != nil { - e.handleQueryError("failed to convert long to wide series when converting from dataframe", err, qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) - return - } - - // Before 8x, a special metric column was used to name time series. The LongToWide transforms that into a metric label on the value field. - // But that makes series name have both the value column name AND the metric name. So here we are removing the metric label here and moving it to the - // field name to get the same naming for the series as pre v8 - if len(originalData.Fields) == 3 { - for _, field := range frame.Fields { - if len(field.Labels) == 1 { // 7x only supported one label - name, ok := field.Labels["metric"] - if ok { - field.Name = name - field.Labels = nil - } - } - } - } - } - if qm.FillMissing != nil { - // we align the start-time - startUnixTime := qm.TimeRange.From.Unix() / int64(qm.Interval.Seconds()) * int64(qm.Interval.Seconds()) - alignedTimeRange := backend.TimeRange{ - From: time.Unix(startUnixTime, 0), - To: qm.TimeRange.To, - } - - var err error - frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) - if err != nil { - logger.Error("Failed to resample dataframe", "err", err) - frame.AppendNotices(data.Notice{Text: "Failed to resample dataframe", Severity: data.NoticeSeverityWarning}) - return - } - } - } - - queryResult.dataResponse.Frames = data.Frames{frame} - ch <- queryResult -} - -func (e *DataSourceHandler) newProcessCfg(queryContext context.Context, query backend.DataQuery, - results []*pgconn.Result, interpolatedQuery string) (*dataQueryModel, error) { - columnNames := []string{} - columnTypes := []string{} - - // The results will contain column information in the metadata - for _, result := range results { - // Get column names from the result metadata - for _, field := range result.FieldDescriptions { - columnNames = append(columnNames, field.Name) - pqtype, ok := pgtype.NewMap().TypeForOID(field.DataTypeOID) - if !ok { - // Handle special cases for field types - switch field.DataTypeOID { - case pgtype.TimetzOID: - columnTypes = append(columnTypes, "timetz") - case 790: - columnTypes = append(columnTypes, "money") - default: - columnTypes = append(columnTypes, "unknown") - } - } else { - columnTypes = append(columnTypes, pqtype.Name) - } - } - } - - qm := &dataQueryModel{ - columnTypes: columnTypes, - columnNames: columnNames, - timeIndex: -1, - timeEndIndex: -1, - metricIndex: -1, - metricPrefix: false, - queryContext: queryContext, - } - - queryJSON := QueryJson{} - err := json.Unmarshal(query.JSON, &queryJSON) - if err != nil { - return nil, err - } - - if queryJSON.Fill { - qm.FillMissing = &data.FillMissing{} - qm.Interval = time.Duration(queryJSON.FillInterval * float64(time.Second)) - switch strings.ToLower(queryJSON.FillMode) { - case "null": - qm.FillMissing.Mode = data.FillModeNull - case "previous": - qm.FillMissing.Mode = data.FillModePrevious - case "value": - qm.FillMissing.Mode = data.FillModeValue - qm.FillMissing.Value = queryJSON.FillValue - default: - } - } - - qm.TimeRange.From = query.TimeRange.From.UTC() - qm.TimeRange.To = query.TimeRange.To.UTC() - - // Default to time_series if no format is provided - switch queryJSON.Format { - case "table": - qm.Format = dataQueryFormatTable - case "time_series": - fallthrough - default: - qm.Format = dataQueryFormatSeries - } - - for i, col := range qm.columnNames { - for _, tc := range e.timeColumnNames { - if col == tc { - qm.timeIndex = i - break - } - } - - if qm.Format == dataQueryFormatTable && strings.EqualFold(col, "timeend") { - qm.timeEndIndex = i - continue - } - - switch col { - case "metric": - qm.metricIndex = i - default: - if qm.metricIndex == -1 { - columnType := qm.columnTypes[i] - for _, mct := range e.metricColumnTypes { - if columnType == mct { - qm.metricIndex = i - continue - } - } - } - } - } - qm.InterpolatedQuery = interpolatedQuery - return qm, nil -} - -func convertResultsToFrame(results []*pgconn.Result, rowLimit int64) (*data.Frame, error) { - m := pgtype.NewMap() - - // Find the first SELECT result to establish the frame structure - var firstSelectResult *pgconn.Result - for _, result := range results { - if result.CommandTag.Select() { - firstSelectResult = result - break - } - } - - // If no SELECT results found, return empty frame - if firstSelectResult == nil { - return data.NewFrame(""), nil - } - - // Create frame structure based on the first SELECT result - fields := make(data.Fields, len(firstSelectResult.FieldDescriptions)) - fieldTypes, err := getFieldTypesFromDescriptions(firstSelectResult.FieldDescriptions, m) - if err != nil { - return nil, err - } - - for i, v := range firstSelectResult.FieldDescriptions { - fields[i] = data.NewFieldFromFieldType(fieldTypes[i], 0) - fields[i].Name = v.Name - } - frame := *data.NewFrame("", fields...) - - // Process all SELECT results, but validate column compatibility - for _, result := range results { - // Skip non-select statements - if !result.CommandTag.Select() { - continue - } - - // Validate that this result has the same structure as the frame - if len(result.FieldDescriptions) != len(frame.Fields) { - return nil, fmt.Errorf("incompatible result structure: expected %d columns, got %d columns", - len(frame.Fields), len(result.FieldDescriptions)) - } - - // Validate column names and types match - for i, fd := range result.FieldDescriptions { - if fd.Name != frame.Fields[i].Name { - return nil, fmt.Errorf("column name mismatch at position %d: expected %q, got %q", - i, frame.Fields[i].Name, fd.Name) - } - } - - fieldDescriptions := result.FieldDescriptions - for rowIdx := range result.Rows { - if rowIdx == int(rowLimit) { - frame.AppendNotices(data.Notice{ - Severity: data.NoticeSeverityWarning, - Text: fmt.Sprintf("Results have been limited to %v because the SQL row limit was reached", rowLimit), - }) - break - } - row := make([]any, len(fieldDescriptions)) - for colIdx, fd := range fieldDescriptions { - rawValue := result.Rows[rowIdx][colIdx] - - if rawValue == nil { - row[colIdx] = nil - continue - } - - convertedValue, err := convertPostgresValue(rawValue, fd, m) - if err != nil { - return nil, err - } - row[colIdx] = convertedValue - } - - // Validate row length matches frame field count before appending - if len(row) != len(frame.Fields) { - return nil, fmt.Errorf("row data length mismatch: expected %d values, got %d values", - len(frame.Fields), len(row)) - } - - frame.AppendRow(row...) - } - } - - return &frame, nil -} - -// convertPostgresValue converts a raw PostgreSQL value to the appropriate Go type -func convertPostgresValue(rawValue []byte, fd pgconn.FieldDescription, m *pgtype.Map) (interface{}, error) { - dataTypeOID := fd.DataTypeOID - format := fd.Format - - // Convert based on type - switch fd.DataTypeOID { - case pgtype.Int2OID: - var d *int16 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.Int4OID: - var d *int32 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.Int8OID: - var d *int64 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.NumericOID, pgtype.Float8OID, pgtype.Float4OID: - var d *float64 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.BoolOID: - var d *bool - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.ByteaOID: - d, err := pgtype.ByteaCodec.DecodeValue(pgtype.ByteaCodec{}, m, dataTypeOID, format, rawValue) - if err != nil { - return nil, err - } - str := string(d.([]byte)) - return &str, nil - case pgtype.TimestampOID, pgtype.TimestamptzOID, pgtype.DateOID: - var d *time.Time - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.TimeOID, pgtype.TimetzOID: - var d *string - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - case pgtype.JSONOID, pgtype.JSONBOID: - var d *string - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - // Handle null JSON values - if d == nil { - return nil, nil - } - j := json.RawMessage(*d) - return &j, nil - default: - var d *string - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - return d, nil - } -} - -func getFieldTypesFromDescriptions(fieldDescriptions []pgconn.FieldDescription, m *pgtype.Map) ([]data.FieldType, error) { - fieldTypes := make([]data.FieldType, len(fieldDescriptions)) - for i, v := range fieldDescriptions { - typeName, ok := m.TypeForOID(v.DataTypeOID) - if !ok { - fieldTypes[i] = data.FieldTypeNullableString - } else { - switch typeName.Name { - case "int2": - fieldTypes[i] = data.FieldTypeNullableInt16 - case "int4": - fieldTypes[i] = data.FieldTypeNullableInt32 - case "int8": - fieldTypes[i] = data.FieldTypeNullableInt64 - case "float4", "float8", "numeric": - fieldTypes[i] = data.FieldTypeNullableFloat64 - case "bool": - fieldTypes[i] = data.FieldTypeNullableBool - case "timestamptz", "timestamp", "date": - fieldTypes[i] = data.FieldTypeNullableTime - case "json", "jsonb": - fieldTypes[i] = data.FieldTypeNullableJSON - default: - fieldTypes[i] = data.FieldTypeNullableString - } - } - } - return fieldTypes, nil -} - -// convertSQLTimeColumnToEpochMS converts column named time to unix timestamp in milliseconds -// to make native datetime types and epoch dates work in annotation and table queries. -func convertSQLTimeColumnToEpochMS(frame *data.Frame, timeIndex int) error { - if timeIndex < 0 || timeIndex >= len(frame.Fields) { - return fmt.Errorf("timeIndex %d is out of range", timeIndex) - } - - origin := frame.Fields[timeIndex] - valueType := origin.Type() - if valueType == data.FieldTypeTime || valueType == data.FieldTypeNullableTime { - return nil - } - - newField := data.NewFieldFromFieldType(data.FieldTypeNullableTime, 0) - newField.Name = origin.Name - newField.Labels = origin.Labels - - valueLength := origin.Len() - for i := 0; i < valueLength; i++ { - v, err := origin.NullableFloatAt(i) - if err != nil { - return fmt.Errorf("unable to convert data to a time field") - } - if v == nil { - newField.Append(nil) - } else { - timestamp := time.Unix(0, int64(epochPrecisionToMS(*v))*int64(time.Millisecond)) - newField.Append(×tamp) - } - } - frame.Fields[timeIndex] = newField - - return nil -} - -// convertSQLValueColumnToFloat converts timeseries value column to float. -func convertSQLValueColumnToFloat(frame *data.Frame, Index int) (*data.Frame, error) { - if Index < 0 || Index >= len(frame.Fields) { - return frame, fmt.Errorf("metricIndex %d is out of range", Index) - } - - origin := frame.Fields[Index] - valueType := origin.Type() - if valueType == data.FieldTypeFloat64 || valueType == data.FieldTypeNullableFloat64 { - return frame, nil - } - - newField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, origin.Len()) - newField.Name = origin.Name - newField.Labels = origin.Labels - - for i := 0; i < origin.Len(); i++ { - v, err := origin.NullableFloatAt(i) - if err != nil { - return frame, err - } - newField.Set(i, v) - } - - frame.Fields[Index] = newField - - return frame, nil -} - -// epochPrecisionToMS converts epoch precision to millisecond, if needed. -// Only seconds to milliseconds supported right now -func epochPrecisionToMS(value float64) float64 { - s := strconv.FormatFloat(value, 'e', -1, 64) - if strings.HasSuffix(s, "e+09") { - return value * float64(1e3) - } - - if strings.HasSuffix(s, "e+18") { - return value / float64(time.Millisecond) - } - - return value -} - -func isDownstreamError(err error) bool { - if backend.IsDownstreamError(err) { - return true - } - resultProcessingDownstreamErrors := []error{ - data.ErrorInputFieldsWithoutRows, - data.ErrorSeriesUnsorted, - data.ErrorNullTimeValues, - } - for _, e := range resultProcessingDownstreamErrors { - if errors.Is(err, e) { - return true - } - } - return false -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgx/sql_engine_test.go b/pkg/tsdb/grafana-postgresql-datasource/pgx/sql_engine_test.go deleted file mode 100644 index 2f161badb7d..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/pgx/sql_engine_test.go +++ /dev/null @@ -1,681 +0,0 @@ -package pgx - -import ( - "fmt" - "net" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana-plugin-sdk-go/backend/log" -) - -func Pointer[T any](v T) *T { return &v } - -func TestSQLEngine(t *testing.T) { - dt := time.Date(2018, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) - - t.Run("Handle interpolating $__interval and $__interval_ms", func(t *testing.T) { - from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) - to := from.Add(5 * time.Minute) - timeRange := backend.TimeRange{From: from, To: to} - - text := "$__interval $__timeGroupAlias(time,$__interval) $__interval_ms" - - t.Run("interpolate 10 minutes $__interval", func(t *testing.T) { - query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Minute * 10} - sql := Interpolate(query, timeRange, "", text) - require.Equal(t, "10m $__timeGroupAlias(time,10m) 600000", sql) - }) - - t.Run("interpolate 4seconds $__interval", func(t *testing.T) { - query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Second * 4} - sql := Interpolate(query, timeRange, "", text) - require.Equal(t, "4s $__timeGroupAlias(time,4s) 4000", sql) - }) - - t.Run("interpolate 200 milliseconds $__interval", func(t *testing.T) { - query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Millisecond * 200} - sql := Interpolate(query, timeRange, "", text) - require.Equal(t, "200ms $__timeGroupAlias(time,200ms) 200", sql) - }) - }) - - t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) { - from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) - to := from.Add(5 * time.Minute) - timeRange := backend.TimeRange{From: from, To: to} - query := backend.DataQuery{JSON: []byte("{}"), MaxDataPoints: 1500, Interval: time.Second * 60} - - t.Run("interpolate __unixEpochFrom function", func(t *testing.T) { - sql := Interpolate(query, timeRange, "", "select $__unixEpochFrom()") - require.Equal(t, fmt.Sprintf("select %d", from.Unix()), sql) - }) - - t.Run("interpolate __unixEpochTo function", func(t *testing.T) { - sql := Interpolate(query, timeRange, "", "select $__unixEpochTo()") - require.Equal(t, fmt.Sprintf("select %d", to.Unix()), sql) - }) - }) - - t.Run("Given row values with int64 as time columns", func(t *testing.T) { - tSeconds := dt.Unix() - tMilliseconds := dt.UnixNano() / 1e6 - tNanoSeconds := dt.UnixNano() - var nilPointer *int64 - - originFrame := data.NewFrame("", - data.NewField("time1", nil, []int64{ - tSeconds, - }), - data.NewField("time2", nil, []*int64{ - Pointer(tSeconds), - }), - data.NewField("time3", nil, []int64{ - tMilliseconds, - }), - data.NewField("time4", nil, []*int64{ - Pointer(tMilliseconds), - }), - data.NewField("time5", nil, []int64{ - tNanoSeconds, - }), - data.NewField("time6", nil, []*int64{ - Pointer(tNanoSeconds), - }), - data.NewField("time7", nil, []*int64{ - nilPointer, - }), - ) - - for i := 0; i < len(originFrame.Fields); i++ { - err := convertSQLTimeColumnToEpochMS(originFrame, i) - require.NoError(t, err) - } - - require.Equal(t, dt.Unix(), (*originFrame.Fields[0].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[1].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[2].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[3].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[4].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[5].At(0).(*time.Time)).Unix()) - require.Nil(t, originFrame.Fields[6].At(0)) - }) - - t.Run("Given row values with uint64 as time columns", func(t *testing.T) { - tSeconds := uint64(dt.Unix()) - tMilliseconds := uint64(dt.UnixNano() / 1e6) - tNanoSeconds := uint64(dt.UnixNano()) - var nilPointer *uint64 - - originFrame := data.NewFrame("", - data.NewField("time1", nil, []uint64{ - tSeconds, - }), - data.NewField("time2", nil, []*uint64{ - Pointer(tSeconds), - }), - data.NewField("time3", nil, []uint64{ - tMilliseconds, - }), - data.NewField("time4", nil, []*uint64{ - Pointer(tMilliseconds), - }), - data.NewField("time5", nil, []uint64{ - tNanoSeconds, - }), - data.NewField("time6", nil, []*uint64{ - Pointer(tNanoSeconds), - }), - data.NewField("time7", nil, []*uint64{ - nilPointer, - }), - ) - - for i := 0; i < len(originFrame.Fields); i++ { - err := convertSQLTimeColumnToEpochMS(originFrame, i) - require.NoError(t, err) - } - - require.Equal(t, dt.Unix(), (*originFrame.Fields[0].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[1].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[2].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[3].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[4].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[5].At(0).(*time.Time)).Unix()) - require.Nil(t, originFrame.Fields[6].At(0)) - }) - - t.Run("Given row values with int32 as time columns", func(t *testing.T) { - tSeconds := int32(dt.Unix()) - var nilInt *int32 - - originFrame := data.NewFrame("", - data.NewField("time1", nil, []int32{ - tSeconds, - }), - data.NewField("time2", nil, []*int32{ - Pointer(tSeconds), - }), - data.NewField("time7", nil, []*int32{ - nilInt, - }), - ) - for i := 0; i < 3; i++ { - err := convertSQLTimeColumnToEpochMS(originFrame, i) - require.NoError(t, err) - } - - require.Equal(t, dt.Unix(), (*originFrame.Fields[0].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[1].At(0).(*time.Time)).Unix()) - require.Nil(t, originFrame.Fields[2].At(0)) - }) - - t.Run("Given row values with uint32 as time columns", func(t *testing.T) { - tSeconds := uint32(dt.Unix()) - var nilInt *uint32 - - originFrame := data.NewFrame("", - data.NewField("time1", nil, []uint32{ - tSeconds, - }), - data.NewField("time2", nil, []*uint32{ - Pointer(tSeconds), - }), - data.NewField("time7", nil, []*uint32{ - nilInt, - }), - ) - for i := 0; i < len(originFrame.Fields); i++ { - err := convertSQLTimeColumnToEpochMS(originFrame, i) - require.NoError(t, err) - } - require.Equal(t, dt.Unix(), (*originFrame.Fields[0].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[1].At(0).(*time.Time)).Unix()) - require.Nil(t, originFrame.Fields[2].At(0)) - }) - - t.Run("Given row values with float64 as time columns", func(t *testing.T) { - tSeconds := float64(dt.UnixNano()) / float64(time.Second) - tMilliseconds := float64(dt.UnixNano()) / float64(time.Millisecond) - tNanoSeconds := float64(dt.UnixNano()) - var nilPointer *float64 - - originFrame := data.NewFrame("", - data.NewField("time1", nil, []float64{ - tSeconds, - }), - data.NewField("time2", nil, []*float64{ - Pointer(tSeconds), - }), - data.NewField("time3", nil, []float64{ - tMilliseconds, - }), - data.NewField("time4", nil, []*float64{ - Pointer(tMilliseconds), - }), - data.NewField("time5", nil, []float64{ - tNanoSeconds, - }), - data.NewField("time6", nil, []*float64{ - Pointer(tNanoSeconds), - }), - data.NewField("time7", nil, []*float64{ - nilPointer, - }), - ) - - for i := 0; i < len(originFrame.Fields); i++ { - err := convertSQLTimeColumnToEpochMS(originFrame, i) - require.NoError(t, err) - } - - require.Equal(t, dt.Unix(), (*originFrame.Fields[0].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[1].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[2].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[3].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[4].At(0).(*time.Time)).Unix()) - require.Equal(t, dt.Unix(), (*originFrame.Fields[5].At(0).(*time.Time)).Unix()) - require.Nil(t, originFrame.Fields[6].At(0)) - }) - - t.Run("Given row values with float32 as time columns", func(t *testing.T) { - tSeconds := float32(dt.Unix()) - var nilInt *float32 - - originFrame := data.NewFrame("", - data.NewField("time1", nil, []float32{ - tSeconds, - }), - data.NewField("time2", nil, []*float32{ - Pointer(tSeconds), - }), - data.NewField("time7", nil, []*float32{ - nilInt, - }), - ) - for i := 0; i < len(originFrame.Fields); i++ { - err := convertSQLTimeColumnToEpochMS(originFrame, i) - require.NoError(t, err) - } - require.Equal(t, int64(tSeconds), (*originFrame.Fields[0].At(0).(*time.Time)).Unix()) - require.Equal(t, int64(tSeconds), (*originFrame.Fields[1].At(0).(*time.Time)).Unix()) - require.Nil(t, originFrame.Fields[2].At(0)) - }) - - t.Run("Given row with value columns, would be converted to float64", func(t *testing.T) { - originFrame := data.NewFrame("", - data.NewField("value1", nil, []int64{ - int64(1), - }), - data.NewField("value2", nil, []*int64{ - Pointer(int64(1)), - }), - data.NewField("value3", nil, []int32{ - int32(1), - }), - data.NewField("value4", nil, []*int32{ - Pointer(int32(1)), - }), - data.NewField("value5", nil, []int16{ - int16(1), - }), - data.NewField("value6", nil, []*int16{ - Pointer(int16(1)), - }), - data.NewField("value7", nil, []int8{ - int8(1), - }), - data.NewField("value8", nil, []*int8{ - Pointer(int8(1)), - }), - data.NewField("value9", nil, []float64{ - float64(1), - }), - data.NewField("value10", nil, []*float64{ - Pointer(1.0), - }), - data.NewField("value11", nil, []float32{ - float32(1), - }), - data.NewField("value12", nil, []*float32{ - Pointer(float32(1)), - }), - data.NewField("value13", nil, []uint64{ - uint64(1), - }), - data.NewField("value14", nil, []*uint64{ - Pointer(uint64(1)), - }), - data.NewField("value15", nil, []uint32{ - uint32(1), - }), - data.NewField("value16", nil, []*uint32{ - Pointer(uint32(1)), - }), - data.NewField("value17", nil, []uint16{ - uint16(1), - }), - data.NewField("value18", nil, []*uint16{ - Pointer(uint16(1)), - }), - data.NewField("value19", nil, []uint8{ - uint8(1), - }), - data.NewField("value20", nil, []*uint8{ - Pointer(uint8(1)), - }), - ) - for i := 0; i < len(originFrame.Fields); i++ { - _, err := convertSQLValueColumnToFloat(originFrame, i) - require.NoError(t, err) - if i == 8 { - require.Equal(t, float64(1), originFrame.Fields[i].At(0).(float64)) - } else { - require.NotNil(t, originFrame.Fields[i].At(0).(*float64)) - require.Equal(t, float64(1), *originFrame.Fields[i].At(0).(*float64)) - } - } - }) - - t.Run("Given row with nil value columns", func(t *testing.T) { - var int64NilPointer *int64 - var int32NilPointer *int32 - var int16NilPointer *int16 - var int8NilPointer *int8 - var float64NilPointer *float64 - var float32NilPointer *float32 - var uint64NilPointer *uint64 - var uint32NilPointer *uint32 - var uint16NilPointer *uint16 - var uint8NilPointer *uint8 - - originFrame := data.NewFrame("", - data.NewField("value1", nil, []*int64{ - int64NilPointer, - }), - data.NewField("value2", nil, []*int32{ - int32NilPointer, - }), - data.NewField("value3", nil, []*int16{ - int16NilPointer, - }), - data.NewField("value4", nil, []*int8{ - int8NilPointer, - }), - data.NewField("value5", nil, []*float64{ - float64NilPointer, - }), - data.NewField("value6", nil, []*float32{ - float32NilPointer, - }), - data.NewField("value7", nil, []*uint64{ - uint64NilPointer, - }), - data.NewField("value8", nil, []*uint32{ - uint32NilPointer, - }), - data.NewField("value9", nil, []*uint16{ - uint16NilPointer, - }), - data.NewField("value10", nil, []*uint8{ - uint8NilPointer, - }), - ) - for i := 0; i < len(originFrame.Fields); i++ { - t.Run("", func(t *testing.T) { - _, err := convertSQLValueColumnToFloat(originFrame, i) - require.NoError(t, err) - require.Nil(t, originFrame.Fields[i].At(0)) - }) - } - }) - - t.Run("Should not return raw connection errors", func(t *testing.T) { - err := net.OpError{Op: "Dial", Err: fmt.Errorf("inner-error")} - transformer := &testQueryResultTransformer{} - dp := DataSourceHandler{ - log: backend.NewLoggerWith("logger", "test"), - queryResultTransformer: transformer, - } - resultErr := dp.TransformQueryError(dp.log, &err) - assert.False(t, transformer.transformQueryErrorWasCalled) - errorText := resultErr.Error() - assert.NotEqual(t, err, resultErr) - assert.NotContains(t, errorText, "inner-error") - assert.Contains(t, errorText, "failed to connect to server") - }) - - t.Run("Should return non-connection errors unmodified", func(t *testing.T) { - err := fmt.Errorf("normal error") - transformer := &testQueryResultTransformer{} - dp := DataSourceHandler{ - log: backend.NewLoggerWith("logger", "test"), - queryResultTransformer: transformer, - } - resultErr := dp.TransformQueryError(dp.log, err) - assert.True(t, transformer.transformQueryErrorWasCalled) - assert.Equal(t, err, resultErr) - assert.ErrorIs(t, err, resultErr) - }) -} - -func TestConvertResultsToFrame(t *testing.T) { - // Import the pgx packages needed for testing - // These imports are included in the main file but need to be accessible for tests - t.Run("convertResultsToFrame with single result", func(t *testing.T) { - // Create mock field descriptions - fieldDescs := []pgconn.FieldDescription{ - {Name: "id", DataTypeOID: pgtype.Int4OID}, - {Name: "name", DataTypeOID: pgtype.TextOID}, - {Name: "value", DataTypeOID: pgtype.Float8OID}, - } - - // Create mock result data - mockRows := [][][]byte{ - {[]byte("1"), []byte("test1"), []byte("10.5")}, - {[]byte("2"), []byte("test2"), []byte("20.7")}, - } - - // Create mock result - result := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows, - } - result.CommandTag = pgconn.NewCommandTag("SELECT 2") - - results := []*pgconn.Result{result} - - frame, err := convertResultsToFrame(results, 1000) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 3, len(frame.Fields)) - require.Equal(t, 2, frame.Rows()) - - // Verify field names - require.Equal(t, "id", frame.Fields[0].Name) - require.Equal(t, "name", frame.Fields[1].Name) - require.Equal(t, "value", frame.Fields[2].Name) - }) - - t.Run("convertResultsToFrame with multiple compatible results", func(t *testing.T) { - // Create mock field descriptions (same structure for both results) - fieldDescs := []pgconn.FieldDescription{ - {Name: "id", DataTypeOID: pgtype.Int4OID}, - {Name: "name", DataTypeOID: pgtype.TextOID}, - } - - // Create first result - mockRows1 := [][][]byte{ - {[]byte("1"), []byte("test1")}, - {[]byte("2"), []byte("test2")}, - } - result1 := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows1, - } - result1.CommandTag = pgconn.NewCommandTag("SELECT 2") - - // Create second result with same structure - mockRows2 := [][][]byte{ - {[]byte("3"), []byte("test3")}, - {[]byte("4"), []byte("test4")}, - } - result2 := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows2, - } - result2.CommandTag = pgconn.NewCommandTag("SELECT 2") - - results := []*pgconn.Result{result1, result2} - - frame, err := convertResultsToFrame(results, 1000) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 2, len(frame.Fields)) - require.Equal(t, 4, frame.Rows()) // Should have rows from both results - - // Verify field names - require.Equal(t, "id", frame.Fields[0].Name) - require.Equal(t, "name", frame.Fields[1].Name) - }) - - t.Run("convertResultsToFrame with row limit", func(t *testing.T) { - // Create mock field descriptions - fieldDescs := []pgconn.FieldDescription{ - {Name: "id", DataTypeOID: pgtype.Int4OID}, - } - - // Create mock result data with 3 rows - mockRows := [][][]byte{ - {[]byte("1")}, - {[]byte("2")}, - {[]byte("3")}, - } - - result := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows, - } - result.CommandTag = pgconn.NewCommandTag("SELECT 3") - - results := []*pgconn.Result{result} - - // Set row limit to 2 - frame, err := convertResultsToFrame(results, 2) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 1, len(frame.Fields)) - require.Equal(t, 2, frame.Rows()) // Should be limited to 2 rows - - // Should have a notice about the limit - require.NotNil(t, frame.Meta) - require.Len(t, frame.Meta.Notices, 1) - require.Contains(t, frame.Meta.Notices[0].Text, "Results have been limited to 2") - }) - - t.Run("convertResultsToFrame with mixed SELECT and non-SELECT results", func(t *testing.T) { - // Create a non-SELECT result (should be skipped) - nonSelectResult := &pgconn.Result{} - nonSelectResult.CommandTag = pgconn.NewCommandTag("UPDATE 1") - - // Create a SELECT result - fieldDescs := []pgconn.FieldDescription{ - {Name: "id", DataTypeOID: pgtype.Int4OID}, - } - mockRows := [][][]byte{ - {[]byte("1")}, - } - selectResult := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows, - } - selectResult.CommandTag = pgconn.NewCommandTag("SELECT 1") - - results := []*pgconn.Result{nonSelectResult, selectResult} - - frame, err := convertResultsToFrame(results, 1000) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 1, len(frame.Fields)) - require.Equal(t, 1, frame.Rows()) - }) - - t.Run("convertResultsToFrame with no SELECT results", func(t *testing.T) { - // Create only non-SELECT results - result1 := &pgconn.Result{} - result1.CommandTag = pgconn.NewCommandTag("UPDATE 1") - - result2 := &pgconn.Result{} - result2.CommandTag = pgconn.NewCommandTag("INSERT 1") - - results := []*pgconn.Result{result1, result2} - - frame, err := convertResultsToFrame(results, 1000) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 0, len(frame.Fields)) - require.Equal(t, 0, frame.Rows()) - }) - - t.Run("convertResultsToFrame with multiple results and row limit per result", func(t *testing.T) { - // Create mock field descriptions (same structure for both results) - fieldDescs := []pgconn.FieldDescription{ - {Name: "id", DataTypeOID: pgtype.Int4OID}, - } - - // Create first result with 3 rows - mockRows1 := [][][]byte{ - {[]byte("1")}, - {[]byte("2")}, - {[]byte("3")}, - } - result1 := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows1, - } - result1.CommandTag = pgconn.NewCommandTag("SELECT 3") - - // Create second result with 3 rows - mockRows2 := [][][]byte{ - {[]byte("4")}, - {[]byte("5")}, - {[]byte("6")}, - } - result2 := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows2, - } - result2.CommandTag = pgconn.NewCommandTag("SELECT 3") - - results := []*pgconn.Result{result1, result2} - - // Set row limit to 2 (should limit each result to 2 rows) - frame, err := convertResultsToFrame(results, 2) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 1, len(frame.Fields)) - require.Equal(t, 4, frame.Rows()) // 2 rows from each result - - // Should have notices about the limit from both results - require.NotNil(t, frame.Meta) - require.Len(t, frame.Meta.Notices, 2) - require.Contains(t, frame.Meta.Notices[0].Text, "Results have been limited to 2") - require.Contains(t, frame.Meta.Notices[1].Text, "Results have been limited to 2") - }) - - t.Run("convertResultsToFrame handles null values correctly", func(t *testing.T) { - // Create mock field descriptions - fieldDescs := []pgconn.FieldDescription{ - {Name: "id", DataTypeOID: pgtype.Int4OID}, - {Name: "name", DataTypeOID: pgtype.TextOID}, - } - - // Create mock result data with null values - mockRows := [][][]byte{ - {[]byte("1"), nil}, // null name - {nil, []byte("test2")}, // null id - } - - result := &pgconn.Result{ - FieldDescriptions: fieldDescs, - Rows: mockRows, - } - result.CommandTag = pgconn.NewCommandTag("SELECT 2") - - results := []*pgconn.Result{result} - - frame, err := convertResultsToFrame(results, 1000) - require.NoError(t, err) - require.NotNil(t, frame) - require.Equal(t, 2, len(frame.Fields)) - require.Equal(t, 2, frame.Rows()) - - // Check that null values are handled correctly - // The exact representation depends on the field type, but should not panic - require.NotPanics(t, func() { - frame.Fields[0].At(1) // null id - frame.Fields[1].At(0) // null name - }) - }) -} - -type testQueryResultTransformer struct { - transformQueryErrorWasCalled bool -} - -func (t *testQueryResultTransformer) TransformQueryError(_ log.Logger, err error) error { - t.transformQueryErrorWasCalled = true - return err -} - -func (t *testQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { - return nil -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager.go b/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager.go deleted file mode 100644 index 89b43263aec..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager.go +++ /dev/null @@ -1,151 +0,0 @@ -package postgres - -import ( - "fmt" - "os" - - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" -) - -var validateCertFuncPgx = validateCertFilePathsPgx - -type pgxTlsManager struct { - logger log.Logger -} - -func newPgxTlsManager(logger log.Logger) *pgxTlsManager { - return &pgxTlsManager{ - logger: logger, - } -} - -// getTLSSettings retrieves TLS settings and handles certificate file creation if needed. -func (m *pgxTlsManager) getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) { - tlsConfig := tlsSettings{ - Mode: dsInfo.JsonData.Mode, - } - - if tlsConfig.Mode == "disable" { - m.logger.Debug("Postgres TLS/SSL is disabled") - return tlsConfig, nil - } - - tlsConfig.ConfigurationMethod = dsInfo.JsonData.ConfigurationMethod - tlsConfig.RootCertFile = dsInfo.JsonData.RootCertFile - tlsConfig.CertFile = dsInfo.JsonData.CertFile - tlsConfig.CertKeyFile = dsInfo.JsonData.CertKeyFile - - if tlsConfig.ConfigurationMethod == "file-content" { - if err := m.createCertFiles(dsInfo, &tlsConfig); err != nil { - return tlsConfig, fmt.Errorf("failed to create TLS certificate files: %w", err) - } - } else { - if err := validateCertFuncPgx(tlsConfig.RootCertFile, tlsConfig.CertFile, tlsConfig.CertKeyFile); err != nil { - return tlsConfig, fmt.Errorf("invalid TLS certificate file paths: %w", err) - } - } - - return tlsConfig, nil -} - -// createCertFiles writes certificate files to temporary locations. -func (m *pgxTlsManager) createCertFiles(dsInfo sqleng.DataSourceInfo, tlsConfig *tlsSettings) error { - m.logger.Debug("Writing TLS certificate files to temporary locations") - - var err error - if tlsConfig.RootCertFile, err = m.writeCertFile("root-*.crt", dsInfo.DecryptedSecureJSONData["tlsCACert"]); err != nil { - return err - } - if tlsConfig.CertFile, err = m.writeCertFile("client-*.crt", dsInfo.DecryptedSecureJSONData["tlsClientCert"]); err != nil { - return err - } - if tlsConfig.CertKeyFile, err = m.writeCertFile("client-*.key", dsInfo.DecryptedSecureJSONData["tlsClientKey"]); err != nil { - return err - } - - return nil -} - -// writeCertFile writes a single certificate file to a temporary location. -func (m *pgxTlsManager) writeCertFile(pattern, content string) (string, error) { - if content == "" { - return "", nil - } - - m.logger.Debug("Writing certificate file", "pattern", pattern) - file, err := os.CreateTemp("", pattern) - if err != nil { - return "", fmt.Errorf("failed to create temporary file: %w", err) - } - defer func() { - if err := file.Close(); err != nil { - m.logger.Error("Failed to close file", "error", err) - } - }() - - if _, err := file.WriteString(content); err != nil { - return "", fmt.Errorf("failed to write to temporary file: %w", err) - } - - return file.Name(), nil -} - -// cleanupCertFiles removes temporary certificate files. -func (m *pgxTlsManager) cleanupCertFiles(tlsConfig tlsSettings) { - // Only clean up if the configuration method is "file-content" - if tlsConfig.ConfigurationMethod != "file-content" { - m.logger.Debug("Skipping cleanup of TLS certificate files") - return - } - m.logger.Debug("Cleaning up TLS certificate files") - - files := []struct { - path string - name string - }{ - {tlsConfig.RootCertFile, "root certificate"}, - {tlsConfig.CertFile, "client certificate"}, - {tlsConfig.CertKeyFile, "client key"}, - } - - for _, file := range files { - if file.path == "" { - continue - } - if err := os.Remove(file.path); err != nil { - m.logger.Error("Failed to remove file", "type", file.name, "path", file.path, "error", err) - } else { - m.logger.Debug("Successfully removed file", "type", file.name, "path", file.path) - } - } -} - -// validateCertFilePaths validates the existence of configured certificate file paths. -func validateCertFilePathsPgx(rootCert, clientCert, clientKey string) error { - for _, path := range []string{rootCert, clientCert, clientKey} { - if path == "" { - continue - } - exists, err := fileExistsPgx(path) - if err != nil { - return fmt.Errorf("error checking file existence: %w", err) - } - if !exists { - return sqleng.ErrCertFileNotExist - } - } - return nil -} - -// fileExists checks if a file exists at the given path. -func fileExistsPgx(path string) (bool, error) { - _, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager_test.go b/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager_test.go deleted file mode 100644 index 4717469348f..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/pgxtlsmanager_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package postgres - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Test getTLSSettings. -func TestPgxGetTLSSettings(t *testing.T) { - mockValidateCertFilePathsPgx() - t.Cleanup(resetValidateCertFilePathsPgx) - - updatedTime := time.Now() - - testCases := []struct { - desc string - expErr string - jsonData sqleng.JsonData - secureJSONData map[string]string - uid string - tlsSettings tlsSettings - updated time.Time - }{ - { - desc: "Custom TLS authentication disabled", - updated: updatedTime, - jsonData: sqleng.JsonData{ - Mode: "disable", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - ConfigurationMethod: "file-path", - }, - tlsSettings: tlsSettings{Mode: "disable"}, - }, - { - desc: "Custom TLS authentication with file path", - updated: updatedTime.Add(time.Minute), - jsonData: sqleng.JsonData{ - Mode: "verify-full", - ConfigurationMethod: "file-path", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - }, - tlsSettings: tlsSettings{ - Mode: "verify-full", - ConfigurationMethod: "file-path", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - }, - }, - } - for _, tt := range testCases { - t.Run(tt.desc, func(t *testing.T) { - var settings tlsSettings - var err error - mng := pgxTlsManager{ - logger: backend.NewLoggerWith("logger", "tsdb.postgres"), - } - - ds := sqleng.DataSourceInfo{ - JsonData: tt.jsonData, - DecryptedSecureJSONData: tt.secureJSONData, - UID: tt.uid, - Updated: tt.updated, - } - - settings, err = mng.getTLSSettings(ds) - - if tt.expErr == "" { - require.NoError(t, err, tt.desc) - assert.Equal(t, tt.tlsSettings, settings) - } else { - require.Error(t, err, tt.desc) - assert.True(t, strings.HasPrefix(err.Error(), tt.expErr), - fmt.Sprintf("%s: %q doesn't start with %q", tt.desc, err, tt.expErr)) - } - }) - } -} - -func mockValidateCertFilePathsPgx() { - validateCertFuncPgx = func(rootCert, clientCert, clientKey string) error { - return nil - } -} - -func resetValidateCertFilePathsPgx() { - validateCertFuncPgx = validateCertFilePathsPgx -} - -func TestTLSManager_GetTLSSettings(t *testing.T) { - logger := log.New() - tlsManager := newPgxTlsManager(logger) - - dsInfo := sqleng.DataSourceInfo{ - JsonData: sqleng.JsonData{ - Mode: "require", - ConfigurationMethod: "file-content", - }, - DecryptedSecureJSONData: map[string]string{ - "tlsCACert": "root-cert-content", - "tlsClientCert": "client-cert-content", - "tlsClientKey": "client-key-content", - }, - } - - tlsConfig, err := tlsManager.getTLSSettings(dsInfo) - require.NoError(t, err) - assert.Equal(t, "require", tlsConfig.Mode) - assert.NotEmpty(t, tlsConfig.RootCertFile) - assert.NotEmpty(t, tlsConfig.CertFile) - assert.NotEmpty(t, tlsConfig.CertKeyFile) - - // Cleanup temporary files - tlsManager.cleanupCertFiles(tlsConfig) - assert.NoFileExists(t, tlsConfig.RootCertFile) - assert.NoFileExists(t, tlsConfig.CertFile) - assert.NoFileExists(t, tlsConfig.CertKeyFile) -} - -func TestTLSManager_CleanupCertFiles_FilePath(t *testing.T) { - logger := log.New() - tlsManager := newPgxTlsManager(logger) - - // Create temporary files for testing - rootCertFile, err := tlsManager.writeCertFile("root-*.crt", "root-cert-content") - require.NoError(t, err) - clientCertFile, err := tlsManager.writeCertFile("client-*.crt", "client-cert-content") - require.NoError(t, err) - clientKeyFile, err := tlsManager.writeCertFile("client-*.key", "client-key-content") - require.NoError(t, err) - - // Simulate a configuration where the method is "file-path" - tlsConfig := tlsSettings{ - ConfigurationMethod: "file-path", - RootCertFile: rootCertFile, - CertFile: clientCertFile, - CertKeyFile: clientKeyFile, - } - - // Call cleanupCertFiles - tlsManager.cleanupCertFiles(tlsConfig) - - // Verify the files are NOT deleted - assert.FileExists(t, rootCertFile, "Root certificate file should not be deleted") - assert.FileExists(t, clientCertFile, "Client certificate file should not be deleted") - assert.FileExists(t, clientKeyFile, "Client key file should not be deleted") - - // Cleanup the files manually - err = os.Remove(rootCertFile) - require.NoError(t, err) - err = os.Remove(clientCertFile) - require.NoError(t, err) - err = os.Remove(clientKeyFile) - require.NoError(t, err) -} - -func TestTLSManager_CreateCertFiles(t *testing.T) { - logger := log.New() - tlsManager := newPgxTlsManager(logger) - - dsInfo := sqleng.DataSourceInfo{ - DecryptedSecureJSONData: map[string]string{ - "tlsCACert": "root-cert-content", - "tlsClientCert": "client-cert-content", - "tlsClientKey": "client-key-content", - }, - } - - tlsConfig := tlsSettings{ - ConfigurationMethod: "file-content", - } - err := tlsManager.createCertFiles(dsInfo, &tlsConfig) - require.NoError(t, err) - - assert.FileExists(t, tlsConfig.RootCertFile) - assert.FileExists(t, tlsConfig.CertFile) - assert.FileExists(t, tlsConfig.CertKeyFile) - - // Cleanup temporary files - tlsManager.cleanupCertFiles(tlsConfig) - assert.NoFileExists(t, tlsConfig.RootCertFile) - assert.NoFileExists(t, tlsConfig.CertFile) - assert.NoFileExists(t, tlsConfig.CertKeyFile) -} - -func TestTLSManager_WriteCertFile(t *testing.T) { - logger := log.New() - tlsManager := newPgxTlsManager(logger) - - // Test writing a valid certificate file - filePath, err := tlsManager.writeCertFile("test-*.crt", "test-cert-content") - require.NoError(t, err) - assert.FileExists(t, filePath) - - content, err := os.ReadFile(filepath.Clean(filePath)) - require.NoError(t, err) - assert.Equal(t, "test-cert-content", string(content)) - - // Cleanup the file - err = os.Remove(filePath) - require.NoError(t, err) - assert.NoFileExists(t, filePath) -} - -func TestTLSManager_CleanupCertFiles(t *testing.T) { - logger := log.New() - tlsManager := newPgxTlsManager(logger) - - // Create temporary files for testing - rootCertFile, err := tlsManager.writeCertFile("root-*.crt", "root-cert-content") - require.NoError(t, err) - clientCertFile, err := tlsManager.writeCertFile("client-*.crt", "client-cert-content") - require.NoError(t, err) - clientKeyFile, err := tlsManager.writeCertFile("client-*.key", "client-key-content") - require.NoError(t, err) - - tlsConfig := tlsSettings{ - ConfigurationMethod: "file-content", - RootCertFile: rootCertFile, - CertFile: clientCertFile, - CertKeyFile: clientKeyFile, - } - - // Cleanup the files - tlsManager.cleanupCertFiles(tlsConfig) - - // Verify the files are deleted - assert.NoFileExists(t, rootCertFile) - assert.NoFileExists(t, clientCertFile) - assert.NoFileExists(t, clientKeyFile) -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index 379741a18e9..6c7ea9552fb 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -2,7 +2,6 @@ package postgres import ( "context" - "database/sql" "encoding/json" "fmt" "reflect" @@ -16,63 +15,12 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/jackc/pgx/v5/pgxpool" - "github.com/lib/pq" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - sqlengpgx "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/pgx" "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" ) -func newPostgres(ctx context.Context, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, *sqleng.DataSourceHandler, error) { - connector, err := pq.NewConnector(cnnstr) - if err != nil { - logger.Error("postgres connector creation failed", "error", err) - return nil, nil, fmt.Errorf("postgres connector creation failed") - } - - proxyClient, err := settings.ProxyClient(ctx) - if err != nil { - logger.Error("postgres proxy creation failed", "error", err) - return nil, nil, fmt.Errorf("postgres proxy creation failed") - } - - if proxyClient.SecureSocksProxyEnabled() { - dialer, err := proxyClient.NewSecureSocksProxyContextDialer() - if err != nil { - logger.Error("postgres proxy creation failed", "error", err) - return nil, nil, fmt.Errorf("postgres proxy creation failed") - } - postgresDialer := newPostgresProxyDialer(dialer) - // update the postgres dialer with the proxy dialer - connector.Dialer(postgresDialer) - } - - config := sqleng.DataPluginConfiguration{ - DSInfo: dsInfo, - MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, - RowLimit: rowLimit, - } - - queryResultTransformer := postgresQueryResultTransformer{} - - db := sql.OpenDB(connector) - - db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) - db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) - db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) - - handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), - logger) - if err != nil { - logger.Error("Failed connecting to Postgres", "err", err) - return nil, nil, err - } - - logger.Debug("Successfully connected to Postgres") - return db, handler, nil -} - -func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*pgxpool.Pool, *sqlengpgx.DataSourceHandler, error) { +func newPostgres(ctx context.Context, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*pgxpool.Pool, *sqleng.DataSourceHandler, error) { pgxConf, err := pgxpool.ParseConfig(cnnstr) if err != nil { logger.Error("postgres config creation failed", "error", err) @@ -92,7 +40,7 @@ func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit return nil, nil, fmt.Errorf("postgres proxy creation failed") } - pgxConf.ConnConfig.DialFunc = newPgxDialFunc(dialer) + pgxConf.ConnConfig.DialFunc = newDialFunc(dialer) } // by default pgx resolves hostnames to ip addresses. we must avoid this. @@ -101,7 +49,7 @@ func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit return []string{host}, nil } - config := sqlengpgx.DataPluginConfiguration{ + config := sqleng.DataPluginConfiguration{ DSInfo: dsInfo, MetricColumnTypes: []string{"unknown", "text", "varchar", "char", "bpchar"}, RowLimit: rowLimit, @@ -117,7 +65,7 @@ func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit return nil, nil, err } - handler, err := sqlengpgx.NewQueryDataHandler(userFacingDefaultError, p, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), + handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, p, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), logger) if err != nil { logger.Error("Failed connecting to Postgres", "err", err) @@ -128,7 +76,7 @@ func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit return p, handler, nil } -func NewInstanceSettings(logger log.Logger, dataPath string) datasource.InstanceFactoryFunc { +func NewInstanceSettings(logger log.Logger) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { cfg := backend.GrafanaConfigFromContext(ctx) sqlCfg, err := cfg.SQL() @@ -171,48 +119,26 @@ func NewInstanceSettings(logger log.Logger, dataPath string) datasource.Instance return nil, err } - usePGX := cfg.FeatureToggles().IsEnabled("postgresDSUsePGX") - - if usePGX { - pgxlogger := logger.FromContext(ctx).With("driver", "pgx") - pgxTlsManager := newPgxTlsManager(pgxlogger) - pgxTlsSettings, err := pgxTlsManager.getTLSSettings(dsInfo) - if err != nil { - return "", err - } - - // Ensure cleanupCertFiles is called after the connection is opened - defer pgxTlsManager.cleanupCertFiles(pgxTlsSettings) - cnnstr, err := generateConnectionString(dsInfo, pgxTlsSettings, usePGX, pgxlogger) - if err != nil { - return "", err - } - _, handler, err := newPostgresPGX(ctx, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, pgxlogger, settings) - if err != nil { - pgxlogger.Error("Failed connecting to Postgres", "err", err) - return nil, err - } - pgxlogger.Debug("Successfully connected to Postgres") - return handler, nil - } else { - pqlogger := logger.FromContext(ctx).With("driver", "libpq") - tlsManager := newTLSManager(pqlogger, dataPath) - tlsSettings, err := tlsManager.getTLSSettings(dsInfo) - if err != nil { - return "", err - } - cnnstr, err := generateConnectionString(dsInfo, tlsSettings, usePGX, pqlogger) - if err != nil { - return nil, err - } - _, handler, err := newPostgres(ctx, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, pqlogger, settings) - if err != nil { - pqlogger.Error("Failed connecting to Postgres", "err", err) - return nil, err - } - pqlogger.Debug("Successfully connected to Postgres") - return handler, nil + pgxlogger := logger.FromContext(ctx).With("driver", "pgx") + tlsManager := newTLSManager(pgxlogger) + tlsSettings, err := tlsManager.getTLSSettings(dsInfo) + if err != nil { + return "", err } + + // Ensure cleanupCertFiles is called after the connection is opened + defer tlsManager.cleanupCertFiles(tlsSettings) + cnnstr, err := generateConnectionString(dsInfo, tlsSettings, pgxlogger) + if err != nil { + return "", err + } + _, handler, err := newPostgres(ctx, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, pgxlogger, settings) + if err != nil { + pgxlogger.Error("Failed connecting to Postgres", "err", err) + return nil, err + } + pgxlogger.Debug("Successfully connected to Postgres") + return handler, nil } } @@ -302,7 +228,7 @@ func buildBaseConnectionString(params connectionParams) string { return connStr } -func generateConnectionString(dsInfo sqleng.DataSourceInfo, tlsSettings tlsSettings, isPGX bool, logger log.Logger) (string, error) { +func generateConnectionString(dsInfo sqleng.DataSourceInfo, tlsSettings tlsSettings, logger log.Logger) (string, error) { params, err := parseConnectionParams(dsInfo, logger) if err != nil { return "", err @@ -312,15 +238,6 @@ func generateConnectionString(dsInfo sqleng.DataSourceInfo, tlsSettings tlsSetti connStr += fmt.Sprintf(" sslmode='%s'", escape(tlsSettings.Mode)) - // there is an issue with the lib/pq module, the `verify-ca` tls mode - // does not work correctly. ( see https://github.com/lib/pq/issues/1106 ) - // to workaround the problem, if the `verify-ca` mode is chosen, - // we disable sslsni. - if tlsSettings.Mode == "verify-ca" && !isPGX { - logger.Debug("Disabling sslsni for verify-ca mode") - connStr += " sslsni=0" - } - // Attach root certificate if provided if tlsSettings.RootCertFile != "" { logger.Debug("Setting server root certificate", "tlsRootCert", tlsSettings.RootCertFile) diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_snapshot_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_snapshot_test.go deleted file mode 100644 index e5a5decf3ae..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_snapshot_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package postgres - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" - "github.com/grafana/grafana/pkg/util/testutil" -) - -// These tests require a real postgres database: -// - make devenv sources=postgres_tests -// - either set the env variable GRAFANA_TEST_DB = postgres -// - or set `forceRun := true` below -// -// The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! -// Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a -// preconfigured Postgres server suitable for running these tests. -func TestIntegrationPostgresPGXSnapshots(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - // the logic in this function is copied from postgres_tests.go - shouldRunTest := func() bool { - testDbName, present := os.LookupEnv("GRAFANA_TEST_DB") - - if present && testDbName == "postgres" { - return true - } - - return false - } - - if !shouldRunTest() { - t.Skip() - } - - getCnnStr := func() string { - host := os.Getenv("POSTGRES_HOST") - if host == "" { - host = "localhost" - } - port := os.Getenv("POSTGRES_PORT") - if port == "" { - port = "5432" - } - - return fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable", - host, port) - } - - sqlQueryCommentRe := regexp.MustCompile(`^-- (.+)\n`) - - readSqlFile := func(path string) (string, string) { - // the file-path is not coming from the outside, - // it is hardcoded in this file. - //nolint:gosec - sqlBytes, err := os.ReadFile(path) - require.NoError(t, err) - - sql := string(sqlBytes) - - // first line of the file contains the sql query to run, commented out - match := sqlQueryCommentRe.FindStringSubmatch(sql) - require.Len(t, match, 2) - - rawSQL := strings.TrimSpace(match[1]) - - return rawSQL, sql - } - - makeQuery := func(rawSQL string, format string) backend.QueryDataRequest { - queryData := map[string]string{ - "rawSql": rawSQL, - "format": format, - } - - queryBytes, err := json.Marshal(queryData) - require.NoError(t, err) - - return backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: queryBytes, - RefID: "A", - TimeRange: backend.TimeRange{ - From: time.Date(2023, 12, 24, 14, 15, 22, 123456, time.UTC), - To: time.Date(2023, 12, 24, 14, 45, 13, 876543, time.UTC), - }, - }, - }, - } - } - - tt := []struct { - name string - format string - }{ - {format: "time_series", name: "simple"}, - {format: "time_series", name: "no_rows_long"}, - {format: "time_series", name: "no_rows_wide"}, - {format: "time_series", name: "7x_compat_metric_label"}, - {format: "time_series", name: "convert_to_float64"}, - {format: "time_series", name: "convert_to_float64_not"}, - {format: "time_series", name: "fill_null"}, - {format: "time_series", name: "fill_previous"}, - {format: "time_series", name: "fill_value"}, - {format: "time_series", name: "fill_value_wide"}, - {format: "table", name: "simple"}, - {format: "table", name: "multi_stat1"}, - {format: "table", name: "multi_stat2"}, - {format: "table", name: "no_rows"}, - {format: "table", name: "types_numeric"}, - {format: "table", name: "types_char"}, - {format: "table", name: "types_datetime_pgx"}, - {format: "table", name: "types_other"}, - {format: "table", name: "types_enum"}, - {format: "table", name: "types_jsonb"}, - {format: "table", name: "timestamp_convert_bigint"}, - {format: "table", name: "timestamp_convert_integer"}, - {format: "table", name: "timestamp_convert_real"}, - {format: "table", name: "timestamp_convert_double"}, - {format: "table", name: "time_group_compat_case1"}, - {format: "table", name: "time_group_compat_case2"}, - } - - for _, test := range tt { - require.True(t, test.format == "table" || test.format == "time_series") - t.Run(test.name, func(t *testing.T) { - origInterpolate := sqleng.Interpolate - t.Cleanup(func() { - sqleng.Interpolate = origInterpolate - }) - - sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { - return sql - } - - jsonData := sqleng.JsonData{ - MaxOpenConns: 10, - MaxIdleConns: 2, - ConnMaxLifetime: 14400, - Timescaledb: false, - Mode: "disable", - ConfigurationMethod: "file-path", - } - - dsInfo := sqleng.DataSourceInfo{ - JsonData: jsonData, - DecryptedSecureJSONData: map[string]string{}, - } - - logger := log.New() - - cnnstr := getCnnStr() - - p, handler, err := newPostgresPGX(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) - - t.Cleanup((func() { - _, err := p.Exec(context.Background(), "DROP TABLE tbl") - require.NoError(t, err) - p.Close() - })) - - require.NoError(t, err) - - sqlFilePath := filepath.Join("testdata", test.format, test.name+".sql") - goldenFileName := filepath.Join(test.format, test.name+".golden") - - rawSQL, sql := readSqlFile(sqlFilePath) - - _, err = p.Exec(context.Background(), sql) - require.NoError(t, err) - - query := makeQuery(rawSQL, test.format) - - result, err := handler.QueryData(context.Background(), &query) - require.Len(t, result.Responses, 1) - response, found := result.Responses["A"] - require.True(t, found) - require.NoError(t, err) - experimental.CheckGoldenJSONResponse(t, "testdata", goldenFileName, &response, updateGoldenFiles) - }) - } -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go deleted file mode 100644 index 7c66bde7580..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go +++ /dev/null @@ -1,1579 +0,0 @@ -package postgres - -import ( - "encoding/json" - "fmt" - "math/rand" - "os" - "strings" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" - - "github.com/grafana/grafana/pkg/util/testutil" - _ "github.com/lib/pq" -) - -// Test generateConnectionString. -func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - testCases := []struct { - desc string - host string - user string - password string - database string - tlsSettings tlsSettings - expConnStr string - expErr string - uid string - }{ - { - desc: "Unix socket host", - host: "/var/run/postgresql", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' host='/var/run/postgresql' dbname='database' password='password' sslmode='verify-full'", - }, - { - desc: "TCP host", - host: "host", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full'", - }, - { - desc: "verify-ca automatically adds disable-sni", - host: "host:1234", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-ca"}, - expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-ca' sslsni=0", - }, - { - desc: "TCP/port host", - host: "host:1234", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-full'", - }, - { - desc: "Ipv6 host", - host: "[::1]", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' host='::1' dbname='database' password='password' sslmode='verify-full'", - }, - { - desc: "Ipv6/port host", - host: "[::1]:1234", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' host='::1' dbname='database' password='password' port=1234 sslmode='verify-full'", - }, - { - desc: "Invalid port", - host: "host:invalid", - user: "user", - database: "database", - tlsSettings: tlsSettings{}, - expErr: "error parsing postgres url", - }, - { - desc: "Password with single quote and backslash", - host: "host", - user: "user", - password: `p'\assword`, - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='user' host='host' dbname='database' password='p\'\\assword' sslmode='verify-full'`, - }, - { - desc: "User/DB with single quote and backslash", - host: "host", - user: `u'\ser`, - password: `password`, - database: `d'\atabase`, - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='u\'\\ser' host='host' dbname='d\'\\atabase' password='password' sslmode='verify-full'`, - }, - { - desc: "Custom TLS mode disabled", - host: "host", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "disable"}, - expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='disable'", - }, - { - desc: "Custom TLS mode verify-full with certificate files", - host: "host", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{ - Mode: "verify-full", - RootCertFile: "i/am/coding/ca.crt", - CertFile: "i/am/coding/client.crt", - CertKeyFile: "i/am/coding/client.key", - }, - expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full' " + - "sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'", - }, - { - desc: "No password", - host: "host", - user: "user", - password: "", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' host='host' dbname='database' sslmode='verify-full'", - }, - } - for _, tt := range testCases { - t.Run(tt.desc, func(t *testing.T) { - ds := sqleng.DataSourceInfo{ - URL: tt.host, - User: tt.user, - DecryptedSecureJSONData: map[string]string{"password": tt.password}, - Database: tt.database, - UID: tt.uid, - } - logger := backend.NewLoggerWith("logger", "tsdb.postgres") - - connStr, err := generateConnectionString(ds, tt.tlsSettings, false, logger) - - if tt.expErr == "" { - require.NoError(t, err, tt.desc) - assert.Equal(t, tt.expConnStr, connStr) - } else { - require.Error(t, err, tt.desc) - assert.True(t, strings.HasPrefix(err.Error(), tt.expErr), - fmt.Sprintf("%s: %q doesn't start with %q", tt.desc, err, tt.expErr)) - } - }) - } -} - -// To run this test, set runPostgresTests=true -// Or from the commandline: GRAFANA_TEST_DB=postgres go test -tags=integration -v ./pkg/tsdb/grafana-postgresql-datasource -// The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! -// Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a -// preconfigured Postgres server suitable for running these tests. -// There is also a datasource and dashboard provisioned by devenv scripts that you can -// use to verify that the generated data are visualized as expected, see -// devenv/README.md for setup instructions. -func TestIntegrationPostgresPGX(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - // change to true to run the PostgreSQL tests - const runPostgresTests = false - - if !isTestDbPostgres() && !runPostgresTests { - t.Skip() - } - - origInterpolate := sqleng.Interpolate - t.Cleanup(func() { - sqleng.Interpolate = origInterpolate - }) - sqleng.Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, timeInterval string, sql string) string { - return sql - } - - jsonData := sqleng.JsonData{ - MaxOpenConns: 10, - MaxIdleConns: 2, - ConnMaxLifetime: 14400, - Timescaledb: false, - Mode: "disable", - ConfigurationMethod: "file-path", - } - - dsInfo := sqleng.DataSourceInfo{ - JsonData: jsonData, - DecryptedSecureJSONData: map[string]string{}, - } - - logger := backend.NewLoggerWith("logger", "postgres.test") - - cnnstr := postgresTestDBConnString() - - p, exe, err := newPostgresPGX(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) - - require.NoError(t, err) - - fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) - - t.Run("Given a table with different native data types", func(t *testing.T) { - sql := ` - DROP TABLE IF EXISTS postgres_types; - CREATE TABLE postgres_types( - c00_smallint smallint, - c01_integer integer, - c02_bigint bigint, - - c03_real real, - c04_double double precision, - c05_decimal decimal(10,2), - c06_numeric numeric(10,2), - - c07_char char(10), - c08_varchar varchar(10), - c09_text text, - - c10_timestamp timestamp without time zone, - c11_timestamptz timestamp with time zone, - c12_date date, - c13_time time without time zone, - c14_timetz time with time zone, - time date, - c15_interval interval, - c16_smallint smallint, - - c17_json json, - c18_jsonb jsonb - ); - ` - _, err := p.Exec(t.Context(), sql) - require.NoError(t, err) - - sql = ` - INSERT INTO postgres_types VALUES( - 1,2,3, - 4.5,6.7,1.1,1.2, - 'char10','varchar10','text', - now(),now(),now(),now(),now(),now(),'15m'::interval,null, - '{"key1": "value1"}'::json, '{"key2": "value2"}'::jsonb - ); - ` - _, err = p.Exec(t.Context(), sql) - require.NoError(t, err) - - t.Run("When doing a table query should map Postgres column types to Go types", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT * FROM postgres_types", - "format": "table" - }`), - RefID: "A", - }, - }, - } - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.Len(t, frames[0].Fields, 20) - - require.Equal(t, int16(1), *frames[0].Fields[0].At(0).(*int16)) - require.Equal(t, int32(2), *frames[0].Fields[1].At(0).(*int32)) - require.Equal(t, int64(3), *frames[0].Fields[2].At(0).(*int64)) - - require.Equal(t, float64(4.5), *frames[0].Fields[3].At(0).(*float64)) - require.Equal(t, float64(6.7), *frames[0].Fields[4].At(0).(*float64)) - require.Equal(t, float64(1.1), *frames[0].Fields[5].At(0).(*float64)) - require.Equal(t, float64(1.2), *frames[0].Fields[6].At(0).(*float64)) - - require.Equal(t, "char10 ", *frames[0].Fields[7].At(0).(*string)) - require.Equal(t, "varchar10", *frames[0].Fields[8].At(0).(*string)) - require.Equal(t, "text", *frames[0].Fields[9].At(0).(*string)) - - _, ok := frames[0].Fields[10].At(0).(*time.Time) - require.True(t, ok) - _, ok = frames[0].Fields[11].At(0).(*time.Time) - require.True(t, ok) - _, ok = frames[0].Fields[12].At(0).(*time.Time) - require.True(t, ok) - _, ok = frames[0].Fields[13].At(0).(*string) - require.True(t, ok) - _, ok = frames[0].Fields[14].At(0).(*string) - require.True(t, ok) - _, ok = frames[0].Fields[15].At(0).(*time.Time) - require.True(t, ok) - require.Equal(t, "00:15:00", *frames[0].Fields[16].At(0).(*string)) - require.Nil(t, frames[0].Fields[17].At(0)) - - _, ok = frames[0].Fields[18].At(0).(*json.RawMessage) - require.True(t, ok) - require.Equal(t, json.RawMessage(`{"key1": "value1"}`), *frames[0].Fields[18].At(0).(*json.RawMessage)) - _, ok = frames[0].Fields[19].At(0).(*json.RawMessage) - require.True(t, ok) - require.Equal(t, json.RawMessage(`{"key2": "value2"}`), *frames[0].Fields[19].At(0).(*json.RawMessage)) - }) - }) - - t.Run("Given a table with metrics that lacks data for some series ", func(t *testing.T) { - sql := ` - DROP TABLE IF EXISTS metric; - CREATE TABLE metric ( - time timestamp, - value integer - ) - ` - - _, err := p.Exec(t.Context(), sql) - require.NoError(t, err) - - type metric struct { - Time time.Time - Value int64 - } - - series := []*metric{} - firstRange := genTimeRangeByInterval(fromStart, 10*time.Minute, 10*time.Second) - secondRange := genTimeRangeByInterval(fromStart.Add(20*time.Minute), 10*time.Minute, 10*time.Second) - - for _, t := range firstRange { - series = append(series, &metric{ - Time: t, - Value: 15, - }) - } - - for _, t := range secondRange { - series = append(series, &metric{ - Time: t, - Value: 20, - }) - } - - for _, m := range series { - _, err := p.Exec(t.Context(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) - require.NoError(t, err) - } - - t.Run("When doing a metric query using timeGroup", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.Equal(t, 4, frames[0].Fields[0].Len()) - - // without fill this should result in 4 buckets - - dt := fromStart - - for i := 0; i < 2; i++ { - aValue := *frames[0].Fields[1].At(i).(*float64) - aTime := *frames[0].Fields[0].At(i).(*time.Time) - require.Equal(t, float64(15), aValue) - require.Equal(t, dt, aTime) - dt = dt.Add(5 * time.Minute) - } - - // adjust for 10 minute gap between first and second set of points - dt = dt.Add(10 * time.Minute) - for i := 2; i < 4; i++ { - aValue := *frames[0].Fields[1].At(i).(*float64) - aTime := *frames[0].Fields[0].At(i).(*time.Time) - require.Equal(t, float64(20), aValue) - require.Equal(t, dt, aTime) - dt = dt.Add(5 * time.Minute) - } - }) - - t.Run("When doing a query without a format should default to time_series", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1 " - }`), - RefID: "A", - }, - }, - } - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.Len(t, frames[0].Fields, 2) - }) - - t.Run("When doing a metric query using timeGroup and $__interval", func(t *testing.T) { - mockInterpolate := sqleng.Interpolate - sqleng.Interpolate = origInterpolate - t.Cleanup(func() { - sqleng.Interpolate = mockInterpolate - }) - - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - Interval: time.Second * 60, - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(30 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - frames := queryResult.Frames - - require.NoError(t, queryResult.Error) - require.Equal(t, - "SELECT floor(extract(epoch from time)/60)*60 AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", - frames[0].Meta.ExecutedQueryString) - }) - - t.Run("When doing a metric query using timeGroup with NULL fill enabled", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeGroup(time, '5m', NULL) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(34 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 7, frames[0].Fields[0].Len()) - - dt := fromStart - - for i := 0; i < 2; i++ { - aValue := *frames[0].Fields[1].At(i).(*float64) - aTime := *frames[0].Fields[0].At(i).(*time.Time) - require.Equal(t, float64(15), aValue) - require.True(t, aTime.Equal(dt)) - dt = dt.Add(5 * time.Minute) - } - - // check for NULL values inserted by fill - require.Nil(t, frames[0].Fields[1].At(2)) - require.Nil(t, frames[0].Fields[1].At(3)) - - // adjust for 10 minute gap between first and second set of points - dt = dt.Add(10 * time.Minute) - for i := 4; i < 6; i++ { - aValue := *frames[0].Fields[1].At(i).(*float64) - aTime := *frames[0].Fields[0].At(i).(*time.Time) - require.Equal(t, float64(20), aValue) - require.True(t, aTime.Equal(dt)) - dt = dt.Add(5 * time.Minute) - } - - // check for NULL values inserted by fill - require.Nil(t, frames[0].Fields[1].At(6)) - }) - - t.Run("When doing a metric query using timeGroup with value fill enabled", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeGroup(time, '5m', 1.5) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(34 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 1.5, *frames[0].Fields[1].At(3).(*float64)) - }) - }) - - t.Run("Given a table with one data point", func(t *testing.T) { - type metric struct { - Time time.Time - Value int64 - } - - startTime := time.Now().UTC().Add(-time.Minute * 5) - series := []*metric{ - { - Time: startTime, - Value: 33, - }, - } - - for _, m := range series { - _, err := p.Exec(t.Context(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) - require.NoError(t, err) - } - - t.Run("querying with time group with default value", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "WITH data AS (SELECT now()-'3m'::interval AS ts, 42 AS n) SELECT $__timeGroup(ts, '1m', 0), n FROM data", - "format": "time_series" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: startTime, - To: startTime.Add(5 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, "Time", frames[0].Fields[0].Name) - require.Equal(t, "n", frames[0].Fields[1].Name) - require.Equal(t, float64(0), *frames[0].Fields[1].At(0).(*float64)) - require.Equal(t, float64(0), *frames[0].Fields[1].At(1).(*float64)) - require.Equal(t, float64(42), *frames[0].Fields[1].At(2).(*float64)) - require.Equal(t, float64(0), *frames[0].Fields[1].At(3).(*float64)) - require.Equal(t, float64(0), *frames[0].Fields[1].At(4).(*float64)) - require.Equal(t, float64(0), *frames[0].Fields[1].At(5).(*float64)) - }) - }) - - t.Run("When doing a metric query using timeGroup with previous fill enabled", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeGroup(time, '5m', previous), avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(34 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, float64(15.0), *frames[0].Fields[1].At(2).(*float64)) - require.Equal(t, float64(15.0), *frames[0].Fields[1].At(3).(*float64)) - require.Equal(t, float64(20.0), *frames[0].Fields[1].At(6).(*float64)) - }) - - t.Run("Given a table with metrics having multiple values and measurements", func(t *testing.T) { - type metric_values struct { - Time time.Time - TimeInt64 int64 - TimeInt64Nullable *int64 - TimeFloat64 float64 - TimeFloat64Nullable *float64 - TimeInt32 int32 - TimeInt32Nullable *int32 - TimeFloat32 float32 - TimeFloat32Nullable *float32 - Measurement string - ValueOne int64 - ValueTwo int64 - } - - _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS metric_values") - require.NoError(t, err) - - _, err = p.Exec(t.Context(), `CREATE TABLE metric_values ( - "time" TIMESTAMP NULL, - "timeInt64" BIGINT NOT NULL, "timeInt64Nullable" BIGINT NULL, - "timeFloat64" DOUBLE PRECISION NOT NULL, "timeFloat64Nullable" DOUBLE PRECISION NULL, - "timeInt32" INTEGER NOT NULL, "timeInt32Nullable" INTEGER NULL, - "timeFloat32" DOUBLE PRECISION NOT NULL, "timeFloat32Nullable" DOUBLE PRECISION NULL, - measurement VARCHAR(255) NULL, - "valueOne" INTEGER NULL, "valueTwo" INTEGER NULL - )`) - require.NoError(t, err) - - rng := rand.New(rand.NewSource(time.Now().Unix())) - rnd := func(min, max int64) int64 { - return rng.Int63n(max-min) + min - } - - var tInitial time.Time - - series := []*metric_values{} - for i, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { - if i == 0 { - tInitial = t - } - tSeconds := t.Unix() - tSecondsInt32 := int32(tSeconds) - tSecondsFloat32 := float32(tSeconds) - tMilliseconds := tSeconds * 1e3 - tMillisecondsFloat := float64(tMilliseconds) - first := metric_values{ - Time: t, - TimeInt64: tMilliseconds, - TimeInt64Nullable: &(tMilliseconds), - TimeFloat64: tMillisecondsFloat, - TimeFloat64Nullable: &tMillisecondsFloat, - TimeInt32: tSecondsInt32, - TimeInt32Nullable: &tSecondsInt32, - TimeFloat32: tSecondsFloat32, - TimeFloat32Nullable: &tSecondsFloat32, - Measurement: "Metric A", - ValueOne: rnd(0, 100), - ValueTwo: rnd(0, 100), - } - second := first - second.Measurement = "Metric B" - second.ValueOne = rnd(0, 100) - second.ValueTwo = rnd(0, 100) - - series = append(series, &first) - series = append(series, &second) - } - - // _, err = session.InsertMulti(series) - for _, m := range series { - _, err := p.Exec(t.Context(), `INSERT INTO "metric_values" ( - time, - "timeInt64", "timeInt64Nullable", - "timeFloat64", "timeFloat64Nullable", - "timeInt32", "timeInt32Nullable", - "timeFloat32", "timeFloat32Nullable", - measurement, "valueOne", "valueTwo" - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, - m.Time, - m.TimeInt64, m.TimeInt64Nullable, - m.TimeFloat64, m.TimeFloat64Nullable, - m.TimeInt32, m.TimeInt32Nullable, - m.TimeFloat32, m.TimeFloat32Nullable, - m.Measurement, m.ValueOne, m.ValueTwo, - ) - require.NoError(t, err) - } - - t.Run( - "When doing a metric query using epoch (int64) as time column and value column (int64) should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeInt64\" as time, \"timeInt64\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (int64 nullable) as time column and value column (int64 nullable,) should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeInt64Nullable\" as time, \"timeInt64Nullable\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (float64) as time column and value column (float64), should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeFloat64\" as time, \"timeFloat64\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (float64 nullable) as time column and value column (float64 nullable), should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeFloat64Nullable\" as time, \"timeFloat64Nullable\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (int32) as time column and value column (int32), should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeInt32\" as time, \"timeInt32\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (int32 nullable) as time column and value column (int32 nullable), should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeInt32Nullable\" as time, \"timeInt32Nullable\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.True(t, tInitial.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (float32) as time column and value column (float32), should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeFloat32\" as time, \"timeFloat32\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - aTime := time.Unix(0, int64(float64(float32(tInitial.Unix()))*1e3)*int64(time.Millisecond)) - require.True(t, aTime.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable), should return metric with time in time.Time", - func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"timeFloat32Nullable\" as time, \"timeFloat32Nullable\" FROM metric_values ORDER BY time LIMIT 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - aTime := time.Unix(0, int64(float64(float32(tInitial.Unix()))*1e3)*int64(time.Millisecond)) - require.True(t, aTime.Equal(*frames[0].Fields[0].At(0).(*time.Time))) - }) - - t.Run("When doing a metric query grouping by time and select metric column should return correct series", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - require.Equal(t, "Metric A - value one", frames[0].Fields[1].Name) - require.Equal(t, "Metric B - value one", frames[0].Fields[2].Name) - }) - - t.Run("When doing a metric query with metric column and multiple value columns", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeEpoch(time), measurement as metric, \"valueOne\", \"valueTwo\" FROM metric_values ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.NoError(t, err) - require.Equal(t, 1, len(frames)) - require.Equal(t, 5, len(frames[0].Fields)) - require.Equal(t, "valueOne", frames[0].Fields[1].Name) - require.Equal(t, data.Labels{"metric": "Metric A"}, frames[0].Fields[1].Labels) - require.Equal(t, "valueOne", frames[0].Fields[2].Name) - require.Equal(t, data.Labels{"metric": "Metric B"}, frames[0].Fields[2].Labels) - require.Equal(t, "valueTwo", frames[0].Fields[3].Name) - require.Equal(t, data.Labels{"metric": "Metric A"}, frames[0].Fields[3].Labels) - require.Equal(t, "valueTwo", frames[0].Fields[4].Name) - require.Equal(t, data.Labels{"metric": "Metric B"}, frames[0].Fields[4].Labels) - }) - - t.Run("When doing a metric query grouping by time should return correct series", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - require.Equal(t, "valueOne", frames[0].Fields[1].Name) - require.Equal(t, "valueTwo", frames[0].Fields[2].Name) - }) - - t.Run("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func(t *testing.T) { - fakeInterpolate := sqleng.Interpolate - t.Cleanup(func() { - sqleng.Interpolate = fakeInterpolate - }) - sqleng.Interpolate = origInterpolate - - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1", - "format": "time_series" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: fromStart.Add(-5 * time.Minute), - To: fromStart, - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - frames := queryResult.Frames - require.Len(t, frames, 1) - require.Equal(t, - "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1", - frames[0].Meta.ExecutedQueryString) - }) - }) - - t.Run("Given a table with event data", func(t *testing.T) { - type event struct { - TimeSec int64 - Description string - Tags string - } - - _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS event") - require.NoError(t, err) - _, err = p.Exec(t.Context(), `CREATE TABLE event (time_sec BIGINT NULL, description VARCHAR(255) NULL, tags VARCHAR(255) NULL)`) - require.NoError(t, err) - - events := []*event{} - for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), time.Hour, 25*time.Minute) { - events = append(events, &event{ - TimeSec: t.Unix(), - Description: "Someone deployed something", - Tags: "deploy", - }) - events = append(events, &event{ - TimeSec: t.Add(5 * time.Minute).Unix(), - Description: "New support ticket registered", - Tags: "ticket", - }) - } - - for _, e := range events { - _, err := p.Exec(t.Context(), "INSERT INTO event (time_sec, description, tags) VALUES ($1, $2, $3)", e.TimeSec, e.Description, e.Tags) - require.NoError(t, err) - } - - t.Run("When doing an annotation query of deploy events should return expected result", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"time_sec\" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='deploy' ORDER BY 1 ASC", - "format": "table" - }`), - RefID: "Deploys", - TimeRange: backend.TimeRange{ - From: fromStart.Add(-20 * time.Minute), - To: fromStart.Add(40 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - - queryResult := resp.Responses["Deploys"] - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.Len(t, frames[0].Fields, 3) - }) - - t.Run("When doing an annotation query of ticket events should return expected result", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT \"time_sec\" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='ticket' ORDER BY 1 ASC", - "format": "table" - }`), - RefID: "Tickets", - TimeRange: backend.TimeRange{ - From: fromStart.Add(-20 * time.Minute), - To: fromStart.Add(40 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - - queryResult := resp.Responses["Tickets"] - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - }) - - t.Run("When doing an annotation query with a time column in datetime format", func(t *testing.T) { - dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) - dtFormat := "2006-01-02 15:04:05.999999999" - - queryjson := fmt.Sprintf("{\"rawSql\": \"SELECT CAST('%s' AS TIMESTAMP) as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\" }", dt.Format(dtFormat)) - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(queryjson), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - - // Should be in time.Time - require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) - }) - - t.Run("When doing an annotation query with a time column in epoch second format should return time.Time", func(t *testing.T) { - dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) - - queryjson := fmt.Sprintf("{\"rawSql\": \"SELECT %d as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\"}", dt.Unix()) - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(queryjson), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - - // Should be in time.Time - require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) - }) - - t.Run("When doing an annotation query with a time column in epoch second format (t *testing.Tint) should return time.Time", func(t *testing.T) { - dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) - queryjson := fmt.Sprintf("{\"rawSql\": \"SELECT cast(%d as bigint) as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\"}", dt.Unix()) - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(queryjson), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - - // Should be in time.Time - require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) - }) - - t.Run("When doing an annotation query with a time column in epoch millisecond format should return time.Time", func(t *testing.T) { - dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) - - queryjson := fmt.Sprintf("{\"rawSql\":\"SELECT %d as time, 'message' as text, 'tag1,tag2' as tags\", \"format\": \"table\"}", dt.Unix()*1000) - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(queryjson), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - - // Should be in time.Time - require.Equal(t, dt.Unix(), (*frames[0].Fields[0].At(0).(*time.Time)).Unix()) - }) - - t.Run("When doing an annotation query with a time column holding a bigint null value should return nil", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT cast(null as bigint) as time, 'message' as text, 'tag1,tag2' as tags", - "format": "table" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - - // Should be in time.Time - require.Nil(t, frames[0].Fields[0].At(0)) - }) - - t.Run("When doing an annotation query with a time column holding a timestamp null value should return nil", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT cast(null as timestamp) as time, 'message' as text, 'tag1,tag2' as tags", - "format": "table" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 3, len(frames[0].Fields)) - - // Should be in time.Time - assert.Nil(t, frames[0].Fields[0].At(0)) - }) - - t.Run("When doing an annotation query with a time and timeend column should return two fields of type time", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT 1631053772276 as time, 1631054012276 as timeend, '' as text, '' as tags", - "format": "table" - }`), - RefID: "A", - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Equal(t, 1, len(frames)) - require.Equal(t, 4, len(frames[0].Fields)) - - require.Equal(t, data.FieldTypeNullableTime, frames[0].Fields[0].Type()) - require.Equal(t, data.FieldTypeNullableTime, frames[0].Fields[1].Type()) - }) - - t.Run("When row limit set to 1", func(t *testing.T) { - jsonData := sqleng.JsonData{ - MaxOpenConns: 10, - MaxIdleConns: 2, - ConnMaxLifetime: 14400, - Timescaledb: false, - Mode: "disable", - ConfigurationMethod: "file-path", - } - - dsInfo := sqleng.DataSourceInfo{ - JsonData: jsonData, - DecryptedSecureJSONData: map[string]string{}, - } - _, handler, err := newPostgresPGX(t.Context(), "error", 1, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) - - require.NoError(t, err) - - t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT 1 as value UNION ALL select 2 as value", - "format": "table" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: time.Now(), - To: time.Now(), - }, - }, - }, - } - - resp, err := handler.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - frames := queryResult.Frames - require.NoError(t, err) - require.Equal(t, 1, len(frames)) - require.Equal(t, 1, len(frames[0].Fields)) - require.Equal(t, 1, frames[0].Rows()) - require.Len(t, frames[0].Meta.Notices, 1) - require.Equal(t, data.NoticeSeverityWarning, frames[0].Meta.Notices[0].Severity) - }) - - t.Run("When doing a time series query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT 1 as time, 1 as value UNION ALL select 2 as time, 2 as value", - "format": "time_series" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: time.Now(), - To: time.Now(), - }, - }, - }, - } - - resp, err := handler.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - frames := queryResult.Frames - require.NoError(t, err) - require.Equal(t, 1, len(frames)) - require.Equal(t, 2, len(frames[0].Fields)) - require.Equal(t, 1, frames[0].Rows()) - require.Len(t, frames[0].Meta.Notices, 1) - require.Equal(t, data.NoticeSeverityWarning, frames[0].Meta.Notices[0].Severity) - }) - }) - }) - - t.Run("Given an empty table", func(t *testing.T) { - _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS empty_obj") - require.NoError(t, err) - _, err = p.Exec(t.Context(), "CREATE TABLE empty_obj (empty_key VARCHAR(255) NULL, empty_val BIGINT NULL)") - require.NoError(t, err) - - t.Run("When no rows are returned, should return an empty frame", func(t *testing.T) { - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - JSON: []byte(`{ - "rawSql": "SELECT empty_key, empty_val FROM empty_obj", - "format": "table" - }`), - RefID: "A", - TimeRange: backend.TimeRange{ - From: time.Now(), - To: time.Now().Add(1 * time.Minute), - }, - }, - }, - } - - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - - frames := queryResult.Frames - require.Len(t, frames, 1) - require.Equal(t, 0, frames[0].Rows()) - require.NotNil(t, frames[0].Fields) - require.Empty(t, frames[0].Fields) - }) - - t.Run("Should handle multiple result sets without panicking", func(t *testing.T) { - // Create a test table for the panic scenario test - sql := ` - DROP TABLE IF EXISTS test_multi_results; - CREATE TABLE test_multi_results( - id integer, - name text, - value numeric - ); - INSERT INTO test_multi_results VALUES - (1, 'test1', 10.5), - (2, 'test2', 20.7), - (3, 'test3', 30.2); - ` - _, err := p.Exec(t.Context(), sql) - require.NoError(t, err) - - t.Run("Should handle compatible multiple result sets", func(t *testing.T) { - // This query returns multiple result sets with the same structure - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - RefID: "A", - JSON: []byte(`{ - "rawSql": "SELECT id, name FROM test_multi_results WHERE id <= 2; SELECT id, name FROM test_multi_results WHERE id >= 2;", - "format": "table" - }`), - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(1 * time.Hour), - }, - }, - }, - } - - // This should not panic and should work correctly - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - - // The frame should be properly constructed from both SELECT results - frame := frames[0] - require.Equal(t, 2, len(frame.Fields)) // id, name from both queries - require.Equal(t, "id", frame.Fields[0].Name) - require.Equal(t, "name", frame.Fields[1].Name) - require.Equal(t, 4, frame.Rows()) // 2 rows from first result + 2 rows from second result - }) - - t.Run("Should return error for incompatible multiple result sets", func(t *testing.T) { - // This query returns multiple result sets with different structures - the kind that used to cause panic - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - RefID: "A", - JSON: []byte(`{ - "rawSql": "SELECT id, name FROM test_multi_results WHERE id <= 2; SELECT id, value FROM test_multi_results WHERE id >= 2;", - "format": "table" - }`), - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(1 * time.Hour), - }, - }, - }, - } - - // This should not panic anymore, but should return an error instead - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - - // We expect an error about column mismatch, not a panic - require.Error(t, queryResult.Error) - require.Contains(t, queryResult.Error.Error(), "column name mismatch") - }) - - t.Run("Should return error for incompatible number of columns", func(t *testing.T) { - // This query returns multiple result sets with different number of columns - // This should fix the error "runtime error: index out of range [1] with length 1" - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - RefID: "A", - JSON: []byte(`{ - "rawSql": "SELECT id, name FROM test_multi_results WHERE id = 1; SELECT id FROM test_multi_results WHERE id = 1;", - "format": "table" - }`), - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(1 * time.Hour), - }, - }, - }, - } - - // This should not panic anymore, but should return an error instead - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - - // We expect an error about incompatible result structure, not a panic - require.Error(t, queryResult.Error) - require.Contains(t, queryResult.Error.Error(), "incompatible result structure: expected 2 columns, got 1 columns") - }) - }) - - t.Run("Should handle queries with mixed statement types", func(t *testing.T) { - // This tests a scenario with UPDATE + SELECT that could cause the original panic - query := &backend.QueryDataRequest{ - Queries: []backend.DataQuery{ - { - RefID: "A", - JSON: []byte(`{ - "rawSql": "UPDATE test_multi_results SET name = 'updated' WHERE id = 1; SELECT id, name FROM test_multi_results WHERE id = 1;", - "format": "table" - }`), - TimeRange: backend.TimeRange{ - From: fromStart, - To: fromStart.Add(1 * time.Hour), - }, - }, - }, - } - - // This should not panic - resp, err := exe.QueryData(t.Context(), query) - require.NoError(t, err) - queryResult := resp.Responses["A"] - require.NoError(t, queryResult.Error) - - frames := queryResult.Frames - require.Len(t, frames, 1) - - // Should only contain data from the SELECT part - frame := frames[0] - require.Equal(t, 2, len(frame.Fields)) // id, name - require.Equal(t, 1, frame.Rows()) // 1 row - - // Verify the update worked - nameField := frame.Fields[1] - nameValue := nameField.At(0).(*string) - require.Equal(t, "updated", *nameValue) - }) - }) - - t.Run("Test Postgres connection with pgpass file", func(t *testing.T) { - require.NoError(t, preparePgpassFile(t)) - require.FileExists(t, os.Getenv("PGPASSFILE"), "Make sure that PGPASSFILE is set and file exists") - - cnnstr := postgresTestDBConnString() - require.NotContains(t, cnnstr, "password=", "Make sure that password is not in the connection string") - - pgpassPool, _, err := newPostgresPGX(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) - require.NoError(t, err) - - _, err = pgpassPool.Query(t.Context(), "SELECT 1") // Test connection - require.NoError(t, err) - }) -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go index 1face10ab2d..5eabc0a3f71 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go @@ -7,8 +7,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/setting" - sqlengpgx "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/pgx" "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" ) @@ -16,48 +14,30 @@ type Service struct { im instancemgmt.InstanceManager } -func ProvideService(cfg *setting.Cfg) *Service { +func ProvideService() *Service { logger := backend.NewLoggerWith("logger", "tsdb.postgres") s := &Service{ - im: datasource.NewInstanceManager(NewInstanceSettings(logger, cfg.DataPath)), + im: datasource.NewInstanceManager(NewInstanceSettings(logger)), } return s } // NOTE: do not put any business logic into this method. it's whole job is to forward the call "inside" func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - cfg := backend.GrafanaConfigFromContext(ctx) - if cfg.FeatureToggles().IsEnabled("postgresDSUsePGX") { - dsHandler, err := s.getDSInfoPGX(ctx, req.PluginContext) - if err != nil { - return sqlengpgx.ErrToHealthCheckResult(err) - } - return dsHandler.CheckHealth(ctx, req) - } else { - dsHandler, err := s.getDSInfo(ctx, req.PluginContext) - if err != nil { - return sqleng.ErrToHealthCheckResult(err) - } - return dsHandler.CheckHealth(ctx, req) + dsHandler, err := s.getDSInfo(ctx, req.PluginContext) + if err != nil { + return sqleng.ErrToHealthCheckResult(err) } + return dsHandler.CheckHealth(ctx, req) } // NOTE: do not put any business logic into this method. it's whole job is to forward the call "inside" func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - cfg := backend.GrafanaConfigFromContext(ctx) - if cfg.FeatureToggles().IsEnabled("postgresDSUsePGX") { - dsInfo, err := s.getDSInfoPGX(ctx, req.PluginContext) - if err != nil { - return nil, err - } - return dsInfo.QueryData(ctx, req) - } else { - dsInfo, err := s.getDSInfo(ctx, req.PluginContext) - if err != nil { - return nil, err - } - return dsInfo.QueryData(ctx, req) + dsInfo, err := s.getDSInfo(ctx, req.PluginContext) + if err != nil { + return nil, err } + return dsInfo.QueryData(ctx, req) } func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*sqleng.DataSourceHandler, error) { @@ -68,12 +48,3 @@ func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext instance := i.(*sqleng.DataSourceHandler) return instance, nil } - -func (s *Service) getDSInfoPGX(ctx context.Context, pluginCtx backend.PluginContext) (*sqlengpgx.DataSourceHandler, error) { - i, err := s.im.Get(ctx, pluginCtx) - if err != nil { - return nil, err - } - instance := i.(*sqlengpgx.DataSourceHandler) - return instance, nil -} diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go index c0017825b82..6aa7a22e7f5 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_snapshot_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "encoding/json" "fmt" "os" @@ -23,7 +22,7 @@ import ( var updateGoldenFiles = false // These tests require a real postgres database: -// - make devenv sources=potgres_tests +// - make devenv sources=postgres_tests // - either set the env variable GRAFANA_TEST_DB = postgres // - or set `forceRun := true` below // @@ -127,6 +126,8 @@ func TestIntegrationPostgresSnapshots(t *testing.T) { {format: "table", name: "types_char"}, {format: "table", name: "types_datetime"}, {format: "table", name: "types_other"}, + {format: "table", name: "types_enum"}, + {format: "table", name: "types_jsonb"}, {format: "table", name: "timestamp_convert_bigint"}, {format: "table", name: "timestamp_convert_integer"}, {format: "table", name: "timestamp_convert_real"}, @@ -148,10 +149,11 @@ func TestIntegrationPostgresSnapshots(t *testing.T) { } jsonData := sqleng.JsonData{ - MaxOpenConns: 0, + MaxOpenConns: 10, MaxIdleConns: 2, ConnMaxLifetime: 14400, Timescaledb: false, + Mode: "disable", ConfigurationMethod: "file-path", } @@ -164,28 +166,24 @@ func TestIntegrationPostgresSnapshots(t *testing.T) { cnnstr := getCnnStr() - db, handler, err := newPostgres(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) - - t.Cleanup((func() { - _, err := db.Exec("DROP TABLE tbl") - require.NoError(t, err) - err = db.Close() - require.NoError(t, err) - })) - + p, handler, err := newPostgres(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) require.NoError(t, err) + t.Cleanup(func() { + p.Close() + }) + sqlFilePath := filepath.Join("testdata", test.format, test.name+".sql") goldenFileName := filepath.Join(test.format, test.name+".golden") rawSQL, sql := readSqlFile(sqlFilePath) - _, err = db.Exec(sql) + _, err = p.Exec(t.Context(), sql) require.NoError(t, err) query := makeQuery(rawSQL, test.format) - result, err := handler.QueryData(context.Background(), &query) + result, err := handler.QueryData(t.Context(), &query) require.Len(t, result.Responses, 1) response, found := result.Responses["A"] require.True(t, found) diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go index 7d6e99fcb92..2de6a6bc6f0 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go @@ -1,7 +1,7 @@ package postgres import ( - "context" + "encoding/json" "fmt" "math/rand" "os" @@ -15,20 +15,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" - "github.com/grafana/grafana/pkg/util/testutil" - _ "github.com/lib/pq" ) // Test generateConnectionString. func TestIntegrationGenerateConnectionString(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() - testCases := []struct { desc string host string @@ -58,15 +52,6 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { tlsSettings: tlsSettings{Mode: "verify-full"}, expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full'", }, - { - desc: "verify-ca automatically adds disable-sni", - host: "host:1234", - user: "user", - password: "password", - database: "database", - tlsSettings: tlsSettings{Mode: "verify-ca"}, - expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-ca' sslsni=0", - }, { desc: "TCP/port host", host: "host:1234", @@ -156,8 +141,6 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { - logger := backend.NewLoggerWith("logger", "tsdb.postgres") - ds := sqleng.DataSourceInfo{ URL: tt.host, User: tt.user, @@ -165,8 +148,9 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { Database: tt.database, UID: tt.uid, } + logger := backend.NewLoggerWith("logger", "tsdb.postgres") - connStr, err := generateConnectionString(ds, tt.tlsSettings, false, logger) + connStr, err := generateConnectionString(ds, tt.tlsSettings, logger) if tt.expErr == "" { require.NoError(t, err, tt.desc) @@ -207,10 +191,11 @@ func TestIntegrationPostgres(t *testing.T) { } jsonData := sqleng.JsonData{ - MaxOpenConns: 0, + MaxOpenConns: 10, MaxIdleConns: 2, ConnMaxLifetime: 14400, Timescaledb: false, + Mode: "disable", ConfigurationMethod: "file-path", } @@ -223,7 +208,7 @@ func TestIntegrationPostgres(t *testing.T) { cnnstr := postgresTestDBConnString() - db, exe, err := newPostgres(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + p, exe, err := newPostgres(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) require.NoError(t, err) @@ -253,10 +238,13 @@ func TestIntegrationPostgres(t *testing.T) { c14_timetz time with time zone, time date, c15_interval interval, - c16_smallint smallint + c16_smallint smallint, + + c17_json json, + c18_jsonb jsonb ); ` - _, err := db.Exec(sql) + _, err := p.Exec(t.Context(), sql) require.NoError(t, err) sql = ` @@ -264,12 +252,11 @@ func TestIntegrationPostgres(t *testing.T) { 1,2,3, 4.5,6.7,1.1,1.2, 'char10','varchar10','text', - - now(),now(),now(),now(),now(),now(),'15m'::interval, - null + now(),now(),now(),now(),now(),now(),'15m'::interval,null, + '{"key1": "value1"}'::json, '{"key2": "value2"}'::jsonb ); ` - _, err = db.Exec(sql) + _, err = p.Exec(t.Context(), sql) require.NoError(t, err) t.Run("When doing a table query should map Postgres column types to Go types", func(t *testing.T) { @@ -284,14 +271,14 @@ func TestIntegrationPostgres(t *testing.T) { }, }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) frames := queryResult.Frames require.Len(t, frames, 1) - require.Len(t, frames[0].Fields, 18) + require.Len(t, frames[0].Fields, 20) require.Equal(t, int16(1), *frames[0].Fields[0].At(0).(*int16)) require.Equal(t, int32(2), *frames[0].Fields[1].At(0).(*int32)) @@ -312,14 +299,21 @@ func TestIntegrationPostgres(t *testing.T) { require.True(t, ok) _, ok = frames[0].Fields[12].At(0).(*time.Time) require.True(t, ok) - _, ok = frames[0].Fields[13].At(0).(*time.Time) + _, ok = frames[0].Fields[13].At(0).(*string) require.True(t, ok) - _, ok = frames[0].Fields[14].At(0).(*time.Time) + _, ok = frames[0].Fields[14].At(0).(*string) require.True(t, ok) _, ok = frames[0].Fields[15].At(0).(*time.Time) require.True(t, ok) require.Equal(t, "00:15:00", *frames[0].Fields[16].At(0).(*string)) require.Nil(t, frames[0].Fields[17].At(0)) + + _, ok = frames[0].Fields[18].At(0).(*json.RawMessage) + require.True(t, ok) + require.Equal(t, json.RawMessage(`{"key1": "value1"}`), *frames[0].Fields[18].At(0).(*json.RawMessage)) + _, ok = frames[0].Fields[19].At(0).(*json.RawMessage) + require.True(t, ok) + require.Equal(t, json.RawMessage(`{"key2": "value2"}`), *frames[0].Fields[19].At(0).(*json.RawMessage)) }) }) @@ -332,7 +326,7 @@ func TestIntegrationPostgres(t *testing.T) { ) ` - _, err := db.Exec(sql) + _, err := p.Exec(t.Context(), sql) require.NoError(t, err) type metric struct { @@ -359,7 +353,7 @@ func TestIntegrationPostgres(t *testing.T) { } for _, m := range series { - _, err := db.Exec(`INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) + _, err := p.Exec(t.Context(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) require.NoError(t, err) } @@ -376,7 +370,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -408,6 +402,27 @@ func TestIntegrationPostgres(t *testing.T) { } }) + t.Run("When doing a query without a format should default to time_series", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1 " + }`), + RefID: "A", + }, + }, + } + resp, err := exe.QueryData(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Len(t, frames[0].Fields, 2) + }) + t.Run("When doing a metric query using timeGroup and $__interval", func(t *testing.T) { mockInterpolate := sqleng.Interpolate sqleng.Interpolate = origInterpolate @@ -432,7 +447,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] frames := queryResult.Frames @@ -460,7 +475,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -514,7 +529,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -540,7 +555,7 @@ func TestIntegrationPostgres(t *testing.T) { } for _, m := range series { - _, err := db.Exec(`INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) + _, err := p.Exec(t.Context(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) require.NoError(t, err) } @@ -561,7 +576,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -596,7 +611,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -624,10 +639,10 @@ func TestIntegrationPostgres(t *testing.T) { ValueTwo int64 } - _, err := db.Exec("DROP TABLE IF EXISTS metric_values") + _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS metric_values") require.NoError(t, err) - _, err = db.Exec(`CREATE TABLE metric_values ( + _, err = p.Exec(t.Context(), `CREATE TABLE metric_values ( "time" TIMESTAMP NULL, "timeInt64" BIGINT NOT NULL, "timeInt64Nullable" BIGINT NULL, "timeFloat64" DOUBLE PRECISION NOT NULL, "timeFloat64Nullable" DOUBLE PRECISION NULL, @@ -680,7 +695,7 @@ func TestIntegrationPostgres(t *testing.T) { // _, err = session.InsertMulti(series) for _, m := range series { - _, err := db.Exec(`INSERT INTO "metric_values" ( + _, err := p.Exec(t.Context(), `INSERT INTO "metric_values" ( time, "timeInt64", "timeInt64Nullable", "timeFloat64", "timeFloat64Nullable", @@ -713,7 +728,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -737,7 +752,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -761,7 +776,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -785,7 +800,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -809,7 +824,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -833,7 +848,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -857,7 +872,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -882,7 +897,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -906,7 +921,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -931,7 +946,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -963,7 +978,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -998,7 +1013,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1017,9 +1032,9 @@ func TestIntegrationPostgres(t *testing.T) { Tags string } - _, err := db.Exec("DROP TABLE IF EXISTS event") + _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS event") require.NoError(t, err) - _, err = db.Exec(`CREATE TABLE event (time_sec BIGINT NULL, description VARCHAR(255) NULL, tags VARCHAR(255) NULL)`) + _, err = p.Exec(t.Context(), `CREATE TABLE event (time_sec BIGINT NULL, description VARCHAR(255) NULL, tags VARCHAR(255) NULL)`) require.NoError(t, err) events := []*event{} @@ -1037,7 +1052,7 @@ func TestIntegrationPostgres(t *testing.T) { } for _, e := range events { - _, err := db.Exec("INSERT INTO event (time_sec, description, tags) VALUES ($1, $2, $3)", e.TimeSec, e.Description, e.Tags) + _, err := p.Exec(t.Context(), "INSERT INTO event (time_sec, description, tags) VALUES ($1, $2, $3)", e.TimeSec, e.Description, e.Tags) require.NoError(t, err) } @@ -1058,7 +1073,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["Deploys"] @@ -1085,7 +1100,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["Tickets"] @@ -1108,7 +1123,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1133,7 +1148,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1158,7 +1173,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1184,7 +1199,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1210,7 +1225,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1236,7 +1251,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1262,7 +1277,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1276,8 +1291,20 @@ func TestIntegrationPostgres(t *testing.T) { }) t.Run("When row limit set to 1", func(t *testing.T) { - dsInfo := sqleng.DataSourceInfo{} - _, handler, err := newPostgres(context.Background(), "error", 1, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + jsonData := sqleng.JsonData{ + MaxOpenConns: 10, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Timescaledb: false, + Mode: "disable", + ConfigurationMethod: "file-path", + } + + dsInfo := sqleng.DataSourceInfo{ + JsonData: jsonData, + DecryptedSecureJSONData: map[string]string{}, + } + _, handler, err := newPostgres(t.Context(), "error", 1, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) require.NoError(t, err) @@ -1298,7 +1325,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := handler.QueryData(context.Background(), query) + resp, err := handler.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1328,7 +1355,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := handler.QueryData(context.Background(), query) + resp, err := handler.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1344,9 +1371,9 @@ func TestIntegrationPostgres(t *testing.T) { }) t.Run("Given an empty table", func(t *testing.T) { - _, err := db.Exec("DROP TABLE IF EXISTS empty_obj") + _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS empty_obj") require.NoError(t, err) - _, err = db.Exec("CREATE TABLE empty_obj (empty_key VARCHAR(255) NULL, empty_val BIGINT NULL)") + _, err = p.Exec(t.Context(), "CREATE TABLE empty_obj (empty_key VARCHAR(255) NULL, empty_val BIGINT NULL)") require.NoError(t, err) t.Run("When no rows are returned, should return an empty frame", func(t *testing.T) { @@ -1366,7 +1393,7 @@ func TestIntegrationPostgres(t *testing.T) { }, } - resp, err := exe.QueryData(context.Background(), query) + resp, err := exe.QueryData(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] @@ -1376,6 +1403,154 @@ func TestIntegrationPostgres(t *testing.T) { require.NotNil(t, frames[0].Fields) require.Empty(t, frames[0].Fields) }) + + t.Run("Should handle multiple result sets without panicking", func(t *testing.T) { + // Create a test table for the panic scenario test + sql := ` + DROP TABLE IF EXISTS test_multi_results; + CREATE TABLE test_multi_results( + id integer, + name text, + value numeric + ); + INSERT INTO test_multi_results VALUES + (1, 'test1', 10.5), + (2, 'test2', 20.7), + (3, 'test3', 30.2); + ` + _, err := p.Exec(t.Context(), sql) + require.NoError(t, err) + + t.Run("Should handle compatible multiple result sets", func(t *testing.T) { + // This query returns multiple result sets with the same structure + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "SELECT id, name FROM test_multi_results WHERE id <= 2; SELECT id, name FROM test_multi_results WHERE id >= 2;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic and should work correctly + resp, err := exe.QueryData(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + + // The frame should be properly constructed from both SELECT results + frame := frames[0] + require.Equal(t, 2, len(frame.Fields)) // id, name from both queries + require.Equal(t, "id", frame.Fields[0].Name) + require.Equal(t, "name", frame.Fields[1].Name) + require.Equal(t, 4, frame.Rows()) // 2 rows from first result + 2 rows from second result + }) + + t.Run("Should return error for incompatible multiple result sets", func(t *testing.T) { + // This query returns multiple result sets with different structures - the kind that used to cause panic + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "SELECT id, name FROM test_multi_results WHERE id <= 2; SELECT id, value FROM test_multi_results WHERE id >= 2;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic anymore, but should return an error instead + resp, err := exe.QueryData(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + + // We expect an error about column mismatch, not a panic + require.Error(t, queryResult.Error) + require.Contains(t, queryResult.Error.Error(), "column name mismatch") + }) + + t.Run("Should return error for incompatible number of columns", func(t *testing.T) { + // This query returns multiple result sets with different number of columns + // This should fix the error "runtime error: index out of range [1] with length 1" + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "SELECT id, name FROM test_multi_results WHERE id = 1; SELECT id FROM test_multi_results WHERE id = 1;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic anymore, but should return an error instead + resp, err := exe.QueryData(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + + // We expect an error about incompatible result structure, not a panic + require.Error(t, queryResult.Error) + require.Contains(t, queryResult.Error.Error(), "incompatible result structure: expected 2 columns, got 1 columns") + }) + }) + + t.Run("Should handle queries with mixed statement types", func(t *testing.T) { + // This tests a scenario with UPDATE + SELECT that could cause the original panic + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "UPDATE test_multi_results SET name = 'updated' WHERE id = 1; SELECT id, name FROM test_multi_results WHERE id = 1;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic + resp, err := exe.QueryData(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + + // Should only contain data from the SELECT part + frame := frames[0] + require.Equal(t, 2, len(frame.Fields)) // id, name + require.Equal(t, 1, frame.Rows()) // 1 row + + // Verify the update worked + nameField := frame.Fields[1] + nameValue := nameField.At(0).(*string) + require.Equal(t, "updated", *nameValue) + }) }) t.Run("Test Postgres connection with pgpass file", func(t *testing.T) { @@ -1385,10 +1560,10 @@ func TestIntegrationPostgres(t *testing.T) { cnnstr := postgresTestDBConnString() require.NotContains(t, cnnstr, "password=", "Make sure that password is not in the connection string") - dbPgpass, _, err := newPostgres(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + pgpassPool, _, err := newPostgres(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) require.NoError(t, err) - _, err = dbPgpass.Exec("SELECT 1") // Test connection + _, err = pgpassPool.Query(t.Context(), "SELECT 1") // Test connection require.NoError(t, err) }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/proxy.go b/pkg/tsdb/grafana-postgresql-datasource/proxy.go index dca66df38e9..dc7fa069347 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/proxy.go +++ b/pkg/tsdb/grafana-postgresql-datasource/proxy.go @@ -3,40 +3,13 @@ package postgres import ( "context" "net" - "time" - "github.com/lib/pq" "golang.org/x/net/proxy" ) -// we wrap the proxy.Dialer to become dialer that the postgres module accepts -func newPostgresProxyDialer(dialer proxy.Dialer) pq.Dialer { - return &postgresProxyDialer{d: dialer} -} +type DialFunc = func(ctx context.Context, network string, address string) (net.Conn, error) -var _ pq.Dialer = (&postgresProxyDialer{}) - -// postgresProxyDialer implements the postgres dialer using a proxy dialer, as their functions differ slightly -type postgresProxyDialer struct { - d proxy.Dialer -} - -// Dial uses the normal proxy dial function with the updated dialer -func (p *postgresProxyDialer) Dial(network, addr string) (c net.Conn, err error) { - return p.d.Dial(network, addr) -} - -// DialTimeout uses the normal postgres dial timeout function with the updated dialer -func (p *postgresProxyDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - return p.d.(proxy.ContextDialer).DialContext(ctx, network, address) -} - -type PgxDialFunc = func(ctx context.Context, network string, address string) (net.Conn, error) - -func newPgxDialFunc(dialer proxy.Dialer) PgxDialFunc { +func newDialFunc(dialer proxy.Dialer) DialFunc { return func(ctx context.Context, network string, addr string) (net.Conn, error) { return dialer.Dial(network, addr) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go b/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go index ec36e1a1eae..967c3ecd523 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/proxy_test.go @@ -1,12 +1,11 @@ package postgres import ( - "database/sql" "fmt" "net" "testing" - "github.com/lib/pq" + "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "golang.org/x/net/proxy" ) @@ -25,15 +24,17 @@ func TestPostgresProxyDriver(t *testing.T) { cnnstr := fmt.Sprintf("postgres://auser:password@%s/db?sslmode=disable", dbURL) t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { - connector, err := pq.NewConnector(cnnstr) + config, err := pgxpool.ParseConfig(cnnstr) require.NoError(t, err) - dialer := newPostgresProxyDialer(&testDialer{}) - connector.Dialer(dialer) + config.ConnConfig.DialFunc = newDialFunc(&testDialer{}) - db := sql.OpenDB(connector) - err = db.Ping() + pool, err := pgxpool.NewWithConfig(t.Context(), config) + require.NoError(t, err) + defer pool.Close() + err = pool.Ping(t.Context()) + require.Error(t, err) require.Contains(t, err.Error(), "test-dialer is not functional") }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go index 224d00eace5..183c7f57a9c 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go @@ -10,11 +10,10 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/lib/pq" ) func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - err := e.Ping() + err := e.Ping(ctx) if err != nil { logCheckHealthError(ctx, e.dsInfo, err) if strings.EqualFold(req.PluginContext.User.Role, "Admin") { @@ -64,24 +63,6 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) { } } - if errors.Is(err, pq.ErrSSLNotSupported) { - res.Message = "SSL error: Failed to connect to the server" - } - if strings.HasPrefix(err.Error(), "pq") { - res.Message = "Database error: Failed to connect to the postgres server" - if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { - details["verboseMessage"] = unwrappedErr.Error() - } - } - var pqErr *pq.Error - if errors.As(err, &pqErr) { - if pqErr != nil { - if pqErr.Code != "" { - res.Message += fmt.Sprintf(". Postgres error code: %s", pqErr.Code.Name()) - } - details["verboseMessage"] = pqErr.Message - } - } if errors.Is(err, ErrParsingPostgresURL) { res.Message = fmt.Sprintf("Connection string error: %s", ErrParsingPostgresURL.Error()) if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth_test.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth_test.go index 70f6ad65e45..4fe87ca478e 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,15 +30,6 @@ func TestErrToHealthCheckResult(t *testing.T) { JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/postgres","verboseMessage":"foo\nread tcp: some op"}`), }, }, - { - name: "db error", - err: errors.Join(errors.New("foo"), &pq.Error{Message: pq.ErrCouldNotDetectUsername.Error(), Code: pq.ErrorCode("28P01")}), - want: &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "foo\npq: pq: Could not detect default username. Please provide one explicitly. Postgres error code: invalid_password", - JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/postgres","verboseMessage":"pq: Could not detect default username. Please provide one explicitly"}`), - }, - }, { name: "regular error", err: errors.New("internal server error"), diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go index fdaac59c6f1..5a19ca859f4 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go @@ -2,7 +2,6 @@ package sqleng import ( "context" - "database/sql" "encoding/json" "errors" "fmt" @@ -19,6 +18,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" ) // MetaKeyExecutedQueryString is the key where the executed query should get stored @@ -81,13 +83,13 @@ type DataPluginConfiguration struct { type DataSourceHandler struct { macroEngine SQLMacroEngine queryResultTransformer SqlQueryResultTransformer - db *sql.DB timeColumnNames []string metricColumnTypes []string log log.Logger dsInfo DataSourceInfo rowLimit int64 userError string + pool *pgxpool.Pool } type QueryJson struct { @@ -112,7 +114,7 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er return e.queryResultTransformer.TransformQueryError(logger, err) } -func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, +func NewQueryDataHandler(userFacingDefaultError string, p *pgxpool.Pool, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) { queryDataHandler := DataSourceHandler{ queryResultTransformer: queryResultTransformer, @@ -132,7 +134,7 @@ func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataP queryDataHandler.metricColumnTypes = config.MetricColumnTypes } - queryDataHandler.db = db + queryDataHandler.pool = p return &queryDataHandler, nil } @@ -143,16 +145,16 @@ type DBDataResponse struct { func (e *DataSourceHandler) Dispose() { e.log.Debug("Disposing DB...") - if e.db != nil { - if err := e.db.Close(); err != nil { - e.log.Error("Failed to dispose db", "error", err) - } + + if e.pool != nil { + e.pool.Close() } + e.log.Debug("DB disposed") } -func (e *DataSourceHandler) Ping() error { - return e.db.Ping() +func (e *DataSourceHandler) Ping(ctx context.Context) error { + return e.pool.Ping(ctx) } func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { @@ -167,13 +169,13 @@ func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDat } err := json.Unmarshal(query.JSON, &queryjson) if err != nil { - return nil, fmt.Errorf("error unmarshal query json: %w", err) + return nil, backend.DownstreamErrorf("error unmarshal query json: %s", err.Error()) } // the fill-params are only stored inside this function, during query-interpolation. we do not support // sending them in "from the outside" if queryjson.Fill || queryjson.FillInterval != 0.0 || queryjson.FillMode != "" || queryjson.FillValue != 0.0 { - return nil, fmt.Errorf("query fill-parameters not supported") + return nil, backend.DownstreamErrorf("query fill-parameters not supported") } if queryjson.RawSql == "" { @@ -181,7 +183,7 @@ func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDat } wg.Add(1) - go e.executeQuery(query, &wg, ctx, ch, queryjson) + go e.executeQuery(ctx, query, &wg, ch, queryjson) } wg.Wait() @@ -196,8 +198,21 @@ func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDat return result, nil } -func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitGroup, queryContext context.Context, - ch chan DBDataResponse, queryJson QueryJson) { +func (e *DataSourceHandler) execQuery(ctx context.Context, query string) ([]*pgconn.Result, error) { + c, err := e.pool.Acquire(ctx) + if err != nil { + return nil, backend.DownstreamErrorf("failed to acquire connection: %w", err) + } + defer c.Release() + + mrr := c.Conn().PgConn().Exec(ctx, query) + // Close returns the first error that occurred during the MultiResultReader's use. We will log that later. + defer mrr.Close() //nolint:errcheck + return mrr.ReadAll() +} + +func (e *DataSourceHandler) executeQuery(queryContext context.Context, query backend.DataQuery, wg *sync.WaitGroup, + ch chan DBDataResponse, queryJSON QueryJson) { defer wg.Done() queryResult := DBDataResponse{ dataResponse: backend.DataResponse{}, @@ -205,84 +220,77 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } logger := e.log.FromContext(queryContext) + defer e.handlePanic(logger, &queryResult, ch) - defer func() { - if r := recover(); r != nil { - logger.Error("ExecuteQuery panic", "error", r, "stack", string(debug.Stack())) - if theErr, ok := r.(error); ok { - queryResult.dataResponse.Error = theErr - queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin - } else if theErrString, ok := r.(string); ok { - queryResult.dataResponse.Error = errors.New(theErrString) - queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin - } else { - queryResult.dataResponse.Error = fmt.Errorf("unexpected error - %s", e.userError) - queryResult.dataResponse.ErrorSource = backend.ErrorSourceDownstream - } - ch <- queryResult - } - }() - - if queryJson.RawSql == "" { + if queryJSON.RawSql == "" { panic("Query model property rawSql should not be empty at this point") } - timeRange := query.TimeRange - - errAppendDebug := func(frameErr string, err error, query string, source backend.ErrorSource) { - var emptyFrame data.Frame - emptyFrame.SetMeta(&data.FrameMeta{ - ExecutedQueryString: query, - }) - if isDownstreamError(err) { - source = backend.ErrorSourceDownstream - } - queryResult.dataResponse.Error = fmt.Errorf("%s: %w", frameErr, err) - queryResult.dataResponse.ErrorSource = source - queryResult.dataResponse.Frames = data.Frames{&emptyFrame} - ch <- queryResult - } - // global substitutions - interpolatedQuery := Interpolate(query, timeRange, e.dsInfo.JsonData.TimeInterval, queryJson.RawSql) + interpolatedQuery := Interpolate(query, query.TimeRange, e.dsInfo.JsonData.TimeInterval, queryJSON.RawSql) // data source specific substitutions - interpolatedQuery, err := e.macroEngine.Interpolate(&query, timeRange, interpolatedQuery) + interpolatedQuery, err := e.macroEngine.Interpolate(&query, query.TimeRange, interpolatedQuery) if err != nil { - errAppendDebug("interpolation failed", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourcePlugin) + e.handleQueryError("interpolation failed", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) return } - rows, err := e.db.QueryContext(queryContext, interpolatedQuery) + results, err := e.execQuery(queryContext, interpolatedQuery) if err != nil { - errAppendDebug("db query error", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream) + e.handleQueryError("db query error", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) return } - defer func() { - if err := rows.Close(); err != nil { - logger.Warn("Failed to close rows", "err", err) + + qm, err := e.newProcessCfg(queryContext, query, results, interpolatedQuery) + if err != nil { + e.handleQueryError("failed to get configurations", err, interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) + return + } + + frame, err := convertResultsToFrame(results, e.rowLimit) + if err != nil { + e.handleQueryError("convert frame from rows error", err, interpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) + return + } + + e.processFrame(frame, qm, queryResult, ch, logger) +} + +func (e *DataSourceHandler) handleQueryError(frameErr string, err error, query string, source backend.ErrorSource, ch chan DBDataResponse, queryResult DBDataResponse) { + var emptyFrame data.Frame + emptyFrame.SetMeta(&data.FrameMeta{ExecutedQueryString: query}) + if isDownstreamError(err) { + source = backend.ErrorSourceDownstream + } + queryResult.dataResponse.Error = fmt.Errorf("%s: %w", frameErr, err) + queryResult.dataResponse.ErrorSource = source + queryResult.dataResponse.Frames = data.Frames{&emptyFrame} + ch <- queryResult +} + +func (e *DataSourceHandler) handlePanic(logger log.Logger, queryResult *DBDataResponse, ch chan DBDataResponse) { + if r := recover(); r != nil { + logger.Error("ExecuteQuery panic", "error", r, "stack", string(debug.Stack())) + if theErr, ok := r.(error); ok { + queryResult.dataResponse.Error = theErr + queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin + } else if theErrString, ok := r.(string); ok { + queryResult.dataResponse.Error = errors.New(theErrString) + queryResult.dataResponse.ErrorSource = backend.ErrorSourcePlugin + } else { + queryResult.dataResponse.Error = fmt.Errorf("unexpected error - %s", e.userError) + queryResult.dataResponse.ErrorSource = backend.ErrorSourceDownstream } - }() - - qm, err := e.newProcessCfg(query, queryContext, rows, interpolatedQuery) - if err != nil { - errAppendDebug("failed to get configurations", err, interpolatedQuery, backend.ErrorSourcePlugin) - return - } - - // Convert row.Rows to dataframe - stringConverters := e.queryResultTransformer.GetConverterList() - frame, err := sqlutil.FrameFromRows(rows, e.rowLimit, sqlutil.ToConverters(stringConverters...)...) - if err != nil { - errAppendDebug("convert frame from rows error", err, interpolatedQuery, backend.ErrorSourcePlugin) - return + ch <- *queryResult } +} +func (e *DataSourceHandler) processFrame(frame *data.Frame, qm *dataQueryModel, queryResult DBDataResponse, ch chan DBDataResponse, logger log.Logger) { if frame.Meta == nil { frame.Meta = &data.FrameMeta{} } - - frame.Meta.ExecutedQueryString = interpolatedQuery + frame.Meta.ExecutedQueryString = qm.InterpolatedQuery // If no rows were returned, clear any previously set `Fields` with a single empty `data.Field` slice. // Then assign `queryResult.dataResponse.Frames` the current single frame with that single empty Field. @@ -296,14 +304,14 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } if err := convertSQLTimeColumnsToEpochMS(frame, qm); err != nil { - errAppendDebug("converting time columns failed", err, interpolatedQuery, backend.ErrorSourcePlugin) + e.handleQueryError("converting time columns failed", err, qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) return } if qm.Format == dataQueryFormatSeries { // time series has to have time column if qm.timeIndex == -1 { - errAppendDebug("db has no time column", errors.New("time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it"), interpolatedQuery, backend.ErrorSourceDownstream) + e.handleQueryError("db has no time column", errors.New("time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it"), qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) return } @@ -321,7 +329,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG var err error if frame, err = convertSQLValueColumnToFloat(frame, i); err != nil { - errAppendDebug("convert value to float failed", err, interpolatedQuery, backend.ErrorSourcePlugin) + e.handleQueryError("convert value to float failed", err, qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) return } } @@ -332,7 +340,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG originalData := frame frame, err = data.LongToWide(frame, qm.FillMissing) if err != nil { - errAppendDebug("failed to convert long to wide series when converting from dataframe", err, interpolatedQuery, backend.ErrorSourcePlugin) + e.handleQueryError("failed to convert long to wide series when converting from dataframe", err, qm.InterpolatedQuery, backend.ErrorSourceDownstream, ch, queryResult) return } @@ -364,6 +372,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG if err != nil { logger.Error("Failed to resample dataframe", "err", err) frame.AppendNotices(data.Notice{Text: "Failed to resample dataframe", Severity: data.NoticeSeverityWarning}) + return } } } @@ -384,15 +393,38 @@ var Interpolate = func(query backend.DataQuery, timeRange backend.TimeRange, tim return sql } -func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext context.Context, - rows *sql.Rows, interpolatedQuery string) (*dataQueryModel, error) { - columnNames, err := rows.Columns() - if err != nil { - return nil, err +func (e *DataSourceHandler) newProcessCfg(queryContext context.Context, query backend.DataQuery, + results []*pgconn.Result, interpolatedQuery string) (*dataQueryModel, error) { + // Calculate total number of fields to preallocate slices + totalFields := 0 + for _, result := range results { + totalFields += len(result.FieldDescriptions) } - columnTypes, err := rows.ColumnTypes() - if err != nil { - return nil, err + + columnNames := make([]string, 0, totalFields) + columnTypes := make([]string, 0, totalFields) + + // The results will contain column information in the metadata + for _, result := range results { + // Get column names from the result metadata + for _, field := range result.FieldDescriptions { + columnNames = append(columnNames, field.Name) + pqtype, ok := pgtype.NewMap().TypeForOID(field.DataTypeOID) + if !ok { + // Handle special cases for field types + switch field.DataTypeOID { + case pgtype.TimetzOID: + columnTypes = append(columnTypes, "timetz") + // money type is 790 + case 790: + columnTypes = append(columnTypes, "money") + default: + columnTypes = append(columnTypes, "unknown") + } + } else { + columnTypes = append(columnTypes, pqtype.Name) + } + } } qm := &dataQueryModel{ @@ -405,23 +437,23 @@ func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext queryContext: queryContext, } - queryJson := QueryJson{} - err = json.Unmarshal(query.JSON, &queryJson) + queryJSON := QueryJson{} + err := json.Unmarshal(query.JSON, &queryJSON) if err != nil { return nil, err } - if queryJson.Fill { + if queryJSON.Fill { qm.FillMissing = &data.FillMissing{} - qm.Interval = time.Duration(queryJson.FillInterval * float64(time.Second)) - switch strings.ToLower(queryJson.FillMode) { + qm.Interval = time.Duration(queryJSON.FillInterval * float64(time.Second)) + switch strings.ToLower(queryJSON.FillMode) { case "null": qm.FillMissing.Mode = data.FillModeNull case "previous": qm.FillMissing.Mode = data.FillModePrevious case "value": qm.FillMissing.Mode = data.FillModeValue - qm.FillMissing.Value = queryJson.FillValue + qm.FillMissing.Value = queryJSON.FillValue default: } } @@ -429,13 +461,14 @@ func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext qm.TimeRange.From = query.TimeRange.From.UTC() qm.TimeRange.To = query.TimeRange.To.UTC() - switch queryJson.Format { - case "time_series": - qm.Format = dataQueryFormatSeries + // Default to time_series if no format is provided + switch queryJSON.Format { case "table": qm.Format = dataQueryFormatTable + case "time_series": + fallthrough default: - panic(fmt.Sprintf("Unrecognized query model format: %q", queryJson.Format)) + qm.Format = dataQueryFormatSeries } for i, col := range qm.columnNames { @@ -456,7 +489,7 @@ func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext qm.metricIndex = i default: if qm.metricIndex == -1 { - columnType := qm.columnTypes[i].DatabaseTypeName() + columnType := qm.columnTypes[i] for _, mct := range e.metricColumnTypes { if columnType == mct { qm.metricIndex = i @@ -487,7 +520,7 @@ type dataQueryModel struct { FillMissing *data.FillMissing // property not set until after Interpolate() Interval time.Duration columnNames []string - columnTypes []*sql.ColumnType + columnTypes []string timeIndex int timeEndIndex int metricIndex int @@ -511,6 +544,219 @@ func convertSQLTimeColumnsToEpochMS(frame *data.Frame, qm *dataQueryModel) error return nil } +func convertResultsToFrame(results []*pgconn.Result, rowLimit int64) (*data.Frame, error) { + m := pgtype.NewMap() + + // Find the first SELECT result to establish the frame structure + var firstSelectResult *pgconn.Result + for _, result := range results { + if result.CommandTag.Select() { + firstSelectResult = result + break + } + } + + // If no SELECT results found, return empty frame + if firstSelectResult == nil { + return data.NewFrame(""), nil + } + + // Create frame structure based on the first SELECT result + fields := make(data.Fields, len(firstSelectResult.FieldDescriptions)) + fieldTypes, err := getFieldTypesFromDescriptions(firstSelectResult.FieldDescriptions, m) + if err != nil { + return nil, err + } + + for i, v := range firstSelectResult.FieldDescriptions { + fields[i] = data.NewFieldFromFieldType(fieldTypes[i], 0) + fields[i].Name = v.Name + } + frame := *data.NewFrame("", fields...) + + // Process all SELECT results, but validate column compatibility + for _, result := range results { + // Skip non-select statements + if !result.CommandTag.Select() { + continue + } + + // Validate that this result has the same structure as the frame + if len(result.FieldDescriptions) != len(frame.Fields) { + return nil, fmt.Errorf("incompatible result structure: expected %d columns, got %d columns", + len(frame.Fields), len(result.FieldDescriptions)) + } + + // Validate column names and types match + for i, fd := range result.FieldDescriptions { + if fd.Name != frame.Fields[i].Name { + return nil, fmt.Errorf("column name mismatch at position %d: expected %q, got %q", + i, frame.Fields[i].Name, fd.Name) + } + } + + fieldDescriptions := result.FieldDescriptions + for rowIdx := range result.Rows { + if rowIdx == int(rowLimit) { + frame.AppendNotices(data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: fmt.Sprintf("Results have been limited to %v because the SQL row limit was reached", rowLimit), + }) + break + } + row := make([]any, len(fieldDescriptions)) + for colIdx, fd := range fieldDescriptions { + rawValue := result.Rows[rowIdx][colIdx] + + if rawValue == nil { + row[colIdx] = nil + continue + } + + convertedValue, err := convertPostgresValue(rawValue, fd, m) + if err != nil { + return nil, err + } + row[colIdx] = convertedValue + } + + // Validate row length matches frame field count before appending + if len(row) != len(frame.Fields) { + return nil, fmt.Errorf("row data length mismatch: expected %d values, got %d values", + len(frame.Fields), len(row)) + } + + frame.AppendRow(row...) + } + } + + return &frame, nil +} + +// convertPostgresValue converts a raw PostgreSQL value to the appropriate Go type +func convertPostgresValue(rawValue []byte, fd pgconn.FieldDescription, m *pgtype.Map) (interface{}, error) { + dataTypeOID := fd.DataTypeOID + format := fd.Format + + // Convert based on type + switch fd.DataTypeOID { + case pgtype.Int2OID: + var d *int16 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.Int4OID: + var d *int32 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.Int8OID: + var d *int64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.NumericOID, pgtype.Float8OID, pgtype.Float4OID: + var d *float64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.BoolOID: + var d *bool + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.ByteaOID: + d, err := pgtype.ByteaCodec.DecodeValue(pgtype.ByteaCodec{}, m, dataTypeOID, format, rawValue) + if err != nil { + return nil, err + } + str := string(d.([]byte)) + return &str, nil + case pgtype.TimestampOID, pgtype.TimestamptzOID, pgtype.DateOID: + var d *time.Time + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.TimeOID, pgtype.TimetzOID: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.JSONOID, pgtype.JSONBOID: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + // Handle null JSON values + if d == nil { + return nil, nil + } + j := json.RawMessage(*d) + return &j, nil + default: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + } +} + +func getFieldTypesFromDescriptions(fieldDescriptions []pgconn.FieldDescription, m *pgtype.Map) ([]data.FieldType, error) { + fieldTypes := make([]data.FieldType, len(fieldDescriptions)) + for i, v := range fieldDescriptions { + typeName, ok := m.TypeForOID(v.DataTypeOID) + if !ok { + fieldTypes[i] = data.FieldTypeNullableString + } else { + switch typeName.Name { + case "int2": + fieldTypes[i] = data.FieldTypeNullableInt16 + case "int4": + fieldTypes[i] = data.FieldTypeNullableInt32 + case "int8": + fieldTypes[i] = data.FieldTypeNullableInt64 + case "float4", "float8", "numeric": + fieldTypes[i] = data.FieldTypeNullableFloat64 + case "bool": + fieldTypes[i] = data.FieldTypeNullableBool + case "timestamptz", "timestamp", "date": + fieldTypes[i] = data.FieldTypeNullableTime + case "json", "jsonb": + fieldTypes[i] = data.FieldTypeNullableJSON + default: + fieldTypes[i] = data.FieldTypeNullableString + } + } + } + return fieldTypes, nil +} + // convertSQLTimeColumnToEpochMS converts column named time to unix timestamp in milliseconds // to make native datetime types and epoch dates work in annotation and table queries. func convertSQLTimeColumnToEpochMS(frame *data.Frame, timeIndex int) error { diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go index 4d511a53f25..f0f5f5b7a9b 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go @@ -9,6 +9,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -425,6 +427,246 @@ func TestSQLEngine(t *testing.T) { }) } +func TestConvertResultsToFrame(t *testing.T) { + // Import the pgx packages needed for testing + // These imports are included in the main file but need to be accessible for tests + t.Run("convertResultsToFrame with single result", func(t *testing.T) { + // Create mock field descriptions + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + {Name: "name", DataTypeOID: pgtype.TextOID}, + {Name: "value", DataTypeOID: pgtype.Float8OID}, + } + + // Create mock result data + mockRows := [][][]byte{ + {[]byte("1"), []byte("test1"), []byte("10.5")}, + {[]byte("2"), []byte("test2"), []byte("20.7")}, + } + + // Create mock result + result := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + result.CommandTag = pgconn.NewCommandTag("SELECT 2") + + results := []*pgconn.Result{result} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 3, len(frame.Fields)) + require.Equal(t, 2, frame.Rows()) + + // Verify field names + require.Equal(t, "id", frame.Fields[0].Name) + require.Equal(t, "name", frame.Fields[1].Name) + require.Equal(t, "value", frame.Fields[2].Name) + }) + + t.Run("convertResultsToFrame with multiple compatible results", func(t *testing.T) { + // Create mock field descriptions (same structure for both results) + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + {Name: "name", DataTypeOID: pgtype.TextOID}, + } + + // Create first result + mockRows1 := [][][]byte{ + {[]byte("1"), []byte("test1")}, + {[]byte("2"), []byte("test2")}, + } + result1 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows1, + } + result1.CommandTag = pgconn.NewCommandTag("SELECT 2") + + // Create second result with same structure + mockRows2 := [][][]byte{ + {[]byte("3"), []byte("test3")}, + {[]byte("4"), []byte("test4")}, + } + result2 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows2, + } + result2.CommandTag = pgconn.NewCommandTag("SELECT 2") + + results := []*pgconn.Result{result1, result2} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 2, len(frame.Fields)) + require.Equal(t, 4, frame.Rows()) // Should have rows from both results + + // Verify field names + require.Equal(t, "id", frame.Fields[0].Name) + require.Equal(t, "name", frame.Fields[1].Name) + }) + + t.Run("convertResultsToFrame with row limit", func(t *testing.T) { + // Create mock field descriptions + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + } + + // Create mock result data with 3 rows + mockRows := [][][]byte{ + {[]byte("1")}, + {[]byte("2")}, + {[]byte("3")}, + } + + result := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + result.CommandTag = pgconn.NewCommandTag("SELECT 3") + + results := []*pgconn.Result{result} + + // Set row limit to 2 + frame, err := convertResultsToFrame(results, 2) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 1, len(frame.Fields)) + require.Equal(t, 2, frame.Rows()) // Should be limited to 2 rows + + // Should have a notice about the limit + require.NotNil(t, frame.Meta) + require.Len(t, frame.Meta.Notices, 1) + require.Contains(t, frame.Meta.Notices[0].Text, "Results have been limited to 2") + }) + + t.Run("convertResultsToFrame with mixed SELECT and non-SELECT results", func(t *testing.T) { + // Create a non-SELECT result (should be skipped) + nonSelectResult := &pgconn.Result{} + nonSelectResult.CommandTag = pgconn.NewCommandTag("UPDATE 1") + + // Create a SELECT result + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + } + mockRows := [][][]byte{ + {[]byte("1")}, + } + selectResult := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + selectResult.CommandTag = pgconn.NewCommandTag("SELECT 1") + + results := []*pgconn.Result{nonSelectResult, selectResult} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 1, len(frame.Fields)) + require.Equal(t, 1, frame.Rows()) + }) + + t.Run("convertResultsToFrame with no SELECT results", func(t *testing.T) { + // Create only non-SELECT results + result1 := &pgconn.Result{} + result1.CommandTag = pgconn.NewCommandTag("UPDATE 1") + + result2 := &pgconn.Result{} + result2.CommandTag = pgconn.NewCommandTag("INSERT 1") + + results := []*pgconn.Result{result1, result2} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 0, len(frame.Fields)) + require.Equal(t, 0, frame.Rows()) + }) + + t.Run("convertResultsToFrame with multiple results and row limit per result", func(t *testing.T) { + // Create mock field descriptions (same structure for both results) + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + } + + // Create first result with 3 rows + mockRows1 := [][][]byte{ + {[]byte("1")}, + {[]byte("2")}, + {[]byte("3")}, + } + result1 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows1, + } + result1.CommandTag = pgconn.NewCommandTag("SELECT 3") + + // Create second result with 3 rows + mockRows2 := [][][]byte{ + {[]byte("4")}, + {[]byte("5")}, + {[]byte("6")}, + } + result2 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows2, + } + result2.CommandTag = pgconn.NewCommandTag("SELECT 3") + + results := []*pgconn.Result{result1, result2} + + // Set row limit to 2 (should limit each result to 2 rows) + frame, err := convertResultsToFrame(results, 2) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 1, len(frame.Fields)) + require.Equal(t, 4, frame.Rows()) // 2 rows from each result + + // Should have notices about the limit from both results + require.NotNil(t, frame.Meta) + require.Len(t, frame.Meta.Notices, 2) + require.Contains(t, frame.Meta.Notices[0].Text, "Results have been limited to 2") + require.Contains(t, frame.Meta.Notices[1].Text, "Results have been limited to 2") + }) + + t.Run("convertResultsToFrame handles null values correctly", func(t *testing.T) { + // Create mock field descriptions + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + {Name: "name", DataTypeOID: pgtype.TextOID}, + } + + // Create mock result data with null values + mockRows := [][][]byte{ + {[]byte("1"), nil}, // null name + {nil, []byte("test2")}, // null id + } + + result := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + result.CommandTag = pgconn.NewCommandTag("SELECT 2") + + results := []*pgconn.Result{result} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 2, len(frame.Fields)) + require.Equal(t, 2, frame.Rows()) + + // Check that null values are handled correctly + // The exact representation depends on the field type, but should not panic + require.NotPanics(t, func() { + frame.Fields[0].At(1) // null id + frame.Fields[1].At(0) // null name + }) + }) +} + type testQueryResultTransformer struct { transformQueryErrorWasCalled bool } diff --git a/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go b/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go index 3ad73082408..ddd5105c46b 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go +++ b/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go @@ -6,16 +6,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" postgres "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource" ) func main() { // No need to pass logger name, it will be set by the plugin SDK logger := backend.NewLoggerWith() - // TODO: get rid of setting.NewCfg() once PostgresDSUsePGX is removed - cfg := setting.NewCfg() - if err := datasource.Manage("grafana-postgresql-datasource", postgres.NewInstanceSettings(logger, cfg.DataPath), datasource.ManageOpts{}); err != nil { + if err := datasource.Manage("grafana-postgresql-datasource", postgres.NewInstanceSettings(logger), datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc index 48e55e7b077..5754a588b10 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc +++ b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime.golden.jsonc @@ -9,14 +9,14 @@ // } // Name: // Dimensions: 12 Fields by 2 Rows -// +----------------------------------------+----------------------------------------+--------------------------------------+--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-----------------+-----------------+ -// | Name: ts | Name: tsnn | Name: tsz | Name: tsznn | Name: d | Name: dnn | Name: t | Name: tnn | Name: tz | Name: tznn | Name: i | Name: inn | -// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | -// | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*string | Type: []*string | -// +----------------------------------------+----------------------------------------+--------------------------------------+--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-----------------+-----------------+ -// | 2023-11-15 05:06:07.123456 +0000 +0000 | 2023-11-15 05:06:08.123456 +0000 +0000 | 2021-07-22 11:22:33.654321 +0000 UTC | 2021-07-22 11:22:34.654321 +0000 UTC | 2023-12-20 00:00:00 +0000 +0000 | 2023-12-21 00:00:00 +0000 +0000 | 0000-01-01 12:34:56.234567 +0000 UTC | 0000-01-01 12:34:57.234567 +0000 UTC | 0000-01-01 23:12:36.765432 +0100 +0100 | 0000-01-01 23:12:37.765432 +0100 +0100 | 00:00:00.987654 | 00:00:00.887654 | -// | null | 2023-11-15 05:06:09.123456 +0000 +0000 | null | 2021-07-22 11:22:35.654321 +0000 UTC | null | 2023-12-22 00:00:00 +0000 +0000 | null | 0000-01-01 12:34:58.234567 +0000 UTC | null | 0000-01-01 23:12:38.765432 +0100 +0100 | null | 00:00:00.787654 | -// +----------------------------------------+----------------------------------------+--------------------------------------+--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-----------------+-----------------+ +// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ +// | Name: ts | Name: tsnn | Name: tsz | Name: tsznn | Name: d | Name: dnn | Name: t | Name: tnn | Name: tz | Name: tznn | Name: i | Name: inn | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | +// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ +// | 2023-11-15 05:06:07.123456 +0000 UTC | 2023-11-15 05:06:08.123456 +0000 UTC | 2021-07-22 11:22:33.654321 +0000 +0000 | 2021-07-22 11:22:34.654321 +0000 +0000 | 2023-12-20 00:00:00 +0000 UTC | 2023-12-21 00:00:00 +0000 UTC | 12:34:56.234567 | 12:34:57.234567 | 23:12:36.765432+01 | 23:12:37.765432+01 | 00:00:00.987654 | 00:00:00.887654 | +// | null | 2023-11-15 05:06:09.123456 +0000 UTC | null | 2021-07-22 11:22:35.654321 +0000 +0000 | null | 2023-12-22 00:00:00 +0000 UTC | null | 12:34:58.234567 | null | 23:12:38.765432+01 | null | 00:00:00.787654 | +// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ // // // 🌟 This was machine generated. Do not edit. 🌟 @@ -83,33 +83,33 @@ }, { "name": "t", - "type": "time", + "type": "string", "typeInfo": { - "frame": "time.Time", + "frame": "string", "nullable": true } }, { "name": "tnn", - "type": "time", + "type": "string", "typeInfo": { - "frame": "time.Time", + "frame": "string", "nullable": true } }, { "name": "tz", - "type": "time", + "type": "string", "typeInfo": { - "frame": "time.Time", + "frame": "string", "nullable": true } }, { "name": "tznn", - "type": "time", + "type": "string", "typeInfo": { - "frame": "time.Time", + "frame": "string", "nullable": true } }, @@ -158,20 +158,20 @@ 1703203200000 ], [ - -62167173903766, + "12:34:56.234567", null ], [ - -62167173902766, - -62167173901766 + "12:34:57.234567", + "12:34:58.234567" ], [ - -62167139243235, + "23:12:36.765432+01", null ], [ - -62167139242235, - -62167139241235 + "23:12:37.765432+01", + "23:12:38.765432+01" ], [ "00:00:00.987654", @@ -201,22 +201,10 @@ ], null, null, - [ - 567000, - 0 - ], - [ - 567000, - 567000 - ], - [ - 432000, - 0 - ], - [ - 432000, - 432000 - ], + null, + null, + null, + null, null, null ] diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.golden.jsonc b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.golden.jsonc deleted file mode 100644 index 5754a588b10..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.golden.jsonc +++ /dev/null @@ -1,214 +0,0 @@ -// 🌟 This was machine generated. Do not edit. 🌟 -// -// Frame[0] { -// "typeVersion": [ -// 0, -// 0 -// ], -// "executedQueryString": "SELECT * FROM tbl" -// } -// Name: -// Dimensions: 12 Fields by 2 Rows -// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ -// | Name: ts | Name: tsnn | Name: tsz | Name: tsznn | Name: d | Name: dnn | Name: t | Name: tnn | Name: tz | Name: tznn | Name: i | Name: inn | -// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | -// | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*time.Time | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | -// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ -// | 2023-11-15 05:06:07.123456 +0000 UTC | 2023-11-15 05:06:08.123456 +0000 UTC | 2021-07-22 11:22:33.654321 +0000 +0000 | 2021-07-22 11:22:34.654321 +0000 +0000 | 2023-12-20 00:00:00 +0000 UTC | 2023-12-21 00:00:00 +0000 UTC | 12:34:56.234567 | 12:34:57.234567 | 23:12:36.765432+01 | 23:12:37.765432+01 | 00:00:00.987654 | 00:00:00.887654 | -// | null | 2023-11-15 05:06:09.123456 +0000 UTC | null | 2021-07-22 11:22:35.654321 +0000 +0000 | null | 2023-12-22 00:00:00 +0000 UTC | null | 12:34:58.234567 | null | 23:12:38.765432+01 | null | 00:00:00.787654 | -// +--------------------------------------+--------------------------------------+----------------------------------------+----------------------------------------+-------------------------------+-------------------------------+-----------------+-----------------+--------------------+--------------------+-----------------+-----------------+ -// -// -// 🌟 This was machine generated. Do not edit. 🌟 -{ - "status": 200, - "frames": [ - { - "schema": { - "meta": { - "typeVersion": [ - 0, - 0 - ], - "executedQueryString": "SELECT * FROM tbl" - }, - "fields": [ - { - "name": "ts", - "type": "time", - "typeInfo": { - "frame": "time.Time", - "nullable": true - } - }, - { - "name": "tsnn", - "type": "time", - "typeInfo": { - "frame": "time.Time", - "nullable": true - } - }, - { - "name": "tsz", - "type": "time", - "typeInfo": { - "frame": "time.Time", - "nullable": true - } - }, - { - "name": "tsznn", - "type": "time", - "typeInfo": { - "frame": "time.Time", - "nullable": true - } - }, - { - "name": "d", - "type": "time", - "typeInfo": { - "frame": "time.Time", - "nullable": true - } - }, - { - "name": "dnn", - "type": "time", - "typeInfo": { - "frame": "time.Time", - "nullable": true - } - }, - { - "name": "t", - "type": "string", - "typeInfo": { - "frame": "string", - "nullable": true - } - }, - { - "name": "tnn", - "type": "string", - "typeInfo": { - "frame": "string", - "nullable": true - } - }, - { - "name": "tz", - "type": "string", - "typeInfo": { - "frame": "string", - "nullable": true - } - }, - { - "name": "tznn", - "type": "string", - "typeInfo": { - "frame": "string", - "nullable": true - } - }, - { - "name": "i", - "type": "string", - "typeInfo": { - "frame": "string", - "nullable": true - } - }, - { - "name": "inn", - "type": "string", - "typeInfo": { - "frame": "string", - "nullable": true - } - } - ] - }, - "data": { - "values": [ - [ - 1700024767123, - null - ], - [ - 1700024768123, - 1700024769123 - ], - [ - 1626952953654, - null - ], - [ - 1626952954654, - 1626952955654 - ], - [ - 1703030400000, - null - ], - [ - 1703116800000, - 1703203200000 - ], - [ - "12:34:56.234567", - null - ], - [ - "12:34:57.234567", - "12:34:58.234567" - ], - [ - "23:12:36.765432+01", - null - ], - [ - "23:12:37.765432+01", - "23:12:38.765432+01" - ], - [ - "00:00:00.987654", - null - ], - [ - "00:00:00.887654", - "00:00:00.787654" - ] - ], - "nanos": [ - [ - 456000, - 0 - ], - [ - 456000, - 456000 - ], - [ - 321000, - 0 - ], - [ - 321000, - 321000 - ], - null, - null, - null, - null, - null, - null, - null, - null - ] - } - } - ] -} \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.sql b/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.sql deleted file mode 100644 index d81b3ca235b..00000000000 --- a/pkg/tsdb/grafana-postgresql-datasource/testdata/table/types_datetime_pgx.sql +++ /dev/null @@ -1,44 +0,0 @@ --- SELECT * FROM tbl --- test all date/time-based postgres data types -CREATE TEMPORARY TABLE tbl ( - ts timestamp, - tsnn timestamp NOT NULL, - tsz timestamp with time zone, - tsznn timestamp with time zone NOT NULL, - d date, - dnn date NOT NULL, - t time, - tnn time NOT NULL, - tz time with time zone, - tznn time with time zone NOT NULL, - i interval, - inn interval NOT NULL -); - -INSERT INTO tbl (ts, tsnn, tsz, tsznn, d, dnn, t, tnn, tz, tznn, i, inn) VALUES ( -'2023-11-15 05:06:07.123456', -'2023-11-15 05:06:08.123456', -'2021-07-22 13:22:33.654321 Europe/Berlin', -'2021-07-22 13:22:34.654321 Europe/Berlin', -'2023-12-20', -'2023-12-21', -'12:34:56.234567', -'12:34:57.234567', -'23:12:36.765432+1', -'23:12:37.765432+1', -'987654 microsecond', -'887654 microsecond' -), ( -NULL, -'2023-11-15 05:06:09.123456', -NULL, -'2021-07-22 13:22:35.654321 Europe/Berlin', -NULL, -'2023-12-22', -NULL, -'12:34:58.234567', -NULL, -'23:12:38.765432+1', -NULL, -'787654 microsecond' -); \ No newline at end of file diff --git a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go b/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go index 306b1f43e81..144347ccef3 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go +++ b/pkg/tsdb/grafana-postgresql-datasource/tlsmanager.go @@ -3,48 +3,15 @@ package postgres import ( "fmt" "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" ) var validateCertFunc = validateCertFilePaths -var writeCertFileFunc = writeCertFile -type certFileType int - -const ( - rootCert = iota - clientCert - clientKey -) - -type tlsSettingsProvider interface { - getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) -} - -type datasourceCacheManager struct { - locker *locker - cache sync.Map -} - -type tlsManager struct { - logger log.Logger - dsCacheInstance datasourceCacheManager - dataPath string -} - -func newTLSManager(logger log.Logger, dataPath string) tlsSettingsProvider { - return &tlsManager{ - logger: logger, - dataPath: dataPath, - dsCacheInstance: datasourceCacheManager{locker: newLocker()}, - } +type TLSManager struct { + logger log.Logger } type tlsSettings struct { @@ -55,178 +22,122 @@ type tlsSettings struct { CertKeyFile string } -func (m *tlsManager) getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) { - tlsconfig := tlsSettings{ +func newTLSManager(logger log.Logger) *TLSManager { + return &TLSManager{ + logger: logger, + } +} + +// getTLSSettings retrieves TLS settings and handles certificate file creation if needed. +func (m *TLSManager) getTLSSettings(dsInfo sqleng.DataSourceInfo) (tlsSettings, error) { + tlsConfig := tlsSettings{ Mode: dsInfo.JsonData.Mode, } - isTLSDisabled := (tlsconfig.Mode == "disable") - - if isTLSDisabled { + if tlsConfig.Mode == "disable" { m.logger.Debug("Postgres TLS/SSL is disabled") - return tlsconfig, nil + return tlsConfig, nil } - m.logger.Debug("Postgres TLS/SSL is enabled", "tlsMode", tlsconfig.Mode) + tlsConfig.ConfigurationMethod = dsInfo.JsonData.ConfigurationMethod + tlsConfig.RootCertFile = dsInfo.JsonData.RootCertFile + tlsConfig.CertFile = dsInfo.JsonData.CertFile + tlsConfig.CertKeyFile = dsInfo.JsonData.CertKeyFile - tlsconfig.ConfigurationMethod = dsInfo.JsonData.ConfigurationMethod - tlsconfig.RootCertFile = dsInfo.JsonData.RootCertFile - tlsconfig.CertFile = dsInfo.JsonData.CertFile - tlsconfig.CertKeyFile = dsInfo.JsonData.CertKeyFile - - if tlsconfig.ConfigurationMethod == "file-content" { - if err := m.writeCertFiles(dsInfo, &tlsconfig); err != nil { - return tlsconfig, err + if tlsConfig.ConfigurationMethod == "file-content" { + if err := m.createCertFiles(dsInfo, &tlsConfig); err != nil { + return tlsConfig, fmt.Errorf("failed to create TLS certificate files: %w", err) } } else { - if err := validateCertFunc(tlsconfig.RootCertFile, tlsconfig.CertFile, tlsconfig.CertKeyFile); err != nil { - return tlsconfig, err + if err := validateCertFunc(tlsConfig.RootCertFile, tlsConfig.CertFile, tlsConfig.CertKeyFile); err != nil { + return tlsConfig, fmt.Errorf("invalid TLS certificate file paths: %w", err) } } - return tlsconfig, nil + + return tlsConfig, nil } -func (t certFileType) String() string { - switch t { - case rootCert: - return "root certificate" - case clientCert: - return "client certificate" - case clientKey: - return "client key" - default: - panic(fmt.Sprintf("Unrecognized certFileType %d", t)) - } -} +// createCertFiles writes certificate files to temporary locations. +func (m *TLSManager) createCertFiles(dsInfo sqleng.DataSourceInfo, tlsConfig *tlsSettings) error { + m.logger.Debug("Writing TLS certificate files to temporary locations") -func getFileName(dataDir string, fileType certFileType) string { - var filename string - switch fileType { - case rootCert: - filename = "root.crt" - case clientCert: - filename = "client.crt" - case clientKey: - filename = "client.key" - default: - panic(fmt.Sprintf("unrecognized certFileType %s", fileType.String())) - } - generatedFilePath := filepath.Join(dataDir, filename) - return generatedFilePath -} - -// writeCertFile writes a certificate file. -func writeCertFile(logger log.Logger, fileContent string, generatedFilePath string) error { - fileContent = strings.TrimSpace(fileContent) - if fileContent != "" { - logger.Debug("Writing cert file", "path", generatedFilePath) - if err := os.WriteFile(generatedFilePath, []byte(fileContent), 0600); err != nil { - return err - } - // Make sure the file has the permissions expected by the Postgresql driver, otherwise it will bail - if err := os.Chmod(generatedFilePath, 0600); err != nil { - return err - } - return nil - } - - logger.Debug("Deleting cert file since no content is provided", "path", generatedFilePath) - exists, err := fileExists(generatedFilePath) - if err != nil { + var err error + if tlsConfig.RootCertFile, err = m.writeCertFile("root-*.crt", dsInfo.DecryptedSecureJSONData["tlsCACert"]); err != nil { return err } - if exists { - if err := os.Remove(generatedFilePath); err != nil { - return fmt.Errorf("failed to remove %q: %w", generatedFilePath, err) - } + if tlsConfig.CertFile, err = m.writeCertFile("client-*.crt", dsInfo.DecryptedSecureJSONData["tlsClientCert"]); err != nil { + return err } + if tlsConfig.CertKeyFile, err = m.writeCertFile("client-*.key", dsInfo.DecryptedSecureJSONData["tlsClientKey"]); err != nil { + return err + } + return nil } -func (m *tlsManager) writeCertFiles(dsInfo sqleng.DataSourceInfo, tlsconfig *tlsSettings) error { - m.logger.Debug("Writing TLS certificate files to disk") - tlsRootCert := dsInfo.DecryptedSecureJSONData["tlsCACert"] - tlsClientCert := dsInfo.DecryptedSecureJSONData["tlsClientCert"] - tlsClientKey := dsInfo.DecryptedSecureJSONData["tlsClientKey"] - if tlsRootCert == "" && tlsClientCert == "" && tlsClientKey == "" { - m.logger.Debug("No TLS/SSL certificates provided") +// writeCertFile writes a single certificate file to a temporary location. +func (m *TLSManager) writeCertFile(pattern, content string) (string, error) { + if content == "" { + return "", nil } - // Calculate all files path - workDir := filepath.Join(m.dataPath, "tls", dsInfo.UID+"generatedTLSCerts") - tlsconfig.RootCertFile = getFileName(workDir, rootCert) - tlsconfig.CertFile = getFileName(workDir, clientCert) - tlsconfig.CertKeyFile = getFileName(workDir, clientKey) - - // Find datasource in the cache, if found, skip writing files - cacheKey := strconv.Itoa(int(dsInfo.ID)) - m.dsCacheInstance.locker.RLock(cacheKey) - item, ok := m.dsCacheInstance.cache.Load(cacheKey) - m.dsCacheInstance.locker.RUnlock(cacheKey) - if ok { - if !item.(time.Time).Before(dsInfo.Updated) { - return nil - } - } - - m.dsCacheInstance.locker.Lock(cacheKey) - defer m.dsCacheInstance.locker.Unlock(cacheKey) - - item, ok = m.dsCacheInstance.cache.Load(cacheKey) - if ok { - if !item.(time.Time).Before(dsInfo.Updated) { - return nil - } - } - - // Write certification directory and files - exists, err := fileExists(workDir) + m.logger.Debug("Writing certificate file", "pattern", pattern) + file, err := os.CreateTemp("", pattern) if err != nil { - return err + return "", fmt.Errorf("failed to create temporary file: %w", err) } - if !exists { - if err := os.MkdirAll(workDir, 0700); err != nil { - return err + defer func() { + if err := file.Close(); err != nil { + m.logger.Error("Failed to close file", "error", err) } + }() + + if _, err := file.WriteString(content); err != nil { + return "", fmt.Errorf("failed to write to temporary file: %w", err) } - if err = writeCertFileFunc(m.logger, tlsRootCert, tlsconfig.RootCertFile); err != nil { - return err - } - if err = writeCertFileFunc(m.logger, tlsClientCert, tlsconfig.CertFile); err != nil { - return err - } - if err = writeCertFileFunc(m.logger, tlsClientKey, tlsconfig.CertKeyFile); err != nil { - return err - } - - // we do not want to point to cert-files that do not exist - if tlsRootCert == "" { - tlsconfig.RootCertFile = "" - } - - if tlsClientCert == "" { - tlsconfig.CertFile = "" - } - - if tlsClientKey == "" { - tlsconfig.CertKeyFile = "" - } - - // Update datasource cache - m.dsCacheInstance.cache.Store(cacheKey, dsInfo.Updated) - return nil + return file.Name(), nil } -// validateCertFilePaths validates configured certificate file paths. -func validateCertFilePaths(rootCert, clientCert, clientKey string) error { - for _, fpath := range []string{rootCert, clientCert, clientKey} { - if fpath == "" { +// cleanupCertFiles removes temporary certificate files. +func (m *TLSManager) cleanupCertFiles(tlsConfig tlsSettings) { + // Only clean up if the configuration method is "file-content" + if tlsConfig.ConfigurationMethod != "file-content" { + m.logger.Debug("Skipping cleanup of TLS certificate files") + return + } + m.logger.Debug("Cleaning up TLS certificate files") + + files := []struct { + path string + name string + }{ + {tlsConfig.RootCertFile, "root certificate"}, + {tlsConfig.CertFile, "client certificate"}, + {tlsConfig.CertKeyFile, "client key"}, + } + + for _, file := range files { + if file.path == "" { continue } - exists, err := fileExists(fpath) + if err := os.Remove(file.path); err != nil { + m.logger.Error("Failed to remove file", "type", file.name, "path", file.path, "error", err) + } else { + m.logger.Debug("Successfully removed file", "type", file.name, "path", file.path) + } + } +} + +// validateCertFilePaths validates the existence of configured certificate file paths. +func validateCertFilePaths(rootCert, clientCert, clientKey string) error { + for _, path := range []string{rootCert, clientCert, clientKey} { + if path == "" { + continue + } + exists, err := fileExists(path) if err != nil { - return err + return fmt.Errorf("error checking file existence: %w", err) } if !exists { return sqleng.ErrCertFileNotExist @@ -235,15 +146,14 @@ func validateCertFilePaths(rootCert, clientCert, clientKey string) error { return nil } -// Exists determines whether a file/directory exists or not. -func fileExists(fpath string) (bool, error) { - _, err := os.Stat(fpath) +// fileExists checks if a file exists at the given path. +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) if err != nil { - if !os.IsNotExist(err) { - return false, err + if os.IsNotExist(err) { + return false, nil } - return false, nil + return false, err } - return true, nil } diff --git a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go b/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go index c8eda0a83a7..d288bc34b3e 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/tlsmanager_test.go @@ -2,176 +2,21 @@ package postgres import ( "fmt" + "os" "path/filepath" - "strconv" "strings" - "sync" "testing" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource/sqleng" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - _ "github.com/lib/pq" ) -var writeCertFileCallNum int - -// TestDataSourceCacheManager is to test the Cache manager -func TestDataSourceCacheManager(t *testing.T) { - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() - mng := tlsManager{ - logger: backend.NewLoggerWith("logger", "tsdb.postgres"), - dsCacheInstance: datasourceCacheManager{locker: newLocker()}, - dataPath: cfg.DataPath, - } - jsonData := sqleng.JsonData{ - Mode: "verify-full", - ConfigurationMethod: "file-content", - } - secureJSONData := map[string]string{ - "tlsClientCert": "I am client certification", - "tlsClientKey": "I am client key", - "tlsCACert": "I am CA certification", - } - - updateTime := time.Now().Add(-5 * time.Minute) - - mockValidateCertFilePaths() - t.Cleanup(resetValidateCertFilePaths) - - t.Run("Check datasource cache creation", func(t *testing.T) { - var wg sync.WaitGroup - wg.Add(10) - for id := int64(1); id <= 10; id++ { - go func(id int64) { - ds := sqleng.DataSourceInfo{ - ID: id, - Updated: updateTime, - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - s := tlsSettings{} - err := mng.writeCertFiles(ds, &s) - require.NoError(t, err) - wg.Done() - }(id) - } - wg.Wait() - - t.Run("check cache creation is succeed", func(t *testing.T) { - for id := int64(1); id <= 10; id++ { - updated, ok := mng.dsCacheInstance.cache.Load(strconv.Itoa(int(id))) - require.True(t, ok) - require.Equal(t, updateTime, updated) - } - }) - }) - - t.Run("Check datasource cache modification", func(t *testing.T) { - t.Run("check when version not changed, cache and files are not updated", func(t *testing.T) { - mockWriteCertFile() - t.Cleanup(resetWriteCertFile) - var wg1 sync.WaitGroup - wg1.Add(5) - for id := int64(1); id <= 5; id++ { - go func(id int64) { - ds := sqleng.DataSourceInfo{ - ID: 1, - Updated: updateTime, - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - s := tlsSettings{} - err := mng.writeCertFiles(ds, &s) - require.NoError(t, err) - wg1.Done() - }(id) - } - wg1.Wait() - assert.Equal(t, writeCertFileCallNum, 0) - }) - - t.Run("cache is updated with the last datasource version", func(t *testing.T) { - dsV2 := sqleng.DataSourceInfo{ - ID: 1, - Updated: updateTime.Add(time.Minute), - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - dsV3 := sqleng.DataSourceInfo{ - ID: 1, - Updated: updateTime.Add(2 * time.Minute), - Database: "database", - JsonData: jsonData, - DecryptedSecureJSONData: secureJSONData, - UID: "testData", - } - s := tlsSettings{} - err := mng.writeCertFiles(dsV2, &s) - require.NoError(t, err) - err = mng.writeCertFiles(dsV3, &s) - require.NoError(t, err) - version, ok := mng.dsCacheInstance.cache.Load("1") - require.True(t, ok) - require.Equal(t, updateTime.Add(2*time.Minute), version) - }) - }) -} - -// Test getFileName - -func TestGetFileName(t *testing.T) { - testCases := []struct { - desc string - datadir string - fileType certFileType - expErr string - expectedGeneratedPath string - }{ - { - desc: "Get File Name for root certification", - datadir: ".", - fileType: rootCert, - expectedGeneratedPath: "root.crt", - }, - { - desc: "Get File Name for client certification", - datadir: ".", - fileType: clientCert, - expectedGeneratedPath: "client.crt", - }, - { - desc: "Get File Name for client certification", - datadir: ".", - fileType: clientKey, - expectedGeneratedPath: "client.key", - }, - } - for _, tt := range testCases { - t.Run(tt.desc, func(t *testing.T) { - generatedPath := getFileName(tt.datadir, tt.fileType) - assert.Equal(t, tt.expectedGeneratedPath, generatedPath) - }) - } -} - // Test getTLSSettings. func TestGetTLSSettings(t *testing.T) { - cfg := setting.NewCfg() - cfg.DataPath = t.TempDir() - mockValidateCertFilePaths() t.Cleanup(resetValidateCertFilePaths) @@ -216,75 +61,13 @@ func TestGetTLSSettings(t *testing.T) { CertKeyFile: "i/am/coding/client.key", }, }, - { - desc: "Custom TLS mode verify-full with certificate files content", - updated: updatedTime.Add(2 * time.Minute), - uid: "xxx", - jsonData: sqleng.JsonData{ - Mode: "verify-full", - ConfigurationMethod: "file-content", - }, - secureJSONData: map[string]string{ - "tlsCACert": "I am CA certification", - "tlsClientCert": "I am client certification", - "tlsClientKey": "I am client key", - }, - tlsSettings: tlsSettings{ - Mode: "verify-full", - ConfigurationMethod: "file-content", - RootCertFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "root.crt"), - CertFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "client.crt"), - CertKeyFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "client.key"), - }, - }, - { - desc: "Custom TLS mode verify-ca with no client certificates with certificate files content", - updated: updatedTime.Add(3 * time.Minute), - uid: "xxx", - jsonData: sqleng.JsonData{ - Mode: "verify-ca", - ConfigurationMethod: "file-content", - }, - secureJSONData: map[string]string{ - "tlsCACert": "I am CA certification", - }, - tlsSettings: tlsSettings{ - Mode: "verify-ca", - ConfigurationMethod: "file-content", - RootCertFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "root.crt"), - CertFile: "", - CertKeyFile: "", - }, - }, - { - desc: "Custom TLS mode require with client certificates and no root certificate with certificate files content", - updated: updatedTime.Add(4 * time.Minute), - uid: "xxx", - jsonData: sqleng.JsonData{ - Mode: "require", - ConfigurationMethod: "file-content", - }, - secureJSONData: map[string]string{ - "tlsClientCert": "I am client certification", - "tlsClientKey": "I am client key", - }, - tlsSettings: tlsSettings{ - Mode: "require", - ConfigurationMethod: "file-content", - RootCertFile: "", - CertFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "client.crt"), - CertKeyFile: filepath.Join(cfg.DataPath, "tls", "xxxgeneratedTLSCerts", "client.key"), - }, - }, } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { var settings tlsSettings var err error - mng := tlsManager{ - logger: backend.NewLoggerWith("logger", "tsdb.postgres"), - dsCacheInstance: datasourceCacheManager{locker: newLocker()}, - dataPath: cfg.DataPath, + mng := TLSManager{ + logger: backend.NewLoggerWith("logger", "tsdb.postgres"), } ds := sqleng.DataSourceInfo{ @@ -318,15 +101,145 @@ func resetValidateCertFilePaths() { validateCertFunc = validateCertFilePaths } -func mockWriteCertFile() { - writeCertFileCallNum = 0 - writeCertFileFunc = func(logger log.Logger, fileContent string, generatedFilePath string) error { - writeCertFileCallNum++ - return nil +func TestTLSManager_GetTLSSettings(t *testing.T) { + logger := log.New() + tlsManager := newTLSManager(logger) + + dsInfo := sqleng.DataSourceInfo{ + JsonData: sqleng.JsonData{ + Mode: "require", + ConfigurationMethod: "file-content", + }, + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": "root-cert-content", + "tlsClientCert": "client-cert-content", + "tlsClientKey": "client-key-content", + }, } + + tlsConfig, err := tlsManager.getTLSSettings(dsInfo) + require.NoError(t, err) + assert.Equal(t, "require", tlsConfig.Mode) + assert.NotEmpty(t, tlsConfig.RootCertFile) + assert.NotEmpty(t, tlsConfig.CertFile) + assert.NotEmpty(t, tlsConfig.CertKeyFile) + + // Cleanup temporary files + tlsManager.cleanupCertFiles(tlsConfig) + assert.NoFileExists(t, tlsConfig.RootCertFile) + assert.NoFileExists(t, tlsConfig.CertFile) + assert.NoFileExists(t, tlsConfig.CertKeyFile) } -func resetWriteCertFile() { - writeCertFileCallNum = 0 - writeCertFileFunc = writeCertFile +func TestTLSManager_CleanupCertFiles_FilePath(t *testing.T) { + logger := log.New() + tlsManager := newTLSManager(logger) + + // Create temporary files for testing + rootCertFile, err := tlsManager.writeCertFile("root-*.crt", "root-cert-content") + require.NoError(t, err) + clientCertFile, err := tlsManager.writeCertFile("client-*.crt", "client-cert-content") + require.NoError(t, err) + clientKeyFile, err := tlsManager.writeCertFile("client-*.key", "client-key-content") + require.NoError(t, err) + + // Simulate a configuration where the method is "file-path" + tlsConfig := tlsSettings{ + ConfigurationMethod: "file-path", + RootCertFile: rootCertFile, + CertFile: clientCertFile, + CertKeyFile: clientKeyFile, + } + + // Call cleanupCertFiles + tlsManager.cleanupCertFiles(tlsConfig) + + // Verify the files are NOT deleted + assert.FileExists(t, rootCertFile, "Root certificate file should not be deleted") + assert.FileExists(t, clientCertFile, "Client certificate file should not be deleted") + assert.FileExists(t, clientKeyFile, "Client key file should not be deleted") + + // Cleanup the files manually + err = os.Remove(rootCertFile) + require.NoError(t, err) + err = os.Remove(clientCertFile) + require.NoError(t, err) + err = os.Remove(clientKeyFile) + require.NoError(t, err) +} + +func TestTLSManager_CreateCertFiles(t *testing.T) { + logger := log.New() + tlsManager := newTLSManager(logger) + + dsInfo := sqleng.DataSourceInfo{ + DecryptedSecureJSONData: map[string]string{ + "tlsCACert": "root-cert-content", + "tlsClientCert": "client-cert-content", + "tlsClientKey": "client-key-content", + }, + } + + tlsConfig := tlsSettings{ + ConfigurationMethod: "file-content", + } + err := tlsManager.createCertFiles(dsInfo, &tlsConfig) + require.NoError(t, err) + + assert.FileExists(t, tlsConfig.RootCertFile) + assert.FileExists(t, tlsConfig.CertFile) + assert.FileExists(t, tlsConfig.CertKeyFile) + + // Cleanup temporary files + tlsManager.cleanupCertFiles(tlsConfig) + assert.NoFileExists(t, tlsConfig.RootCertFile) + assert.NoFileExists(t, tlsConfig.CertFile) + assert.NoFileExists(t, tlsConfig.CertKeyFile) +} + +func TestTLSManager_WriteCertFile(t *testing.T) { + logger := log.New() + tlsManager := newTLSManager(logger) + + // Test writing a valid certificate file + filePath, err := tlsManager.writeCertFile("test-*.crt", "test-cert-content") + require.NoError(t, err) + assert.FileExists(t, filePath) + + content, err := os.ReadFile(filepath.Clean(filePath)) + require.NoError(t, err) + assert.Equal(t, "test-cert-content", string(content)) + + // Cleanup the file + err = os.Remove(filePath) + require.NoError(t, err) + assert.NoFileExists(t, filePath) +} + +func TestTLSManager_CleanupCertFiles(t *testing.T) { + logger := log.New() + tlsManager := newTLSManager(logger) + + // Create temporary files for testing + rootCertFile, err := tlsManager.writeCertFile("root-*.crt", "root-cert-content") + require.NoError(t, err) + clientCertFile, err := tlsManager.writeCertFile("client-*.crt", "client-cert-content") + require.NoError(t, err) + clientKeyFile, err := tlsManager.writeCertFile("client-*.key", "client-key-content") + require.NoError(t, err) + + tlsConfig := tlsSettings{ + ConfigurationMethod: "file-content", + RootCertFile: rootCertFile, + CertFile: clientCertFile, + CertKeyFile: clientKeyFile, + } + + // Cleanup the files + tlsManager.cleanupCertFiles(tlsConfig) + + // Verify the files are deleted + assert.NoFileExists(t, rootCertFile) + assert.NoFileExists(t, clientCertFile) + assert.NoFileExists(t, clientKeyFile) } diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index 464a267391a..8d5d4c6282f 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -96,6 +96,8 @@ func makeDataRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*h return nil, backend.DownstreamError(fmt.Errorf("failed to create request: %w", err)) } + addQueryLimitsHeader(query, req) + if query.SupportingQueryType != SupportingQueryNone { value := getSupportingQueryHeaderValue(query.SupportingQueryType) if value != "" { @@ -108,6 +110,15 @@ func makeDataRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*h return req, nil } +func addQueryLimitsHeader(query lokiQuery, req *http.Request) { + if len(query.LimitsContext.Expr) > 0 { + queryLimitStr, err := json.Marshal(query.LimitsContext) + if err == nil { + req.Header.Set("X-Loki-Query-Limits-Context", string(queryLimitStr)) + } + } +} + type lokiResponseError struct { Message string `json:"message"` TraceID string `json:"traceID,omitempty"` @@ -199,6 +210,7 @@ func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts res := backend.DataResponse{ Error: err, ErrorSource: backend.ErrorSourceFromHTTPStatus(resp.StatusCode), + Status: backend.Status(resp.StatusCode), } lp = append(lp, "status", "error", "error", err, "statusSource", res.ErrorSource) api.log.Debug("Error received from Loki", lp...) @@ -214,6 +226,7 @@ func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts iter := jsoniter.Parse(jsoniter.ConfigDefault, resp.Body, 1024) res := converter.ReadPrometheusStyleResult(iter, converter.Options{}) + res.Status = backend.Status(resp.StatusCode) if res.Error != nil { span.RecordError(res.Error) @@ -305,7 +318,9 @@ func (api *LokiAPI) RawQuery(ctx context.Context, resourcePath string) (RawLokiR } body, err = json.Marshal(lokiResponseErr) if err != nil { - return RawLokiResponse{}, err + return RawLokiResponse{ + Status: resp.StatusCode, + }, err } } diff --git a/pkg/tsdb/loki/api_test.go b/pkg/tsdb/loki/api_test.go index 252608780e6..415f807cf10 100644 --- a/pkg/tsdb/loki/api_test.go +++ b/pkg/tsdb/loki/api_test.go @@ -2,10 +2,12 @@ package loki import ( "context" + "encoding/json" "fmt" "net/http" "strings" "testing" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/loki/kinds/dataquery" @@ -47,6 +49,56 @@ func TestApiLogVolume(t *testing.T) { require.True(t, called) }) + t.Run("X-Loki-Query-Limits-Context header should be set when LimitsContext is provided", func(t *testing.T) { + called := false + from := time.Now().Truncate(time.Millisecond).Add(-1 * time.Hour) + to := time.Now().Truncate(time.Millisecond) + limitsContext := LimitsContext{ + Expr: "{cluster=\"us-central1\"}", + From: from, + To: to, + } + + limitsContextJson, _ := json.Marshal(limitsContext) + api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { + called = true + require.Equal(t, string(limitsContextJson), req.Header.Get("X-Loki-Query-Limits-Context")) + }) + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsSample, QueryType: QueryTypeRange, LimitsContext: limitsContext}, ResponseOpts{}) + require.NoError(t, err) + require.True(t, called) + }) + + t.Run("X-Loki-Query-Limits-Context header should not get set when LimitsContext is missing expr", func(t *testing.T) { + called := false + from := time.Now().Truncate(time.Millisecond).Add(-1 * time.Hour) + to := time.Now().Truncate(time.Millisecond) + limitsContext := LimitsContext{ + Expr: "", + From: from, + To: to, + } + + api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { + called = true + require.Equal(t, "", req.Header.Get("X-Loki-Query-Limits-Context")) + }) + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsSample, QueryType: QueryTypeRange, LimitsContext: limitsContext}, ResponseOpts{}) + require.NoError(t, err) + require.True(t, called) + }) + + t.Run("X-Loki-Query-Limits-Context header should not get set when LimitsContext is not provided", func(t *testing.T) { + called := false + api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { + called = true + require.Equal(t, "", req.Header.Get("X-Loki-Query-Limits-Context")) + }) + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsSample, QueryType: QueryTypeRange}, ResponseOpts{}) + require.NoError(t, err) + require.True(t, called) + }) + t.Run("data sample queries should set data sample http header", func(t *testing.T) { called := false api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { @@ -250,6 +302,7 @@ func TestApiReturnValues(t *testing.T) { require.True(t, called) require.Equal(t, "gzip", encodedBytes.Encoding) require.Equal(t, []byte("{\"message\":\"foo\"}"), encodedBytes.Body) + require.Equal(t, 400, encodedBytes.Status) }) t.Run("Loki should return the error as is", func(t *testing.T) { @@ -280,6 +333,18 @@ func TestApiReturnValues(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "foo") }) + + t.Run("should set status for successful requests", func(t *testing.T) { + called := false + api := makeMockedAPI(200, "application/json", []byte("{\"message\":\"foo\"}"), func(req *http.Request) { + called = true + }) + + res, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsVolume, QueryType: QueryTypeRange}, ResponseOpts{}) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, backend.Status(http.StatusOK), res.Status) + }) } func TestErrorSources(t *testing.T) { @@ -287,7 +352,7 @@ func TestErrorSources(t *testing.T) { t.Run("should set correct error source for downstream errors", func(t *testing.T) { called := false - api := makeMockedAPI(400, "application/json", errorResponse, func(req *http.Request) { + api := makeMockedAPI(http.StatusBadRequest, "application/json", errorResponse, func(req *http.Request) { called = true }) @@ -296,11 +361,12 @@ func TestErrorSources(t *testing.T) { require.True(t, called) require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) + require.Equal(t, backend.Status(http.StatusBadRequest), res.Status) }) t.Run("should set correct error source for plugin errors", func(t *testing.T) { called := false - api := makeMockedAPI(406, "application/json", errorResponse, func(req *http.Request) { + api := makeMockedAPI(http.StatusNotAcceptable, "application/json", errorResponse, func(req *http.Request) { called = true }) @@ -309,11 +375,12 @@ func TestErrorSources(t *testing.T) { require.True(t, called) require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourcePlugin, res.ErrorSource) + require.Equal(t, backend.Status(http.StatusNotAcceptable), res.Status) }) t.Run("should set correct error source for server errors", func(t *testing.T) { called := false - api := makeMockedAPI(500, "application/json", errorResponse, func(req *http.Request) { + api := makeMockedAPI(http.StatusInternalServerError, "application/json", errorResponse, func(req *http.Request) { called = true }) @@ -322,11 +389,26 @@ func TestErrorSources(t *testing.T) { require.True(t, called) require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) + require.Equal(t, backend.Status(http.StatusInternalServerError), res.Status) + }) + + t.Run("should set correct error source for server timeout error", func(t *testing.T) { + called := false + api := makeMockedAPI(http.StatusGatewayTimeout, "application/json", errorResponse, func(req *http.Request) { + called = true + }) + + res, err := api.DataQuery(context.Background(), lokiQuery{QueryType: QueryTypeRange}, ResponseOpts{}) + require.NoError(t, err) + require.True(t, called) + require.NotNil(t, res.Error) + require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) + require.Equal(t, backend.Status(http.StatusGatewayTimeout), res.Status) }) t.Run("should handle downstream HTTP errors", func(t *testing.T) { called := false - api := makeMockedAPI(400, "application/json", errorResponse, func(req *http.Request) { + api := makeMockedAPI(http.StatusBadRequest, "application/json", errorResponse, func(req *http.Request) { called = true }) @@ -336,30 +418,33 @@ func TestErrorSources(t *testing.T) { require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) require.Contains(t, res.Error.Error(), "test error") + require.Equal(t, backend.Status(http.StatusBadRequest), res.Status) }) t.Run("should handle client errors in RawQuery", func(t *testing.T) { called := false - api := makeMockedAPI(400, "application/json", errorResponse, func(req *http.Request) { + api := makeMockedAPI(http.StatusBadRequest, "application/json", errorResponse, func(req *http.Request) { called = true }) res, err := api.RawQuery(context.Background(), "/loki/api/v1/labels") require.NoError(t, err) require.True(t, called) - require.Equal(t, 400, res.Status) + require.Equal(t, http.StatusBadRequest, res.Status) require.Contains(t, string(res.Body), "test error") }) t.Run("should handle server errors in RawQuery", func(t *testing.T) { called := false - api := makeMockedAPI(500, "application/json", errorResponse, func(req *http.Request) { + api := makeMockedAPI(http.StatusInternalServerError, "application/json", errorResponse, func(req *http.Request) { called = true }) - _, err := api.RawQuery(context.Background(), "/loki/api/v1/labels") + res, err := api.RawQuery(context.Background(), "/loki/api/v1/labels") require.Error(t, err) require.True(t, called) require.Contains(t, err.Error(), "test error") + // Status code of 0 gets mapped to InternalServerError (500) + require.Equal(t, 0, res.Status) }) } diff --git a/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go index c9764b174fd..9826c494599 100644 --- a/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go @@ -18,6 +18,17 @@ const ( QueryEditorModeBuilder QueryEditorMode = "builder" ) +type LimitsContext struct { + Expr string `json:"expr"` + From int64 `json:"from"` + To int64 `json:"to"` +} + +// NewLimitsContext creates a new LimitsContext object. +func NewLimitsContext() *LimitsContext { + return &LimitsContext{} +} + type LokiQueryType string const ( @@ -59,6 +70,8 @@ type LokiDataQuery struct { Instant *bool `json:"instant,omitempty"` // Used to set step value for range queries. Step *string `json:"step,omitempty"` + // The full query plan for split/shard queries. Encoded and sent to Loki via `X-Loki-Query-Limits-Context` header. Requires "lokiQueryLimitsContext" feature flag + LimitsContext *LimitsContext `json:"limitsContext,omitempty"` // A unique identifier for the query within the list of targets. // In server side expressions, the refId is used as a variable name to identify results. // By default, the UI will assign A->Z; however setting meaningful names may be useful. diff --git a/pkg/tsdb/loki/parse_query.go b/pkg/tsdb/loki/parse_query.go index 06cf90cb339..0bbf76c4f05 100644 --- a/pkg/tsdb/loki/parse_query.go +++ b/pkg/tsdb/loki/parse_query.go @@ -156,6 +156,8 @@ func parseQuery(queryContext *backend.QueryDataRequest, logqlScopesEnabled bool) expr := interpolateVariables(model.Expr, interval, timeRange, queryType, step) + limitsConfig := generateLimitsConfig(model, interval, timeRange, queryType, step) + direction, err := parseDirection(model.Direction) if err != nil { return nil, err @@ -192,8 +194,21 @@ func parseQuery(queryContext *backend.QueryDataRequest, logqlScopesEnabled bool) RefID: query.RefID, SupportingQueryType: supportingQueryType, Scopes: model.Scopes, + LimitsContext: limitsConfig, }) } return qs, nil } + +func generateLimitsConfig(model *QueryJSONModel, interval time.Duration, timeRange time.Duration, queryType QueryType, step time.Duration) LimitsContext { + var limitsConfig LimitsContext + // Only supply limits context config if we have expression, and from and to + if model.LimitsContext != nil && model.LimitsContext.Expr != "" && model.LimitsContext.From > 0 && model.LimitsContext.To > 0 { + // If a limits expression was provided, interpolate it and parse the time range + limitsConfig.Expr = interpolateVariables(model.LimitsContext.Expr, interval, timeRange, queryType, step) + limitsConfig.From = time.UnixMilli(model.LimitsContext.From) + limitsConfig.To = time.UnixMilli(model.LimitsContext.To) + } + return limitsConfig +} diff --git a/pkg/tsdb/loki/parse_query_test.go b/pkg/tsdb/loki/parse_query_test.go index 497f1c880c8..f8028e0dcfe 100644 --- a/pkg/tsdb/loki/parse_query_test.go +++ b/pkg/tsdb/loki/parse_query_test.go @@ -1,6 +1,7 @@ package loki import ( + "strconv" "testing" "time" @@ -145,6 +146,74 @@ func TestParseQuery(t *testing.T) { require.Equal(t, `{namespace="logish"} |= "problems"`, models[0].Expr) }) + t.Run("parsing query model with invalid query limits context expr", func(t *testing.T) { + from := time.Now().Add(-3000 * time.Second) + fullFrom := time.Now().Add(-1 * time.Hour) + to := time.Now() + + queryContext := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(` + { + "expr": "count_over_time({service_name=\"apache\", __stream_shard__=\"2\"}[$__auto])", + "format": "time_series", + "refId": "A", + "limitsContext": {"expr": "", "from": ` + strconv.FormatInt(fullFrom.UnixMilli(), 10) + `, "to": ` + strconv.FormatInt(to.UnixMilli(), 10) + `} + }`, + ), + TimeRange: backend.TimeRange{ + From: from, + To: to, + }, + Interval: time.Second * 15, + MaxDataPoints: 200, + }, + }, + } + models, err := parseQuery(queryContext, true) + require.NoError(t, err) + require.Equal(t, `count_over_time({service_name="apache", __stream_shard__="2"}[15s])`, models[0].Expr) + // If the limits context expression is missing, we don't set any limits context + require.Equal(t, ``, models[0].LimitsContext.Expr) + require.Equal(t, time.Time{}, models[0].LimitsContext.To) + require.Equal(t, time.Time{}, models[0].LimitsContext.From) + }) + + t.Run("parsing query model with query limits context", func(t *testing.T) { + from := time.Now().Add(-3000 * time.Second) + fullFrom := time.Now().Add(-1 * time.Hour) + to := time.Now() + + queryContext := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(` + { + "expr": "count_over_time({service_name=\"apache\", __stream_shard__=\"2\"}[$__auto])", + "format": "time_series", + "refId": "A", + "limitsContext": {"expr": "count_over_time({service_name=\"apache\"}[$__auto])", "from": ` + strconv.FormatInt(fullFrom.UnixMilli(), 10) + `, "to": ` + strconv.FormatInt(to.UnixMilli(), 10) + `} + }`, + ), + TimeRange: backend.TimeRange{ + From: from, + To: to, + }, + Interval: time.Second * 15, + MaxDataPoints: 200, + }, + }, + } + models, err := parseQuery(queryContext, true) + require.NoError(t, err) + require.Equal(t, time.Second*15, models[0].Step) + require.Equal(t, `count_over_time({service_name="apache", __stream_shard__="2"}[15s])`, models[0].Expr) + require.Equal(t, `count_over_time({service_name="apache"}[15s])`, models[0].LimitsContext.Expr) + require.Equal(t, to.Truncate(time.Millisecond), models[0].LimitsContext.To) + require.Equal(t, fullFrom.Truncate(time.Millisecond), models[0].LimitsContext.From) + }) + t.Run("interpolate variables, range between 1s and 0.5s", func(t *testing.T) { expr := "go_goroutines $__interval $__interval_ms $__range $__range_s $__range_ms" queryType := dataquery.LokiQueryTypeRange diff --git a/pkg/tsdb/loki/types.go b/pkg/tsdb/loki/types.go index 0d6156ed158..dd86595ada0 100644 --- a/pkg/tsdb/loki/types.go +++ b/pkg/tsdb/loki/types.go @@ -11,6 +11,11 @@ import ( type QueryType = dataquery.LokiQueryType type SupportingQueryType = dataquery.SupportingQueryType type Direction = dataquery.LokiQueryDirection +type LimitsContext struct { + Expr string + From time.Time + To time.Time +} const ( QueryTypeRange = dataquery.LokiQueryTypeRange @@ -42,4 +47,5 @@ type lokiQuery struct { RefID string SupportingQueryType SupportingQueryType Scopes []scope.ScopeFilter + LimitsContext LimitsContext } diff --git a/pkg/tsdb/tempo/grpc.go b/pkg/tsdb/tempo/grpc.go index 81a41020b6c..ac1ff74d424 100644 --- a/pkg/tsdb/tempo/grpc.go +++ b/pkg/tsdb/tempo/grpc.go @@ -88,6 +88,7 @@ func getDialOpts(ctx context.Context, settings backend.DataSourceInstanceSetting var dialOps []grpc.DialOption + dialOps = append(dialOps, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100*1024*1024))) dialOps = append(dialOps, grpc.WithChainStreamInterceptor(CustomHeadersStreamInterceptor(opts))) if settings.BasicAuthEnabled { // If basic authentication is enabled, it uses TLS transport credentials and sets the basic authentication header for each RPC call. diff --git a/public/app/core/components/help/HelpModal.test.tsx b/public/app/core/components/help/HelpModal.test.tsx index d343c5560b0..a410f1cd4d6 100644 --- a/public/app/core/components/help/HelpModal.test.tsx +++ b/public/app/core/components/help/HelpModal.test.tsx @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react'; import { useAssistant } from '@grafana/assistant'; +import { config } from '@grafana/runtime'; import { useShortcuts } from './HelpModal'; @@ -148,4 +149,73 @@ describe('useShortcuts', () => { const assistantShortcut = globalCategory!.shortcuts.find((shortcut) => shortcut.keys.includes('ctrl + .')); expect(assistantShortcut).toBeDefined(); }); + + describe('time range zoom shortcuts with feature toggle', () => { + beforeEach(() => { + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: jest.fn(), + closeAssistant: jest.fn(), + toggleAssistant: jest.fn(), + }); + }); + + it('should show new zoom shortcuts when feature toggle is enabled', () => { + config.featureToggles.newTimeRangeZoomShortcuts = true; + + const { result } = renderHook(() => useShortcuts()); + + const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range')); + + const zoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+')); + const zoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-')); + + expect(zoomInShortcut).toBeDefined(); + expect(zoomInShortcut!.isNew).toBe(true); + expect(zoomOutShortcut).toBeDefined(); + expect(zoomOutShortcut!.isNew).toBe(true); + }); + + it('should show legacy t z shortcut when feature toggle is disabled', () => { + config.featureToggles.newTimeRangeZoomShortcuts = false; + + const { result } = renderHook(() => useShortcuts()); + + const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range')); + + const legacyZoomShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('z')); + const newZoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+')); + const newZoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-')); + + expect(legacyZoomShortcut).toBeDefined(); + expect(newZoomInShortcut).toBeUndefined(); + expect(newZoomOutShortcut).toBeUndefined(); + }); + + it('should not show isNew badge on legacy shortcuts', () => { + config.featureToggles.newTimeRangeZoomShortcuts = false; + + const { result } = renderHook(() => useShortcuts()); + + const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range')); + + const legacyZoomShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('z')); + + expect(legacyZoomShortcut!.isNew).toBeUndefined(); + }); + + it('should show isNew badge on new shortcuts when feature toggle is enabled', () => { + config.featureToggles.newTimeRangeZoomShortcuts = true; + + const { result } = renderHook(() => useShortcuts()); + + const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range')); + + const zoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+')); + const zoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-')); + + expect(zoomInShortcut!.isNew).toBe(true); + expect(zoomOutShortcut!.isNew).toBe(true); + }); + }); }); diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index 4017443b252..82f39fac28f 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -2,9 +2,10 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; import { useAssistant } from '@grafana/assistant'; -import { GrafanaTheme2 } from '@grafana/data'; +import { FeatureState, GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Grid, Modal, useStyles2, Text } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { Grid, Modal, useStyles2, Text, FeatureBadge } from '@grafana/ui'; import { getModKey } from 'app/core/utils/browser'; export interface HelpModalProps { @@ -36,7 +37,7 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => { - {shortcuts.map(({ keys, description }) => ( + {shortcuts.map(({ keys, description, isNew }) => ( {keys.map((key) => ( @@ -44,9 +45,12 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => { ))} - - {description} - +
+ + {description} + + {isNew && } +
))} @@ -104,10 +108,25 @@ export const useShortcuts = () => { { category: t('help-modal.shortcuts-category.time-range', 'Time range'), shortcuts: [ - { - keys: ['t', 'z'], - description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'), - }, + ...(config.featureToggles.newTimeRangeZoomShortcuts + ? [ + { + keys: ['t', '+'], + description: t('help-modal.shortcuts-description.zoom-in-time-range', 'Zoom in time range'), + isNew: true, + }, + { + keys: ['t', '-'], + description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'), + isNew: true, + }, + ] + : [ + { + keys: ['t', 'z'], + description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'), + }, + ]), { keys: ['t', '←'], description: t('help-modal.shortcuts-description.move-time-range-back', 'Move time range back'), @@ -277,6 +296,12 @@ function getStyles(theme: GrafanaTheme2) { whiteSpace: 'nowrap', minWidth: 83, // To match column widths with the widest }), + descriptionWrapper: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + flexWrap: 'nowrap', + }), shortcutTableKey: css({ display: 'inline-block', textAlign: 'center', diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 865f461ce84..ea232baffcc 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -1,6 +1,6 @@ import { toggleAssistant, isAssistantAvailable } from '@grafana/assistant'; import { LegacyGraphHoverClearEvent, SetPanelAttentionEvent, locationUtil } from '@grafana/data'; -import { LocationService } from '@grafana/runtime'; +import { LocationService, config } from '@grafana/runtime'; import { appEvents } from 'app/core/app_events'; import { getExploreUrl } from 'app/core/utils/explore'; import { toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils'; @@ -231,9 +231,19 @@ export class KeybindingSrv { appEvents.publish(new AbsoluteTimeEvent({ updateUrl })); }); - this.bind('t z', () => { - appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); - }); + if (config.featureToggles.newTimeRangeZoomShortcuts) { + this.bind('t +', () => { + appEvents.publish(new ZoomOutEvent({ scale: 0.5, updateUrl })); + }); + + this.bind('t -', () => { + appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); + }); + } else { + this.bind('t z', () => { + appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); + }); + } this.bind('ctrl+z', () => { appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); diff --git a/public/app/core/utils/shortLinks.test.ts b/public/app/core/utils/shortLinks.test.ts index 39a43af6629..f88f302b4db 100644 --- a/public/app/core/utils/shortLinks.test.ts +++ b/public/app/core/utils/shortLinks.test.ts @@ -164,7 +164,6 @@ describe('buildShortUrl', () => { describe('getLogsPermalinkRange', () => { let row: LogRowModel, rows: LogRowModel[]; beforeEach(() => { - config.featureToggles.logsInfiniteScrolling = true; row = createLogRow({ timeEpochMs: 1111112222222, }); @@ -175,22 +174,6 @@ describe('getLogsPermalinkRange', () => { row, ]; }); - afterAll(() => { - config.featureToggles.logsInfiniteScrolling = false; - }); - - it('returns the original range if infinite scrolling is not enabled', () => { - config.featureToggles.logsInfiniteScrolling = false; - const range = { - from: 1111111111111, - to: 1111112222222, - }; - const expectedRange = { - from: new Date(1111111111111).toISOString(), - to: new Date(1111112222222).toISOString(), - }; - expect(getLogsPermalinkRange(row, [row], range)).toEqual(expectedRange); - }); it('returns the range relative to the previous log line', () => { const range = { diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 706857c122c..208f2ae0fcc 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -170,14 +170,6 @@ function getPreviousLog(row: LogRowModel, allLogs: LogRowModel[]): LogRowModel | } export function getLogsPermalinkRange(row: LogRowModel, rows: LogRowModel[], absoluteRange: AbsoluteTimeRange) { - const range = { - from: new Date(absoluteRange.from).toISOString(), - to: new Date(absoluteRange.to).toISOString(), - }; - if (!config.featureToggles.logsInfiniteScrolling) { - return range; - } - // With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust // Look for the previous sibling log, and use its timestamp const allLogs = rows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx index 0e75531c986..71b05c172cb 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx @@ -4,8 +4,10 @@ import { useEffectOnce } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Button, LoadingPlaceholder, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; +import { Alert, Button, LoadingPlaceholder, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; +import { AccessControlAction } from 'app/types/accessControl'; import { AlertQuery, Labels } from 'app/types/unified-alerting-dto'; import { Folder, KBObjectArray } from '../../../types/rule-form'; @@ -36,6 +38,7 @@ export const NotificationPreview = ({ alertUid, }: NotificationPreviewProps) => { const styles = useStyles2(getStyles); + const previewRoutingDisabled = !condition || !folder; const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = preview.useMutation(); @@ -67,9 +70,7 @@ export const NotificationPreview = ({ }; useEffectOnce(() => { - if (!previewRoutingDisabled) { - onPreview(); - } + onPreview(); }); // Get alert managers's data source information @@ -77,18 +78,18 @@ export const NotificationPreview = ({ const singleAlertManagerConfigured = alertManagerDataSources.length === 1; const getTooltipContent = () => { + if (!condition) { + return ( + + Select a query condition to preview routing + + ); + } if (!folder) { return ( Select a folder to preview routing ); } - if (previewRoutingDisabled) { - return ( - - You don't have sufficient permissions to preview - - ); - } return ''; }; @@ -146,10 +147,12 @@ export const NotificationPreview = ({ )} {alertManagerSource.name === 'grafana' ? ( - + + + ) : ( {children}; + } + + return ( + + + You don't have permission to view notification policies. Preview is not available. + + + ); +} + const getStyles = (theme: GrafanaTheme2) => ({ firstAlertManagerLine: css({ height: '1px', diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 46b202ac7f8..7ca1747a8c5 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -23,3 +23,6 @@ export const shouldAllowPermanentlyDeletingRules = () => (shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false; export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUseBackendFilters ?? false; + +export const shouldUseFullyCompatibleBackendFilters = () => + config.featureToggles.alertingUIUseFullyCompatBackendFilters ?? false; diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 5dd43d6db63..5635a3d666a 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -125,6 +125,22 @@ const AlwaysSupported = true; const NotSupported = false; export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction; + +/** + * Represents the ability to perform an action, with two distinct checks: + * + * @param actionSupported - Whether the action is technically possible in the current context. + * This depends on system capabilities (e.g., API availability), feature availability + * (e.g., Grafana vs external alertmanager), and rule state (e.g., provisioned/immutable rules). + * Examples: Can't edit provisioned rules, can't export from external alertmanagers. + * + * @param actionAllowed - Whether the user has permission to perform the action. + * This is based on RBAC permissions, folder-specific permissions, and admin status. + * Examples: User lacks AlertingRuleUpdate permission, user can't edit in this folder. + * + * Both must be true for an action to be available. This separation allows showing appropriate + * messages: "Feature not available" vs "You don't have permission". + */ export type Ability = [actionSupported: boolean, actionAllowed: boolean]; export type Abilities = Record; diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 3825c905a52..56be25ee248 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -484,6 +484,190 @@ describe('grafana-managed rules', () => { expect(frontendFilter2.groupMatches(group)).toBe(false); }); }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should populate backend filters correctly (ruleType, dashboardUid)', () => { + // Fully compatible filters should be in backend + const { backendFilter } = getGrafanaFilter( + getFilter({ + ruleType: PromRuleType.Alerting, + dashboardUid: 'dashboard-123', + freeFormWords: ['cpu'], + groupName: 'my-group', + }) + ); + + expect(backendFilter.type).toBe(PromRuleType.Alerting); + expect(backendFilter.dashboardUid).toBe('dashboard-123'); + + // Non-compatible filters should NOT be in backend + expect(backendFilter.title).toBeUndefined(); + expect(backendFilter.searchGroupName).toBeUndefined(); + + // Empty state + const { backendFilter: emptyFilter } = getGrafanaFilter(getFilter({})); + expect(emptyFilter.type).toBeUndefined(); + expect(emptyFilter.dashboardUid).toBeUndefined(); + }); + + it('should apply frontend filters correctly', () => { + const alertingRule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + labels: { severity: 'critical' }, + queriedDatasourceUIDs: ['datasource-uid-1'], + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + alerts: [], + }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + // Backend-handled filters (ruleType, dashboardUid) should skip frontend filtering + const { frontendFilter: backendHandledFilter } = getGrafanaFilter( + getFilter({ ruleType: PromRuleType.Recording, dashboardUid: 'dashboard-b' }) + ); + expect(backendHandledFilter.ruleMatches(alertingRule)).toBe(true); + expect(backendHandledFilter.ruleMatches(recordingRule)).toBe(true); + + // Frontend-handled filters (freeFormWords, ruleName) should work + const { frontendFilter: freeFormMatch } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(freeFormMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: freeFormNoMatch } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] })); + expect(freeFormNoMatch.ruleMatches(alertingRule)).toBe(false); + + const { frontendFilter: ruleNameMatch } = getGrafanaFilter(getFilter({ ruleName: 'cpu' })); + expect(ruleNameMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: ruleNameNoMatch } = getGrafanaFilter(getFilter({ ruleName: 'memory' })); + expect(ruleNameNoMatch.ruleMatches(alertingRule)).toBe(false); + + // Group name filtering + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter: groupMatch } = getGrafanaFilter(getFilter({ groupName: 'cpu' })); + expect(groupMatch.groupMatches(group)).toBe(true); + + const { frontendFilter: groupNoMatch } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + expect(groupNoMatch.groupMatches(group)).toBe(false); + + // Always-frontend filters (labels, dataSourceNames, namespace) should work + const { frontendFilter: labelsMatch } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + expect(labelsMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: labelsNoMatch } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); + expect(labelsNoMatch.ruleMatches(alertingRule)).toBe(false); + + const { frontendFilter: dsMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(dsMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: dsNoMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(dsNoMatch.ruleMatches(alertingRule)).toBe(false); + + const { frontendFilter: nsMatch } = getGrafanaFilter(getFilter({ namespace: 'production' })); + expect(nsMatch.groupMatches(group)).toBe(true); + + const { frontendFilter: nsNoMatch } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + expect(nsNoMatch.groupMatches(group)).toBe(false); + }); + }); + + describe('when both alertingUIUseBackendFilters and alertingUIUseFullyCompatBackendFilters are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should include all backend filters (title, ruleType, dashboardUid, searchGroupName)', () => { + const { backendFilter } = getGrafanaFilter( + getFilter({ + freeFormWords: ['cpu'], + ruleName: 'alert', + ruleType: PromRuleType.Alerting, + dashboardUid: 'dashboard-123', + groupName: 'my-group', + }) + ); + + expect(backendFilter.title).toBe('alert cpu'); + expect(backendFilter.type).toBe(PromRuleType.Alerting); + expect(backendFilter.dashboardUid).toBe('dashboard-123'); + expect(backendFilter.searchGroupName).toBe('my-group'); + }); + + it('should skip all backend-handled filters on frontend', () => { + const alertingRule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { frontendFilter } = getGrafanaFilter( + getFilter({ + freeFormWords: ['memory'], + ruleName: 'memory', + ruleType: PromRuleType.Recording, + dashboardUid: 'dashboard-b', + }) + ); + + // All these filters are handled by backend, so frontend should return true + expect(frontendFilter.ruleMatches(alertingRule)).toBe(true); + expect(frontendFilter.ruleMatches(recordingRule)).toBe(true); + }); + + it('should skip groupName filtering on frontend', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + // Should return true because groupName filter is null (handled by backend) + expect(frontendFilter.groupMatches(group)).toBe(true); + }); + + it('should still apply always-frontend filters (labels, dataSourceNames, namespace)', () => { + const rule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + labels: { severity: 'critical' }, + queriedDatasourceUIDs: ['datasource-uid-1'], + alerts: [], + }); + + // Labels filter should still work + const { frontendFilter: labelFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); + expect(labelFilter.ruleMatches(rule)).toBe(false); + + const { frontendFilter: labelFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + expect(labelFilter2.ruleMatches(rule)).toBe(true); + + // DataSourceNames filter should still work + const { frontendFilter: dsFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(dsFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: dsFilter2 } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(dsFilter2.ruleMatches(rule)).toBe(false); + + // Namespace filter should still work + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter: nsFilter } = getGrafanaFilter(getFilter({ namespace: 'production' })); + expect(nsFilter.groupMatches(group)).toBe(true); + + const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + expect(nsFilter2.groupMatches(group)).toBe(false); + }); + }); }); describe('hasClientSideFilters', () => { @@ -560,5 +744,49 @@ describe('grafana-managed rules', () => { expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should return correct values for all filter types', () => { + // Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters + expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + + // Should return true for: frontend-handled filters + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + }); + + describe('when both alertingUIUseBackendFilters and alertingUIUseFullyCompatBackendFilters are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return correct values for all filter types', () => { + // Should return false for: empty, all backend-handled filters, and backend-only filters + expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + + // Should return true for: always-frontend filters only + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index d7d224047d7..5b5ddd81f9b 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -1,7 +1,7 @@ import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; -import { shouldUseBackendFilters } from '../../featureToggles'; +import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../../featureToggles'; import { RulesFilter } from '../../search/rulesSearchParser'; import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; @@ -57,7 +57,8 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { */ export function getGrafanaFilter(filterState: RulesFilter) { const normalizedFilterState = normalizeFilterState(filterState); - const useBackendFilters = shouldUseBackendFilters(); + + const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); // Build title search for backend filtering const titleSearch = buildTitleSearch(normalizedFilterState); @@ -66,20 +67,18 @@ export function getGrafanaFilter(filterState: RulesFilter) { state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [], health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [], contactPoint: normalizedFilterState.contactPoint ?? undefined, - title: useBackendFilters ? titleSearch : undefined, - type: useBackendFilters ? normalizedFilterState.ruleType : undefined, - dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined, - searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, + // If FE filter is defined, don't include the backend filter + title: ruleFilterConfig.ruleName ? undefined : titleSearch, + type: ruleFilterConfig.ruleType ? undefined : normalizedFilterState.ruleType, + dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid, + searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName, }; - const { ruleFilterConfig: grafanaFilterProcessingConfig, groupFilterConfig: grafanaGroupFilterConfig } = - buildGrafanaFilterConfigs(); - return { backendFilter, frontendFilter: { - groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig), - ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig), + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, groupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, ruleFilterConfig), }, }; } @@ -93,17 +92,18 @@ export function getGrafanaFilter(filterState: RulesFilter) { */ function buildGrafanaFilterConfigs() { const useBackendFilters = shouldUseBackendFilters(); + const useFullyCompatibleBackendFilters = shouldUseFullyCompatibleBackendFilters(); const ruleFilterConfig: RuleFilterConfig = { // When backend filtering is enabled, these filters are handled by the backend freeFormWords: useBackendFilters ? null : freeFormFilter, ruleName: useBackendFilters ? null : ruleNameFilter, ruleState: null, - ruleType: useBackendFilters ? null : ruleTypeFilter, + ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter, dataSourceNames: dataSourceNamesFilter, labels: labelsFilter, ruleHealth: null, - dashboardUid: useBackendFilters ? null : dashboardUidFilter, + dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter, plugins: pluginsFilter, contactPoint: null, }; diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 3c000d6cab7..077934e21e1 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -21,6 +21,7 @@ import { getQueryRunnerFor, } from 'app/features/dashboard-scene/utils/utils'; import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; +import { getTemplateSrv } from 'app/features/templating/template_srv'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { @@ -744,6 +745,9 @@ export const panelToRuleFormValues = async ( return undefined; } + // Interpolate interval to replace dashboard variables + const interpolatedInterval = panel.interval ? panel.replaceVariables(panel.interval, undefined) : undefined; + const relativeTimeRange = rangeUtil.timeRangeToRelative(rangeUtil.convertRawToRange(dashboard.time)); const queries = await dataQueriesToGrafanaQueries( targets, @@ -751,7 +755,7 @@ export const panelToRuleFormValues = async ( panel.scopedVars || {}, panel.datasource ?? undefined, panel.maxDataPoints ?? undefined, - panel.interval ?? undefined + interpolatedInterval ); // if no alerting capable queries are found, can't create a rule if (!queries.length || !queries.find((query) => query.datasourceUid !== ExpressionDatasourceUID)) { @@ -825,13 +829,19 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise

{ const panel = this.state.panelRef.resolve(); if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { - panel.changePluginType('timeseries'); + if (config.featureToggles.newVizSuggestions) { + this._autoSelectVisualization(panel); + } else { + panel.changePluginType('timeseries'); + } } this._subs.add( @@ -117,6 +124,31 @@ export class PanelEditor extends SceneObjectBase { }; } + private async _autoSelectVisualization(panel: VizPanel) { + const dataObject = sceneGraph.getData(panel); + + this._subs.add( + dataObject.subscribeToState(async () => { + const { data } = dataObject.state; + const hasData = data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0); + + if (hasData && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { + const panelModel = new PanelModelCompatibilityWrapper(panel); + const suggestions = await getAllSuggestions(data, panelModel); + + if (suggestions.length > 0) { + const defaultFirstSuggestion = suggestions[0]; + await panel.changePluginType( + defaultFirstSuggestion.pluginId, + defaultFirstSuggestion.options, + defaultFirstSuggestion.fieldConfig + ); + } + } + }) + ); + } + private commitChanges() { if (!this.state.isDirty && !this._changesHaveBeenMade) { // Nothing to commit @@ -228,12 +260,17 @@ export class PanelEditor extends SceneObjectBase { }) ); + const isUnconfigured = Boolean( + config.featureToggles.newVizSuggestions && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID + ); + // Setup options pane this.setState({ optionsPane: new PanelOptionsPane({ panelRef: this.state.panelRef, searchQuery: '', listMode: OptionFilter.All, + isVizPickerOpen: isUnconfigured, }), isInitializing: false, }); diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx index 74b78bbc323..86b83bd0ed8 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx @@ -8,7 +8,7 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { getLibraryPanel } from 'app/features/library-panels/state/api'; import { createPanelDataProvider } from '../utils/createPanelDataProvider'; -import { getPanelIdForVizPanel } from '../utils/utils'; +import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; import { panelLinksBehavior } from './PanelMenuBehavior'; @@ -99,8 +99,19 @@ export class LibraryPanelBehavior extends SceneObjectBase(); + + // Subscribe to state changes (this triggers re-renders when state changes) + const filtersState = filtersVar?.useState(); + const groupByState = groupByVar?.useState(); + + const [nonApplicable, setNonApplicable] = useState([]); + const [visibleCount, setVisibleCount] = useState(0); + + // Create stable string representations to detect actual changes + const filterKey = useMemo(() => { + const filters = filtersState?.filters ?? []; + const originFilters = filtersState?.originFilters ?? []; + const filterValues = [...filters, ...originFilters]; + return JSON.stringify(filterValues.map((f) => `${f.key}${f.operator}${f.value}${f.origin ?? ''}`)); + }, [filtersState?.filters, filtersState?.originFilters]); + + const groupByKey = useMemo(() => { + const value = groupByState?.value ?? []; + const groupByValues = Array.isArray(value) ? value : value ? [value] : []; + return JSON.stringify(groupByValues); + }, [groupByState?.value]); + + useEffect(() => { + const fetchApplicability = async () => { + const filters = filtersState?.filters ?? []; + const originFilters = filtersState?.originFilters ?? []; + const value = groupByState?.value ?? []; + + const filterValues = [...filters, ...originFilters]; + const groupByValues = Array.isArray(value) ? value : value ? [value] : []; + + const labels: string[] = []; + + const applicability = await getDrilldownApplicability(queryRunner, filtersVar, groupByVar); + if (filterValues.length) { + const nonApplicableFilters = filterValues.filter((filter) => { + const result = applicability?.find((entry) => entry.key === filter.key && entry.origin === filter.origin); + return result && !result.applicable; + }); + labels.push(...nonApplicableFilters.map((filter) => `${filter.key} ${filter.operator} ${filter.value}`)); + } + + if (groupByValues.length) { + const nonApplicableKeys = groupByValues + .filter((groupByKey) => { + const result = applicability?.find((entry) => entry.key === groupByKey); + return result && !result.applicable; + }) + .map((key) => String(key)); + + labels.push(...nonApplicableKeys); + } + + setNonApplicable(labels); + }; + + fetchApplicability(); + }, [ + filterKey, + groupByKey, + filtersVar, + groupByVar, + queryRunner, + filtersState?.filters, + groupByState?.value, + filtersState?.originFilters, + ]); + + useLayoutEffect(() => { + if (!nonApplicable.length) { + setVisibleCount(0); + return; + } + + setVisibleCount(calculateVisibleCount(nonApplicable, containerWidth, theme)); + }, [containerWidth, nonApplicable, theme]); + + if (nonApplicable.length === 0) { + return null; + } + + const visibleFilters = nonApplicable.slice(0, visibleCount); + const remainingCount = nonApplicable.length - visibleCount; + const hasOverflow = remainingCount > 0; + const remainingFilters = nonApplicable.slice(visibleCount); + + return ( +

+ {visibleFilters.map((filter, index) => ( +
+ {filter} +
+ ))} + {hasOverflow && ( + +
+{remainingCount}
+
+ )} +
+ ); +} + +function getPillWidth(label: string) { + return Math.ceil(measureText(label, FONT_SIZE).width) + GAP_SIZE; +} + +function calculateVisibleCount(labels: string[], containerWidth: number, theme: GrafanaTheme2) { + let usedWidth = 0; + let visible = 0; + + for (let i = 0; i < labels.length; i++) { + const pillWidth = getPillWidth(labels[i]); + const gapBefore = visible > 0 ? GAP_SIZE : 0; + const nextWidth = usedWidth + gapBefore + pillWidth; + const remaining = labels.length - (i + 1); + + if (remaining > 0) { + const overflowWidth = getPillWidth(`+${remaining}`); + if (nextWidth + GAP_SIZE + overflowWidth > containerWidth) { + break; + } + } else if (nextWidth > containerWidth) { + break; + } + + usedWidth = nextWidth; + visible++; + } + + return visible; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + flexWrap: 'nowrap', + gap: theme.spacing(1), + width: '100%', + overflow: 'hidden', + }), + pill: css({ + padding: theme.spacing(0.2, 0.4), + borderRadius: theme.shape.radius.default, + whiteSpace: 'nowrap', + flexShrink: 0, + fontSize: theme.typography.bodySmall.fontSize, + }), + disabledPill: css({ + background: theme.colors.action.selected, + color: theme.colors.text.disabled, + border: 0, + '&:hover': { + background: theme.colors.action.selected, + }, + }), + strikethrough: css({ + textDecoration: 'line-through', + }), + }; +} diff --git a/public/app/features/dashboard-scene/scene/UnconfiguredPanel.tsx b/public/app/features/dashboard-scene/scene/UnconfiguredPanel.tsx index eda81bf019a..2be7201ad74 100644 --- a/public/app/features/dashboard-scene/scene/UnconfiguredPanel.tsx +++ b/public/app/features/dashboard-scene/scene/UnconfiguredPanel.tsx @@ -1,11 +1,13 @@ -import { useCallback, useState } from 'react'; +import { css } from '@emotion/css'; +import { useCallback, useEffect, useState } from 'react'; -import { PanelPlugin, PanelProps } from '@grafana/data'; +import { CoreApp, GrafanaTheme2, PanelPlugin, PanelProps } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { sceneUtils } from '@grafana/scenes'; -import { Box, Button, ButtonGroup, Dropdown, Menu, Stack } from '@grafana/ui'; +import { Box, Button, ButtonGroup, Dropdown, Icon, Menu, Stack, Text, usePanelContext, useStyles2 } from '@grafana/ui'; +import { NEW_PANEL_TITLE } from '../../dashboard/utils/dashboard'; import { DashboardInteractions } from '../utils/interactions'; import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../utils/utils'; @@ -16,6 +18,8 @@ const UnconfiguredPanel = new PanelPlugin(UnconfiguredPanelComp); function UnconfiguredPanelComp(props: PanelProps) { const [isOpen, setIsOpen] = useState(false); + const panelContext = usePanelContext(); + const styles = useStyles2(getStyles); const onMenuClick = useCallback((isOpen: boolean) => { setIsOpen(isOpen); @@ -26,14 +30,15 @@ function UnconfiguredPanelComp(props: PanelProps) { DashboardInteractions.panelActionClicked('configure', props.id, 'panel'); }; - const onUseLibraryPanel = () => { - const dashboard = window.__grafanaSceneContext; + const dashboard = window.__grafanaSceneContext; + const panel = + dashboard instanceof DashboardScene ? findVizPanelByKey(dashboard, getVizPanelKeyForPanelId(props.id)) : null; - if (!(dashboard instanceof DashboardScene)) { + const onUseLibraryPanel = () => { + if (!dashboard || !(dashboard instanceof DashboardScene)) { throw new Error('DashboardScene not found'); } - const panel = findVizPanelByKey(dashboard, getVizPanelKeyForPanelId(props.id)); if (!panel) { throw new Error('Panel not found'); } @@ -41,6 +46,18 @@ function UnconfiguredPanelComp(props: PanelProps) { dashboard.onShowAddLibraryPanelDrawer(panel.getRef()); }; + useEffect(() => { + if (!panel || !config.featureToggles.newVizSuggestions) { + return; + } + + if (panelContext.app === CoreApp.PanelEditor) { + panel.setState({ title: '' }); + } else if (!panel.state.title) { + panel.setState({ title: NEW_PANEL_TITLE }); + } + }, [panel, panelContext.app]); + const MenuActions = () => ( ); + const showEmptyState = config.featureToggles.newVizSuggestions && panelContext.app === CoreApp.PanelEditor; + + if (showEmptyState) { + const defaultContent = ( + + Run a query to visualize it here or go to all visualizations to add other panel types + + ); + + return ( +
+ + + {defaultContent} + +
+ ); + } + return ( @@ -79,3 +115,20 @@ sceneUtils.registerRuntimePanelPlugin({ pluginId: UNCONFIGURED_PANEL_PLUGIN_ID, plugin: UnconfiguredPanel, }); + +function getStyles(theme: GrafanaTheme2) { + return { + emptyStateWrapper: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + textAlign: 'center', + }), + emptyStateIcon: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/dashboard-scene/scene/VizPanelSubHeader.test.tsx b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.test.tsx new file mode 100644 index 00000000000..e05e9b20da4 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.test.tsx @@ -0,0 +1,184 @@ +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + GroupByVariable, + SceneDataTransformer, + SceneQueryRunner, + SceneVariableSet, + VizPanel, + VizPanelState, +} from '@grafana/scenes'; + +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardScene } from './DashboardScene'; +import { VizPanelSubHeader } from './VizPanelSubHeader'; +import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; + +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + return of({ + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }); +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, + getDataSourceSrv: () => ({ + get: jest.fn().mockResolvedValue({ + getRef: () => ({ uid: 'ds-1', type: 'test' }), + }), + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds-1', type: 'test' }), + }), + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => undefined), + }), +})); + +setPluginImportUtils({ + importPanelPlugin: () => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: () => undefined, +}); + +describe('VizPanelSubHeader', () => { + it('renders when the drilldown variables apply to the panel', async () => { + const { subHeader } = await buildScene(); + + expect(subHeader.state.supportsApplicability).toBe(true); + }); + + it('renders only one drilldown var is set', async () => { + const { subHeader } = await buildScene({ noGroupBy: true }); + + expect(subHeader.state.supportsApplicability).toBe(true); + }); + + it('does not render when applicability is disabled', async () => { + const { subHeader } = await buildScene({ applicabilityEnabled: false }); + + expect(subHeader.state.supportsApplicability).toBe(false); + }); + + it('does not render when the datasource uid does not match', async () => { + const { subHeader } = await buildScene({ + variableDatasourceUid: 'other-ds', + }); + + expect(subHeader.state.supportsApplicability).toBe(false); + }); + + it('no longer renders if variable ds changes to a different type', async () => { + const { subHeader, adhocFiltersVariable } = await buildScene({ + noGroupBy: true, + }); + + expect(subHeader.state.supportsApplicability).toBe(true); + + adhocFiltersVariable.setState({ datasource: { uid: 'ds-2' } }); + + expect(subHeader.state.supportsApplicability).toBe(false); + }); + + it('no longers renders if variable applicability becomes disabled', async () => { + const { subHeader, adhocFiltersVariable } = await buildScene({ + noGroupBy: true, + }); + + expect(subHeader.state.supportsApplicability).toBe(true); + + adhocFiltersVariable.setState({ applicabilityEnabled: false }); + + expect(subHeader.state.supportsApplicability).toBe(false); + }); + + it('continues to render if one adhoc is disabled, but groupby remains active', async () => { + const { subHeader, adhocFiltersVariable } = await buildScene(); + + expect(subHeader.state.supportsApplicability).toBe(true); + + adhocFiltersVariable.setState({ applicabilityEnabled: false }); + + expect(subHeader.state.supportsApplicability).toBe(true); + }); + + it('stops rendering if queryRunner changes datasource to different one than vars', async () => { + const { subHeader, queryRunner } = await buildScene(); + + expect(subHeader.state.supportsApplicability).toBe(true); + + queryRunner.setState({ datasource: { uid: 'ds-2' } }); + + expect(subHeader.state.supportsApplicability).toBe(false); + }); +}); + +interface BuildSceneOptions { + applicabilityEnabled?: boolean; + variableDatasourceUid?: string; + noGroupBy?: boolean; +} + +async function buildScene(options?: BuildSceneOptions) { + const subHeader = new VizPanelSubHeader({}); + + const queryRunner = new SceneQueryRunner({ + datasource: { uid: 'ds-1' }, + queries: [{ refId: 'A', datasource: { uid: 'ds-1' } }], + }); + + const adhocFiltersVariable = new AdHocFiltersVariable({ + name: 'adhoc', + label: 'adhoc', + filters: [{ key: 'filter-1', operator: '=', value: 'value-1' }], + datasource: { uid: options?.variableDatasourceUid ?? 'ds-1' }, + applicabilityEnabled: options?.applicabilityEnabled ?? true, + }); + + const groupByVariable = new GroupByVariable({ + name: 'group', + label: 'group', + value: ['groupBy'], + text: ['groupBy'], + options: [], + applicabilityEnabled: options?.applicabilityEnabled ?? true, + datasource: { uid: options?.variableDatasourceUid ?? 'ds-1' }, + }); + + const dataProvider = new SceneDataTransformer({ + $data: queryRunner, + transformations: [], + }); + + const panelState: VizPanelState = { + key: 'panel-1', + title: 'Panel A', + pluginId: 'timeseries', + subHeader, + $data: dataProvider, + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + const panel = new VizPanel(panelState); + + const scene = new DashboardScene({ + $variables: new SceneVariableSet({ + variables: options?.noGroupBy ? [adhocFiltersVariable] : [groupByVariable, adhocFiltersVariable], + }), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + }); + + activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + return { subHeader, groupByVariable, adhocFiltersVariable, queryRunner }; +} diff --git a/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx new file mode 100644 index 00000000000..f2247661e04 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx @@ -0,0 +1,193 @@ +import { Unsubscribable } from 'rxjs'; + +import { + SceneComponentProps, + SceneObjectState, + SceneObjectBase, + sceneGraph, + AdHocFiltersVariable, + GroupByVariable, + SceneQueryRunner, + VizPanel, +} from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { verifyDrilldownApplicability } from '../utils/drilldownUtils'; + +import { PanelNonApplicableDrilldownsSubHeader } from './PanelNonApplicableDrilldownsSubHeader'; + +interface ApplicabilitySupportHelperState { + datasource: DataSourceRef | null; + applicabilityEnabled?: boolean; +} + +export interface VizPanelSubHeaderState extends SceneObjectState { + hideNonApplicableDrilldowns?: boolean; + supportsApplicability?: boolean; +} + +export class VizPanelSubHeader extends SceneObjectBase { + static Component = VizPanelSubHeaderRenderer; + + private _adHocVar?: AdHocFiltersVariable; + private _groupByVar?: GroupByVariable; + + private _adHocSub?: Unsubscribable; + private _groupBySub?: Unsubscribable; + + private _queryRunnerDatasource?: DataSourceRef; + + constructor(state: Partial) { + super({ + hideNonApplicableDrilldowns: state.hideNonApplicableDrilldowns ?? false, + ...state, + }); + + this.addActivationHandler(this._onActivate); + } + + private _onActivate = () => { + if (!this.parent || !(this.parent instanceof VizPanel)) { + throw new Error('VizPanelSubHeader can be used only with VizPanel'); + } + + if (!this.state.hideNonApplicableDrilldowns) { + this.subscribeToDrilldownVariableChanges(); + } + + return () => { + this._groupBySub?.unsubscribe(); + this._adHocSub?.unsubscribe(); + }; + }; + + private subscribeToDrilldownVariableChanges() { + const vars = sceneGraph.getVariables(this); + const queryRunner = this.getQueryRunner(); + + this._adHocVar = vars.state.variables.find((variable) => variable instanceof AdHocFiltersVariable); + this._groupByVar = vars.state.variables.find((variable) => variable instanceof GroupByVariable); + this._queryRunnerDatasource = queryRunner?.state.datasource; + + this.setDrilldownApplicabilitySupportHelper(); + + // keep track of queryRunner datasource updates andupdate rendering + this._subs.add( + queryRunner?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource) { + this._queryRunnerDatasource = n.datasource; + + this.setDrilldownApplicabilitySupportHelper(); + } + }) + ); + + // check when var set updates and search for drilldown vars + this._subs.add( + vars.subscribeToState((n) => { + this._adHocVar = n.variables.find((variable) => variable instanceof AdHocFiltersVariable); + this._groupByVar = n.variables.find((variable) => variable instanceof GroupByVariable); + + this.refreshDrilldownVarsSubscriptions(); + }) + ); + + // adhoc sub so if that changes, we potentially update rendering + this._adHocSub = this._adHocVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setDrilldownApplicabilitySupportHelper({ + datasource: n.datasource, + applicabilityEnabled: n.applicabilityEnabled, + }); + } + }); + + // same for groupBy + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setDrilldownApplicabilitySupportHelper(undefined, { + datasource: n.datasource, + applicabilityEnabled: n.applicabilityEnabled, + }); + } + }); + } + + private refreshDrilldownVarsSubscriptions() { + if (this._groupByVar) { + this._groupBySub?.unsubscribe(); + this._groupByVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setDrilldownApplicabilitySupportHelper(undefined, { + datasource: n.datasource, + applicabilityEnabled: n.applicabilityEnabled, + }); + } + }); + } + + if (this._adHocVar) { + this._adHocVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setDrilldownApplicabilitySupportHelper({ + datasource: n.datasource, + applicabilityEnabled: n.applicabilityEnabled, + }); + } + }); + } + } + + private setDrilldownApplicabilitySupportHelper( + adHocData?: ApplicabilitySupportHelperState, + groupByData?: ApplicabilitySupportHelperState + ) { + this.setState({ + supportsApplicability: + verifyDrilldownApplicability( + this, + this._queryRunnerDatasource, + adHocData?.datasource ?? this._adHocVar?.state.datasource ?? null, + adHocData?.applicabilityEnabled ?? this._adHocVar?.state.applicabilityEnabled ?? false + ) || + verifyDrilldownApplicability( + this, + this._queryRunnerDatasource, + groupByData?.datasource ?? this._groupByVar?.state.datasource ?? null, + groupByData?.applicabilityEnabled ?? this._groupByVar?.state.applicabilityEnabled ?? false + ), + }); + } + + public getQueryRunner() { + const panel = this.parent; + const dataObject = panel ? sceneGraph.getData(panel) : undefined; + const queryRunner = dataObject?.state.$data; + + if (!queryRunner || !(queryRunner instanceof SceneQueryRunner)) { + return null; + } + + return queryRunner; + } +} + +export function VizPanelSubHeaderRenderer({ model }: SceneComponentProps) { + const { supportsApplicability, hideNonApplicableDrilldowns } = model.useState(); + const variables = sceneGraph.getVariables(model); + const adhocFiltersVar = variables.state.variables.find((variable) => variable instanceof AdHocFiltersVariable); + const groupByVar = variables.state.variables.find((variable) => variable instanceof GroupByVariable); + const queryRunner = model.getQueryRunner(); + + if (!queryRunner || hideNonApplicableDrilldowns || !supportsApplicability) { + return null; + } + + return ( + + ); +} diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts index 856f8df3193..9a70858f39b 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts @@ -1,4 +1,5 @@ import { LegacyGraphHoverClearEvent } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { behaviors, sceneGraph, SceneTimeRange } from '@grafana/scenes'; import { DashboardCursorSync } from '@grafana/schema'; import { appEvents } from 'app/core/app_events'; @@ -253,4 +254,146 @@ describe('setupKeyboardShortcuts', () => { expect(drBinding).toBeDefined(); }); }); + + describe('time range zoom shortcuts with feature toggle', () => { + describe('when newTimeRangeZoomShortcuts is enabled', () => { + beforeEach(() => { + config.featureToggles.newTimeRangeZoomShortcuts = true; + jest.clearAllMocks(); + }); + + it('should setup t + zoom in shortcut', () => { + setupKeyboardShortcuts(mockScene); + + const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +'); + expect(tPlusBinding).toBeDefined(); + }); + + it('should setup t - zoom out shortcut with keypress type', () => { + setupKeyboardShortcuts(mockScene); + + const tMinusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't -'); + expect(tMinusBinding).toBeDefined(); + expect(tMinusBinding![0].type).toBe('keypress'); + }); + + it('should not setup t z shortcut when feature toggle is on', () => { + setupKeyboardShortcuts(mockScene); + + const tzBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't z'); + expect(tzBinding).toBeUndefined(); + }); + }); + + describe('when newTimeRangeZoomShortcuts is disabled', () => { + beforeEach(() => { + config.featureToggles.newTimeRangeZoomShortcuts = false; + jest.clearAllMocks(); + }); + + it('should setup legacy t z shortcut', () => { + setupKeyboardShortcuts(mockScene); + + const tzBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't z'); + expect(tzBinding).toBeDefined(); + }); + + it('should not setup new zoom shortcuts when feature toggle is off', () => { + setupKeyboardShortcuts(mockScene); + + const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +'); + const tMinusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't -'); + + expect(tPlusBinding).toBeUndefined(); + expect(tMinusBinding).toBeUndefined(); + }); + }); + + describe('zoom handler logic', () => { + let mockTimeRange: ReturnType; + + function createMockTimeRange() { + return { + state: { + value: { + from: { valueOf: () => new Date('2024-01-01 12:00:00').getTime() }, + to: { valueOf: () => new Date('2024-01-01 18:00:00').getTime() }, // 6 hour span + raw: { from: 'now-6h', to: 'now' }, + }, + }, + onTimeRangeChange: jest.fn(), + } satisfies { + state: { + value: { + from: { valueOf: () => number }; + to: { valueOf: () => number }; + raw: { from: string; to: string }; + }; + }; + onTimeRangeChange: jest.Mock; + }; + } + + beforeEach(() => { + config.featureToggles.newTimeRangeZoomShortcuts = true; + mockTimeRange = createMockTimeRange(); + + (sceneGraph.getTimeRange as jest.Mock).mockReturnValue(mockTimeRange); + jest.clearAllMocks(); + }); + + it('should zoom in (scale 0.5) when t + is pressed', () => { + setupKeyboardShortcuts(mockScene); + + const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +'); + const handler = tPlusBinding![0].onTrigger; + + handler(); + + // Scale 0.5 should result in 3 hour span (half of 6) + expect(mockTimeRange.onTimeRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ + from: expect.any(Object), + to: expect.any(Object), + raw: expect.any(Object), + }) + ); + + const call = mockTimeRange.onTimeRangeChange.mock.calls[0][0]; + const newSpan = call.to.valueOf() - call.from.valueOf(); + expect(newSpan).toBe(3 * 60 * 60 * 1000); // 3 hours in milliseconds + }); + + it('should keep center point when zooming in', () => { + setupKeyboardShortcuts(mockScene); + + const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +'); + const handler = tPlusBinding![0].onTrigger; + + const originalCenter = (mockTimeRange.state.value.from.valueOf() + mockTimeRange.state.value.to.valueOf()) / 2; + + handler(); + + const call = mockTimeRange.onTimeRangeChange.mock.calls[0][0]; + const newCenter = (call.from.valueOf() + call.to.valueOf()) / 2; + + expect(newCenter).toBe(originalCenter); + }); + + it('should do nothing when timespan is zero', () => { + mockTimeRange.state.value.from.valueOf = () => new Date('2024-01-01 12:00:00').getTime(); + mockTimeRange.state.value.to.valueOf = () => new Date('2024-01-01 12:00:00').getTime(); // Same time + + setupKeyboardShortcuts(mockScene); + + const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +'); + const handler = tPlusBinding![0].onTrigger; + + handler(); + + // Should not call onTimeRangeChange when timespan is 0 + expect(mockTimeRange.onTimeRangeChange).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts index b6ebf75bf44..1113b8a8a8c 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { locationUtil, SetPanelAttentionEvent, LegacyGraphHoverClearEvent } from '@grafana/data'; +import { locationUtil, SetPanelAttentionEvent, LegacyGraphHoverClearEvent, dateTime } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; import { behaviors, sceneGraph, VizPanel } from '@grafana/scenes'; import { appEvents } from 'app/core/app_events'; @@ -130,13 +130,29 @@ export function setupKeyboardShortcuts(scene: DashboardScene) { onTrigger: () => sceneGraph.getTimeRange(scene).onRefresh(), }); - // Zoom out - keybindings.addBinding({ - key: 't z', - onTrigger: () => { - handleZoomOut(scene); - }, - }); + if (config.featureToggles.newTimeRangeZoomShortcuts) { + keybindings.addBinding({ + key: 't +', + onTrigger: () => { + handleZoom(scene, 0.5); + }, + }); + + keybindings.addBinding({ + key: 't -', + type: 'keypress', // NOTE: Because some browsers/OS identify minus symbol differently. + onTrigger: () => { + handleZoomOut(scene); + }, + }); + } else { + keybindings.addBinding({ + key: 't z', + onTrigger: () => { + handleZoomOut(scene); + }, + }); + } keybindings.addBinding({ key: 'ctrl+z', @@ -266,6 +282,31 @@ export function setupKeyboardShortcuts(scene: DashboardScene) { }; } +function handleZoom(scene: DashboardScene, scale: number) { + const timeRange = sceneGraph.getTimeRange(scene); + const currentRange = timeRange.state.value; + const timespan = currentRange.to.valueOf() - currentRange.from.valueOf(); + + if (timespan === 0) { + return; + } + + const center = currentRange.to.valueOf() - timespan / 2; + const newTimespan = timespan * scale; + + const to = center + newTimespan / 2; + const from = center - newTimespan / 2; + + timeRange.onTimeRangeChange({ + from: dateTime(from), + to: dateTime(to), + raw: { + from: dateTime(from), + to: dateTime(to), + }, + }); +} + function handleZoomOut(scene: DashboardScene) { const timePicker = dashboardSceneGraph.getTimePicker(scene); timePicker?.onZoom(); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 566bb7c367a..512631961d3 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -31,6 +31,7 @@ import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; import { PanelNotices } from '../../scene/PanelNotices'; +import { VizPanelSubHeader } from '../../scene/VizPanelSubHeader'; import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem'; import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem'; import { PanelTimeRange } from '../../scene/panel-timerange/PanelTimeRange'; @@ -70,6 +71,9 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel { seriesLimit: config.panelSeriesLimit, $data: createPanelDataProvider(panel), titleItems, + subHeader: new VizPanelSubHeader({ + hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, + }), $behaviors: [], extendPanelContext: setDashboardPanelContext, }; @@ -106,6 +110,9 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane const vizPanelState: VizPanelState = { key: getVizPanelKeyForPanelId(id ?? panel.spec.id), titleItems, + subHeader: new VizPanelSubHeader({ + hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, + }), seriesLimit: config.panelSeriesLimit, $behaviors: [ new LibraryPanelBehavior({ diff --git a/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts b/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts new file mode 100644 index 00000000000..178c89e1da4 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/serialization-test-utils.ts @@ -0,0 +1,96 @@ +import { + Spec as DashboardV2Spec, + GridLayoutItemKind, + RowsLayoutRowKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; + +/** + * Normalizes backend output to match frontend behavior. + * The backend sets repeat properties on library panel grid items from the library panel definition, + * but the frontend only sets repeat when explicitly set on the panel instance. + * This function removes repeat properties from library panel items where they weren't set on the instance. + * + * The difference in behavior is due to how the frontend conversion is done. + * It is not feasible to fetch all library panels async in all cases where the transformation is done. + * Library panel repeats will be set by the library panel behavior in those cases. + * + * This is a temporary solution until public dashboards and scripted dashboards have proper backend conversions. + */ +export function normalizeBackendOutputForFrontendComparison( + backendSpec: DashboardV2Spec, + inputPanels: Array<{ + id?: number; + libraryPanel?: { uid?: string }; + repeat?: string; + gridPos?: { w?: number; h?: number; x?: number; y?: number }; + }> +): DashboardV2Spec { + // Deep clone the spec to avoid mutating the original + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const normalized = JSON.parse(JSON.stringify(backendSpec)) as DashboardV2Spec; + + // Create a map of panel ID to whether it has explicit repeat, original width, and if it's a library panel + const panelHasExplicitRepeat = new Map(); + const panelOriginalWidth = new Map(); + const panelIsLibraryPanel = new Map(); + inputPanels.forEach((panel) => { + if (panel.id !== undefined) { + panelHasExplicitRepeat.set(panel.id, !!panel.repeat); + panelIsLibraryPanel.set(panel.id, !!panel.libraryPanel); + if (panel.gridPos?.w !== undefined) { + panelOriginalWidth.set(panel.id, panel.gridPos.w); + } + } + }); + + // Helper to recursively process grid items + function processGridItems(items: GridLayoutItemKind[]): void { + if (!Array.isArray(items)) { + return; + } + + items.forEach((item) => { + if (item.spec?.element?.name) { + // Extract panel ID from element name (format: "panel-{id}") + const match = item.spec.element.name.match(/^panel-(\d+)$/); + if (match) { + const panelId = parseInt(match[1], 10); + const hasExplicitRepeat = panelHasExplicitRepeat.get(panelId); + const isLibraryPanel = panelIsLibraryPanel.get(panelId); + + // Only normalize library panels - check both input panel and element kind + const element = normalized.elements?.[item.spec.element.name]; + const isElementLibraryPanel = element?.kind === 'LibraryPanel'; + + // If this is a library panel item and repeat wasn't explicitly set on the instance, + // remove the repeat property (backend adds it from library panel definition) + // Also restore the original width when removing repeat properties + if ((isLibraryPanel || isElementLibraryPanel) && hasExplicitRepeat === false && item.spec.repeat) { + delete item.spec.repeat; + // Always restore the original width from the input panel + const originalWidth = panelOriginalWidth.get(panelId); + if (originalWidth !== undefined) { + item.spec.width = originalWidth; + } + } + } + } + }); + } + + // Process GridLayout items + if (normalized.layout?.kind === 'GridLayout' && normalized.layout.spec?.items) { + processGridItems(normalized.layout.spec.items); + } + + // Process RowsLayout items + if (normalized.layout?.kind === 'RowsLayout' && normalized.layout.spec?.rows) { + normalized.layout.spec.rows.forEach((row: RowsLayoutRowKind) => { + if (row.spec?.layout?.kind === 'GridLayout' && row.spec.layout.spec?.items) { + processGridItems(row.spec.layout.spec.items); + } + }); + } + + return normalized; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 4e9e8f7fc81..0534ad76e56 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -319,7 +319,9 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S defaultKeys: variable.spec.defaultKeys, useQueriesAsFilterForOptions: true, layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined, - supportsMultiValueOperators: Boolean(getDataSourceSrv().getInstanceSettings(ds)?.meta.multiValueFilterOperators), + supportsMultiValueOperators: Boolean( + getDataSourceSrv().getInstanceSettings({ type: ds.type })?.meta.multiValueFilterOperators + ), }); } if (variable.kind === defaultCustomVariableKind().kind) { @@ -517,7 +519,7 @@ export function createVariablesForSnapshot(dashboard: DashboardV2Spec): SceneVar useQueriesAsFilterForOptions: true, layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined, supportsMultiValueOperators: Boolean( - getDataSourceSrv().getInstanceSettings(ds)?.meta.multiValueFilterOperators + getDataSourceSrv().getInstanceSettings({ type: ds.type })?.meta.multiValueFilterOperators ), }); } diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 25cb5d55e44..41dcc938222 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -45,6 +45,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { PanelNotices } from '../scene/PanelNotices'; +import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; @@ -440,6 +441,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { hoverHeaderOffset: 0, $data: createPanelDataProvider(panel), titleItems, + subHeader: new VizPanelSubHeader({ + hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, + }), $behaviors: [], extendPanelContext: setDashboardPanelContext, _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts index 9838f889a6d..9dbf8e36f0a 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; +import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; @@ -229,8 +230,17 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { expect(frontendOutput).toBeDefined(); expect(backendOutputAfterLoadedByScene).toBeDefined(); + // Normalize backend output to account for differences in library panel repeat handling + // Backend sets repeat from library panel definition, frontend only sets it when explicit on instance + // For migrated dashboards, panels are in the root level, not in spec.panels + const inputPanels = jsonInput.panels || jsonInput.spec?.panels || []; + const normalizedBackendOutput = normalizeBackendOutputForFrontendComparison( + backendOutputAfterLoadedByScene, + inputPanels + ); + // Compare only the spec structures - this is the core transformation - expect(backendOutputAfterLoadedByScene).toEqual(frontendOutput); + expect(normalizedBackendOutput).toEqual(frontendOutput); }); }); @@ -292,8 +302,17 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { expect(frontendOutput).toBeDefined(); expect(backendOutputAfterLoadedByScene).toBeDefined(); + // Normalize backend output to account for differences in library panel repeat handling + // Backend sets repeat from library panel definition, frontend only sets it when explicit on instance + // For migrated dashboards, panels are in the root level, not in spec.panels + const inputPanels = jsonInput.panels || jsonInput.spec?.panels || []; + const normalizedBackendOutput = normalizeBackendOutputForFrontendComparison( + backendOutputAfterLoadedByScene, + inputPanels + ); + // Compare only the spec structures - this is the core transformation - expect(backendOutputAfterLoadedByScene).toEqual(frontendOutput); + expect(normalizedBackendOutput).toEqual(frontendOutput); }); }); }); diff --git a/public/app/features/dashboard-scene/utils/drilldownUtils.test.ts b/public/app/features/dashboard-scene/utils/drilldownUtils.test.ts new file mode 100644 index 00000000000..eb8a30baea3 --- /dev/null +++ b/public/app/features/dashboard-scene/utils/drilldownUtils.test.ts @@ -0,0 +1,236 @@ +import type { DrilldownsApplicability } from '@grafana/data'; +import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + GroupByVariable, + SceneQueryRunner, + SceneDataTransformer, + SceneVariableSet, + VizPanel, + VizPanelState, +} from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; + +import { getDrilldownApplicability, verifyDrilldownApplicability } from './drilldownUtils'; + +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + return { + ...actual, + getDataSourceSrv: jest.fn(), + }; +}); + +const getDataSourceSrvMock = getDataSourceSrv as jest.MockedFunction; + +// Helper to create partial DataSourceSrv mock +const createMockDataSourceSrv = (overrides: Partial = {}): DataSourceSrv => ({ + get: jest.fn(), + getList: jest.fn(), + getInstanceSettings: jest.fn(), + reload: jest.fn(), + registerRuntimeDataSource: jest.fn(), + ...overrides, +}); + +describe('verifyDrilldownApplicability', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns true when applicability is enabled and datasources match', () => { + const subHeader = new VizPanelSubHeader({}); + + const result = verifyDrilldownApplicability( + subHeader, + { uid: 'ds-1' } as DataSourceRef, + { uid: 'ds-1' } as DataSourceRef, + true + ); + + expect(result).toBe(true); + }); + + it('returns false when datasources differ or applicability disabled', () => { + const subHeader = new VizPanelSubHeader({}); + + const mismatch = verifyDrilldownApplicability( + subHeader, + { uid: 'ds-1' } as DataSourceRef, + { uid: 'ds-2' } as DataSourceRef, + true + ); + + expect(mismatch).toBe(false); + + const disabled = verifyDrilldownApplicability( + subHeader, + { uid: 'ds-1' } as DataSourceRef, + { uid: 'ds-1' } as DataSourceRef, + false + ); + + expect(disabled).toBe(false); + }); +}); + +describe('getDrilldownApplicability', () => { + beforeEach(() => { + const applicabilityFn = jest.fn().mockResolvedValue([]); + getDataSourceSrvMock.mockReturnValue( + createMockDataSourceSrv({ + get: jest.fn().mockResolvedValue({ getDrilldownsApplicability: applicabilityFn }), + getInstanceSettings: jest.fn(), + }) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns undefined when no drilldown variables are provided', async () => { + const queryRunner = new SceneQueryRunner({ + datasource: { uid: 'ds-1' }, + queries: [{ refId: 'A' }], + }); + + const result = await getDrilldownApplicability(queryRunner); + + expect(result).toBeUndefined(); + expect(getDataSourceSrvMock).not.toHaveBeenCalled(); + }); + + it('returns undefined when datasource lacks getDrilldownsApplicability', async () => { + getDataSourceSrvMock.mockReturnValue( + createMockDataSourceSrv({ + get: jest.fn().mockResolvedValue({}), + getInstanceSettings: jest.fn(), + }) + ); + + const { queryRunner, groupByVariable } = buildScene(); + + const result = await getDrilldownApplicability(queryRunner, undefined, groupByVariable); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when drilldown variables use a different datasource', async () => { + const applicabilityFn = jest.fn().mockResolvedValue([]); + getDataSourceSrvMock.mockReturnValue( + createMockDataSourceSrv({ + get: jest.fn().mockResolvedValue({ getDrilldownsApplicability: applicabilityFn }), + getInstanceSettings: jest.fn(), + }) + ); + + const { queryRunner, adhocFiltersVariable, groupByVariable } = buildScene({ + datasourceUid: 'ds-query', + filtersDatasourceUid: 'ds-other', + groupByDatasourceUid: 'ds-other', + }); + + const result = await getDrilldownApplicability(queryRunner, adhocFiltersVariable, groupByVariable); + + expect(result).toBeUndefined(); + expect(applicabilityFn).not.toHaveBeenCalled(); + }); + + it('returns applicability data when datasource and variables align', async () => { + const applicability: DrilldownsApplicability[] = [{ key: 'region', applicable: true }]; + const getApplicability = jest.fn().mockResolvedValue(applicability); + getDataSourceSrvMock.mockReturnValue( + createMockDataSourceSrv({ + get: jest.fn().mockResolvedValue({ getDrilldownsApplicability: getApplicability }), + getInstanceSettings: jest.fn(), + }) + ); + + const { queryRunner, adhocFiltersVariable, groupByVariable } = buildScene({ + datasourceUid: 'ds-apply', + filtersDatasourceUid: 'ds-apply', + groupByDatasourceUid: 'ds-apply', + groupValues: ['region', 'instance'], + }); + + const result = await getDrilldownApplicability(queryRunner, adhocFiltersVariable, groupByVariable); + + expect(getApplicability).toHaveBeenCalledTimes(1); + const payload = getApplicability.mock.calls[0][0]; + expect(payload.groupByKeys).toEqual(['region', 'instance']); + expect(payload.filters).toEqual([]); + expect(payload.queries).toBe(queryRunner.state.data?.request?.targets); + expect(payload.timeRange).toBeDefined(); + expect(result).toBe(applicability); + }); +}); + +interface BuildSceneOptions { + datasourceUid?: string; + groupByDatasourceUid?: string; + filtersDatasourceUid?: string; + groupValues?: Array; +} + +function buildScene({ + datasourceUid = 'ds-1', + groupByDatasourceUid = datasourceUid, + filtersDatasourceUid = datasourceUid, + groupValues = ['groupBy'], +}: BuildSceneOptions = {}) { + const subHeader = new VizPanelSubHeader({}); + + const queryRunner = new SceneQueryRunner({ + datasource: { uid: datasourceUid }, + queries: [{ refId: 'A', datasource: { uid: datasourceUid } }], + }); + + const adhocFiltersVariable = new AdHocFiltersVariable({ + name: 'adhoc', + label: 'adhoc', + filters: [], + datasource: { uid: filtersDatasourceUid }, + applicabilityEnabled: true, + }); + + const groupByVariable = new GroupByVariable({ + name: 'group', + label: 'group', + value: groupValues, + text: groupValues.map((val) => String(val)), + options: [], + applicabilityEnabled: true, + datasource: { uid: groupByDatasourceUid }, + }); + + const dataProvider = new SceneDataTransformer({ + $data: queryRunner, + transformations: [], + }); + + const panelState: VizPanelState = { + key: 'panel-1', + title: 'Panel A', + pluginId: 'timeseries', + subHeader, + $data: dataProvider, + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + const panel = new VizPanel(panelState); + + new DashboardScene({ + $variables: new SceneVariableSet({ + variables: [groupByVariable, adhocFiltersVariable], + }), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + }); + + return { queryRunner, adhocFiltersVariable, groupByVariable }; +} diff --git a/public/app/features/dashboard-scene/utils/drilldownUtils.ts b/public/app/features/dashboard-scene/utils/drilldownUtils.ts new file mode 100644 index 00000000000..2ff0ecf7c6e --- /dev/null +++ b/public/app/features/dashboard-scene/utils/drilldownUtils.ts @@ -0,0 +1,75 @@ +import { DrilldownsApplicability } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { AdHocFiltersVariable, GroupByVariable, sceneGraph, SceneObject, SceneQueryRunner } from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +export function verifyDrilldownApplicability( + sourceObject: SceneObject, + queriesDataSource: DataSourceRef | undefined, + drilldownDatasource: DataSourceRef | null, + isApplicabilityEnabled?: boolean +): boolean { + const datasourceUid = sceneGraph.interpolate(sourceObject, queriesDataSource?.uid); + + return Boolean( + isApplicabilityEnabled && datasourceUid === sceneGraph.interpolate(sourceObject, drilldownDatasource?.uid) + ); +} + +export async function getDrilldownApplicability( + queryRunner: SceneQueryRunner, + filtersVar?: AdHocFiltersVariable, + groupByVar?: GroupByVariable +): Promise { + //if no drilldown vars return + if (!filtersVar && !groupByVar) { + return; + } + + const datasource = queryRunner.state.datasource; + const queries = queryRunner.state.data?.request?.targets; + + const ds = await getDataSourceSrv().get(datasource?.uid); + + // return if method not implemented + if (!ds.getDrilldownsApplicability) { + return; + } + + const dsUid = sceneGraph.interpolate(queryRunner, datasource?.uid); + const timeRange = sceneGraph.getTimeRange(queryRunner).state.value; + const groupByKeys = []; + const filters = []; + + const hasGroupByApplicability = + groupByVar && dsUid === sceneGraph.interpolate(groupByVar, groupByVar?.state.datasource?.uid); + const hasFiltersApplicability = + filtersVar && dsUid === sceneGraph.interpolate(filtersVar, filtersVar.state?.datasource?.uid); + + // if neither vars use the ds from the queries, return + if (!hasGroupByApplicability && !hasFiltersApplicability) { + return; + } + + if (hasGroupByApplicability) { + groupByKeys.push( + ...(Array.isArray(groupByVar.state.value) + ? groupByVar.state.value.map((v) => String(v)) + : groupByVar.state.value + ? [String(groupByVar.state.value)] + : []) + ); + } + + if (hasFiltersApplicability) { + filters.push(...filtersVar.state.filters, ...(filtersVar.state.originFilters ?? [])); + } + + return await ds.getDrilldownsApplicability({ + groupByKeys, + filters, + queries, + timeRange, + scopes: sceneGraph.getScopes(queryRunner), + }); +} diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 36ccabdca23..7188756b055 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -24,6 +24,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel'; +import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; @@ -95,6 +96,7 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP return null; } + export function findEditPanel(scene: SceneObject, key: string | undefined): VizPanel | null { if (!key) { return null; @@ -257,8 +259,15 @@ export function getClosestVizPanel(sceneObject: SceneObject): VizPanel | null { } export function getDefaultVizPanel(): VizPanel { - const defaultPluginId = config.featureToggles.dashboardNewLayouts ? UNCONFIGURED_PANEL_PLUGIN_ID : 'timeseries'; - const newPanelTitle = t('dashboard.new-panel-title', 'New panel'); + const defaultPluginId = + config.featureToggles.dashboardNewLayouts || config.featureToggles.newVizSuggestions + ? UNCONFIGURED_PANEL_PLUGIN_ID + : 'timeseries'; + + const newPanelTitle = + config.featureToggles.newVizSuggestions && defaultPluginId === UNCONFIGURED_PANEL_PLUGIN_ID + ? '' + : t('dashboard.new-panel-title', 'New panel'); return new VizPanel({ title: newPanelTitle, @@ -267,6 +276,9 @@ export function getDefaultVizPanel(): VizPanel { titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], hoverHeaderOffset: 0, $behaviors: [], + subHeader: new VizPanelSubHeader({ + hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, + }), extendPanelContext: setDashboardPanelContext, menu: new VizPanelMenu({ $behaviors: [panelMenuBehavior], diff --git a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts index d818e4953a3..45a310403e4 100644 --- a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts @@ -3,6 +3,7 @@ import path from 'path'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; +import { normalizeBackendOutputForFrontendComparison } from 'app/features/dashboard-scene/serialization/serialization-test-utils'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ensureV2Response } from './ResponseTransformers'; @@ -205,8 +206,13 @@ describe('Backend / Frontend result comparison', () => { expect(frontendOutput.spec).toBeDefined(); expect(backendOutput.spec).toBeDefined(); + // Normalize backend output to account for differences in library panel repeat handling + // Backend sets repeat from library panel definition, frontend only sets it when explicit on instance + const inputPanels = jsonInput.spec?.panels || []; + const normalizedBackendSpec = normalizeBackendOutputForFrontendComparison(backendOutput.spec, inputPanels); + // Compare the spec structures - expect(backendOutput.spec).toEqual(frontendOutput.spec); + expect(normalizedBackendSpec).toEqual(frontendOutput.spec); // Verify the conversion doesn't throw errors and produces a valid structure expect(() => JSON.stringify(frontendOutput)).not.toThrow(); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index f8bd275930b..79678f4e15a 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -475,7 +475,6 @@ export class Explore extends PureComponent { onStopScanning={this.onStopScanning} eventBus={this.logsEventBus} splitOpenFn={this.splitOpenFnLogs} - scrollElement={this.scrollElement} isFilterLabelActive={this.isFilterLabelActive} onClickFilterString={this.onClickFilterString} onClickFilterOutString={this.onClickFilterOutString} diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index 958a8c589f8..93836280762 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -65,6 +65,8 @@ describe('Logs', () => { let originalHref = window.location.href; beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + window.HTMLElement.prototype.scroll = jest.fn(); localStorage.clear(); jest.clearAllMocks(); }); @@ -128,9 +130,7 @@ describe('Logs', () => { to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} - addResultsToCache={() => {}} onChangeTime={() => {}} - clearCache={() => {}} getFieldLinks={() => { return []; }} @@ -160,39 +160,6 @@ describe('Logs', () => { return { ...rendered, store: fakeStore }; }; - describe('scrolling behavior', () => { - let originalInnerHeight: number; - beforeEach(() => { - originalInnerHeight = window.innerHeight; - window.innerHeight = 1000; - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scroll = jest.fn(); - }); - afterEach(() => { - window.innerHeight = originalInnerHeight; - }); - - it('should call `scrollElement.scroll`', () => { - const logs = []; - for (let i = 0; i < 50; i++) { - logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i })); - } - const scrollElementMock = { - scroll: jest.fn(), - scrollTop: 920, - }; - setup( - { scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } }, - undefined, - logs - ); - - // element.getBoundingClientRect().top will always be 0 for jsdom - // calc will be `scrollElement.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420 - expect(scrollElementMock.scroll).toBeCalledWith({ behavior: 'smooth', top: 420 }); - }); - }); - it('should render logs', () => { setup(); const logsSection = screen.getByTestId('logRows'); @@ -246,9 +213,7 @@ describe('Logs', () => { to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} - addResultsToCache={() => {}} onChangeTime={() => {}} - clearCache={() => {}} getFieldLinks={() => { return []; }} @@ -296,9 +261,7 @@ describe('Logs', () => { to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} - addResultsToCache={() => {}} onChangeTime={() => {}} - clearCache={() => {}} getFieldLinks={() => { return []; }} @@ -349,9 +312,7 @@ describe('Logs', () => { to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} - addResultsToCache={() => {}} onChangeTime={() => {}} - clearCache={() => {}} getFieldLinks={() => { return []; }} @@ -412,22 +373,6 @@ describe('Logs', () => { expect(fakeChangePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); }); - it('should scroll the scrollElement into view if rows contain id', () => { - const panelState = { logs: { id: '3' } }; - const scrollElementMock = { scroll: jest.fn() }; - setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState }); - - expect(scrollElementMock.scroll).toHaveBeenCalled(); - }); - - it('should not scroll the scrollElement into view if rows does not contain id', () => { - const panelState = { logs: { id: 'not-included' } }; - const scrollElementMock = { scroll: jest.fn() }; - setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState }); - - expect(scrollElementMock.scroll).not.toHaveBeenCalled(); - }); - it('should call reportInteraction on permalinkClick', async () => { const panelState = { logs: { id: 'not-included' } }; const rows = [ @@ -479,8 +424,6 @@ describe('Logs', () => { }); it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => { - const featureToggleValue = config.featureToggles.logsInfiniteScrolling; - config.featureToggles.logsInfiniteScrolling = true; const rows = [ makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }), @@ -503,7 +446,6 @@ describe('Logs', () => { ) ); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); - config.featureToggles.logsInfiniteScrolling = featureToggleValue; }); }); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 7820e5eb9e9..02b63224e76 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -119,11 +119,8 @@ interface Props extends Themeable2 { ) => Promise; getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode; getFieldLinks: GetFieldLinksFn; - addResultsToCache: () => void; - clearCache: () => void; eventBus: EventBus; panelState?: ExplorePanelsState; - scrollElement?: HTMLDivElement; isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; logsFrames?: DataFrame[]; range: TimeRange; @@ -183,8 +180,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { getFieldLinks, theme, logsQueries, - clearCache, - addResultsToCache, exploreId, getRowContext, getLogRowContextUi, @@ -193,7 +188,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { panelState, eventBus, onPinLineCallback, - scrollElement, } = props; const [showLabels, setShowLabels] = useState(store.getBool(SETTINGS_KEYS.showLabels, false)); const [showTime, setShowTime] = useState(store.getBool(SETTINGS_KEYS.showTime, true)); @@ -365,29 +359,17 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { [props.eventBus] ); - const scrollIntoView = useCallback( - (element: HTMLElement) => { - if (config.featureToggles.logsInfiniteScrolling) { - if (logsContainerRef.current) { - topLogsRef.current?.scrollIntoView(); - logsContainerRef.current.scroll({ - behavior: 'smooth', - top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, - }); - } + const scrollIntoView = useCallback((element: HTMLElement) => { + if (logsContainerRef.current) { + topLogsRef.current?.scrollIntoView?.(); + logsContainerRef.current.scroll({ + behavior: 'smooth', + top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, + }); + } - return; - } - - if (scrollElement) { - scrollElement.scroll({ - behavior: 'smooth', - top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, - }); - } - }, - [scrollElement] - ); + return; + }, []); const sortOrderChanged = useCallback( (newSortOrder: LogsSortOrder) => { @@ -626,13 +608,11 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const scrollToTopLogs = useCallback(() => { - if (config.featureToggles.logsInfiniteScrolling) { - if (logsContainerRef.current) { - logsContainerRef.current.scroll({ - behavior: 'auto', - top: 0, - }); - } + if (logsContainerRef.current) { + logsContainerRef.current.scroll({ + behavior: 'auto', + top: 0, + }); } topLogsRef.current?.scrollIntoView(); }, []); @@ -684,7 +664,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const { dedupedRows, dedupCount } = useMemo(() => dedupRows(logRows, dedupStrategy), [dedupStrategy, logRows]); - const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]); const infiniteScrollAvailable = useMemo( () => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan), [logsQueries] @@ -795,6 +774,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { @@ -1060,11 +1040,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { visualisationType === 'logs' && hasData && ( <> -
+
= (props: Props) => { />
- + )} {config.featureToggles.newLogsPanel && visualisationType === 'logs' && ( @@ -1270,14 +1235,14 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n visualisationType: css({ display: 'flex', flex: '1', - justifyContent: 'space-between', + justifyContent: 'flex-end', }), visualisationTypeRadio: css({ margin: `0 0 0 ${theme.spacing(1)}`, }), stickyNavigation: css({ overflow: 'visible', - ...(config.featureToggles.logsInfiniteScrolling && { marginBottom: '0px' }), + marginBottom: '0px', }), logsVolumePanel: css({ marginBottom: theme.spacing(1.5), @@ -1290,17 +1255,3 @@ const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0); return { dedupedRows, dedupCount }; }; - -const createNavigationRange = (logRows: LogRowModel[]): { from: number; to: number } | undefined => { - if (!logRows || logRows.length === 0) { - return undefined; - } - const firstTimeStamp = logRows[0].timeEpochMs; - const lastTimeStamp = logRows[logRows.length - 1].timeEpochMs; - - if (lastTimeStamp < firstTimeStamp) { - return { from: lastTimeStamp, to: firstTimeStamp }; - } - - return { from: firstTimeStamp, to: lastTimeStamp }; -}; diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 13bc78b5849..f93d66c6446 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -31,13 +31,7 @@ import { ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types/store'; import { getTimeZone } from '../../profile/state/selectors'; -import { - addResultsToCache, - clearCache, - loadSupplementaryQueryData, - selectIsWaitingForData, - setSupplementaryQueryEnabled, -} from '../state/query'; +import { loadSupplementaryQueryData, selectIsWaitingForData, setSupplementaryQueryEnabled } from '../state/query'; import { updateTimeRange, loadMoreLogs } from '../state/time'; import { LiveTailControls } from '../useLiveTailControls'; import { getFieldLinksForExplore } from '../utils/links'; @@ -58,7 +52,6 @@ interface LogsContainerProps extends PropsFromRedux { onStopScanning: () => void; eventBus: EventBus; splitOpenFn: SplitOpen; - scrollElement?: HTMLDivElement; isFilterLabelActive: (key: string, value: string, refId?: string) => Promise; onClickFilterString: (value: string, refId?: string) => void; onClickFilterOutString: (value: string, refId?: string) => void; @@ -260,14 +253,6 @@ class LogsContainer extends PureComponent { - this.props.addResultsToCache(this.props.exploreId); - }; - - clearCache = () => { - this.props.clearCache(this.props.exploreId); - }; - loadLogsVolumeData = () => { this.props.loadSupplementaryQueryData(this.props.exploreId, SupplementaryQueryType.LogsVolume); }; @@ -298,7 +283,6 @@ class LogsContainer extends PureComponent ({ type LogsNavigationProps = ComponentProps; const defaultProps: LogsNavigationProps = { - absoluteRange: { from: 1637319381811, to: 1637322981811 }, - timeZone: 'local', - queries: [], - loading: false, logsSortOrder: undefined, - visibleRange: { from: 1637322959000, to: 1637322981811 }, - onChangeTime: jest.fn(), scrollToTopLogs: jest.fn(), - addResultsToCache: jest.fn(), - clearCache: jest.fn(), }; const setup = (propOverrides?: Partial) => { @@ -37,132 +26,13 @@ const setup = (propOverrides?: Partial) => { }; describe('LogsNavigation', () => { - it('should always render 3 navigation buttons', () => { + it('should render scroll to top with default logs order', async () => { setup(); - expect(screen.getByTestId('newerLogsButton')).toBeInTheDocument(); - expect(screen.getByTestId('olderLogsButton')).toBeInTheDocument(); + expect(screen.getByTestId('scrollToTop')).toBeInTheDocument(); - }); - it('should render 3 navigation buttons in correct order when default logs order', () => { - const { container } = setup(); - const expectedOrder = ['newerLogsButton', 'olderLogsButton', 'scrollToTop']; - const elements = container.querySelectorAll( - '[data-testid=newerLogsButton],[data-testid=olderLogsButton],[data-testid=scrollToTop]' - ); - expect(Array.from(elements).map((el) => el.getAttribute('data-testid'))).toMatchObject(expectedOrder); - }); + await userEvent.click(screen.getByTestId('scrollToTop')); - it('should render 3 navigation buttons in correct order when flipped logs order', () => { - const { container } = setup({ logsSortOrder: LogsSortOrder.Ascending }); - const expectedOrder = ['olderLogsButton', 'newerLogsButton', 'scrollToTop']; - const elements = container.querySelectorAll( - '[data-testid=newerLogsButton],[data-testid=olderLogsButton],[data-testid=scrollToTop]' - ); - expect(Array.from(elements).map((el) => el.getAttribute('data-testid'))).toMatchObject(expectedOrder); - }); - - it('should disable fetch buttons when logs are loading', () => { - setup({ loading: true }); - const olderLogsButton = screen.getByTestId('olderLogsButton'); - const newerLogsButton = screen.getByTestId('newerLogsButton'); - expect(olderLogsButton).toBeDisabled(); - expect(newerLogsButton).toBeDisabled(); - }); - - it('should render logs navigation pages section', () => { - setup(); - expect(screen.getByTestId('logsNavigationPages')).toBeInTheDocument(); - }); - - it('should correctly request older logs when flipped order', async () => { - const onChangeTimeMock = jest.fn(); - const { rerender } = setup({ onChangeTime: onChangeTimeMock }); - await userEvent.click(screen.getByTestId('olderLogsButton')); - expect(onChangeTimeMock).toHaveBeenCalledWith({ from: 1637319359000, to: 1637322959000 }); - - rerender( - - ); - await userEvent.click(screen.getByTestId('olderLogsButton')); - expect(onChangeTimeMock).toHaveBeenCalledWith({ from: 1637319338000, to: 1637322938000 }); - }); - - it('should correctly display the active page', async () => { - const queries: DataQuery[] = []; - const { rerender } = setup({ - absoluteRange: { from: 1704737384139, to: 1704737684139 }, - visibleRange: { from: 1704737384207, to: 1704737683316 }, - queries, - logsSortOrder: LogsSortOrder.Descending, - }); - - expect(await screen.findByTestId('page1')).toBeInTheDocument(); - expect(screen.getByTestId('page1').firstChild).toHaveClass('selectedBg'); - - expect(screen.queryByTestId('page2')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByTestId('olderLogsButton')); - - rerender( - - ); - - expect(await screen.findByTestId('page1')).toBeInTheDocument(); - expect(screen.getByTestId('page1').firstChild).not.toHaveClass('selectedBg'); - - expect(await screen.findByTestId('page2')).toBeInTheDocument(); - expect(screen.getByTestId('page2').firstChild).toHaveClass('selectedBg'); - - expect(screen.queryByTestId('page3')).not.toBeInTheDocument(); - }); - - it('should reset the scroll when pagination is clicked', async () => { - const scrollToTopLogsMock = jest.fn(); - setup({ scrollToTopLogs: scrollToTopLogsMock }); - - expect(scrollToTopLogsMock).not.toHaveBeenCalled(); - await userEvent.click(screen.getByTestId('olderLogsButton')); - expect(scrollToTopLogsMock).toHaveBeenCalled(); - }); - - it('should not trigger actions while loading', async () => { - const scrollToTopLogs = jest.fn(); - const changeTimeMock = jest.fn(); - setup({ scrollToTopLogs, onChangeTime: changeTimeMock, loading: true }); - - expect(scrollToTopLogs).not.toHaveBeenCalled(); - expect(changeTimeMock).not.toHaveBeenCalled(); - await userEvent.click(screen.getByTestId('olderLogsButton')); - await userEvent.click(screen.getByTestId('newerLogsButton')); - expect(scrollToTopLogs).not.toHaveBeenCalled(); - expect(changeTimeMock).not.toHaveBeenCalled(); - }); - - it('should not add results to cache unless pagination is used', async () => { - const addResultsToCache = jest.fn(); - setup({ addResultsToCache }); - - expect(addResultsToCache).not.toHaveBeenCalled(); - expect(screen.getByTestId('olderLogsButton')).not.toBeDisabled(); - expect(screen.getByTestId('newerLogsButton')).toBeDisabled(); - - await userEvent.click(screen.getByTestId('olderLogsButton')); - await userEvent.click(screen.getByTestId('newerLogsButton')); - - expect(addResultsToCache).toHaveBeenCalledTimes(1); + expect(defaultProps.scrollToTopLogs).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/features/explore/Logs/LogsNavigation.tsx b/public/app/features/explore/Logs/LogsNavigation.tsx index 47a092b7138..b9b1c0c2c97 100644 --- a/public/app/features/explore/Logs/LogsNavigation.tsx +++ b/public/app/features/explore/Logs/LogsNavigation.tsx @@ -1,220 +1,30 @@ import { css } from '@emotion/css'; -import { isEqual } from 'lodash'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback } from 'react'; -import { AbsoluteTimeRange, GrafanaTheme2, LogsSortOrder } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; +import { GrafanaTheme2, LogsSortOrder } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; -import { DataQuery, TimeZone } from '@grafana/schema'; -import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui'; +import { Button, Icon, useTheme2 } from '@grafana/ui'; import { getChromeHeaderLevelHeight } from 'app/core/components/AppChrome/TopBar/useChromeHeaderHeight'; -import { LogsNavigationPages } from './LogsNavigationPages'; - type Props = { - absoluteRange: AbsoluteTimeRange; - timeZone: TimeZone; - queries: DataQuery[]; - loading: boolean; - visibleRange: AbsoluteTimeRange; logsSortOrder?: LogsSortOrder | null; - onChangeTime: (range: AbsoluteTimeRange) => void; scrollToTopLogs: () => void; scrollToBottomLogs?: () => void; - addResultsToCache: () => void; - clearCache: () => void; }; -export type LogsPage = { - logsRange: AbsoluteTimeRange; - queryRange: AbsoluteTimeRange; -}; - -function LogsNavigation({ - absoluteRange, - logsSortOrder, - timeZone, - loading, - onChangeTime, - scrollToTopLogs, - scrollToBottomLogs, - visibleRange, - queries, - clearCache, - addResultsToCache, -}: Props) { - const [pages, setPages] = useState([]); - - // These refs are to determine, if we want to clear up logs navigation when totally new query is run - const expectedQueriesRef = useRef(); - const expectedRangeRef = useRef(); - // This ref is to store range span for future queres based on firstly selected time range - // e.g. if last 5 min selected, always run 5 min range - const rangeSpanRef = useRef(0); - - const currentPageIndex = useMemo( - () => - pages.findIndex((page) => { - return page.queryRange.to === absoluteRange.to; - }), - [absoluteRange.to, pages] - ); - +function LogsNavigation({ logsSortOrder, scrollToTopLogs }: Props) { const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending; - const onFirstPage = oldestLogsFirst ? currentPageIndex === pages.length - 1 : currentPageIndex === 0; - const onLastPage = oldestLogsFirst ? currentPageIndex === 0 : currentPageIndex === pages.length - 1; const theme = useTheme2(); const styles = getStyles(theme, oldestLogsFirst); - // Main effect to set pages and index - useEffect(() => { - const newPage = { logsRange: visibleRange, queryRange: absoluteRange }; - let newPages: LogsPage[] = []; - // We want to start new pagination if queries change or if absolute range is different than expected - if (!isEqual(expectedRangeRef.current, absoluteRange) || !isEqual(expectedQueriesRef.current, queries)) { - clearCache(); - setPages([newPage]); - expectedQueriesRef.current = queries; - rangeSpanRef.current = absoluteRange.to - absoluteRange.from; - } else { - setPages((pages) => { - // Remove duplicates with new query - newPages = pages.filter((page) => !isEqual(newPage.queryRange, page.queryRange)); - // Sort pages based on logsOrder so they visually align with displayed logs - newPages = [...newPages, newPage].sort((a, b) => sortPages(a, b, logsSortOrder)); - return newPages; - }); - } - }, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]); - - const changeTime = useCallback( - ({ from, to }: AbsoluteTimeRange) => { - addResultsToCache(); - expectedRangeRef.current = { from, to }; - onChangeTime({ from, to }); - }, - [onChangeTime, addResultsToCache] - ); - - const sortPages = (a: LogsPage, b: LogsPage, logsSortOrder?: LogsSortOrder | null) => { - if (logsSortOrder === LogsSortOrder.Ascending) { - return a.queryRange.to > b.queryRange.to ? 1 : -1; - } - return a.queryRange.to > b.queryRange.to ? -1 : 1; - }; - - const olderLogsButton = ( - - ); - - const newerLogsButton = ( - - ); - - const onPageClick = useCallback( - (page: LogsPage, pageNumber: number) => { - reportInteraction('grafana_explore_logs_pagination_clicked', { - pageType: 'page', - pageNumber, - }); - changeTime({ from: page.queryRange.from, to: page.queryRange.to }); - scrollToTopLogs(); - }, - [changeTime, scrollToTopLogs] - ); - const onScrollToTopClick = useCallback(() => { reportInteraction('grafana_explore_logs_scroll_top_clicked'); scrollToTopLogs(); }, [scrollToTopLogs]); - const onScrollToBottomClick = useCallback(() => { - reportInteraction('grafana_explore_logs_scroll_bottom_clicked'); - scrollToBottomLogs?.(); - }, [scrollToBottomLogs]); - return (
- {!config.featureToggles.logsInfiniteScrolling && ( - <> - {oldestLogsFirst ? olderLogsButton : newerLogsButton} - - {oldestLogsFirst ? newerLogsButton : olderLogsButton} - - )} - {scrollToBottomLogs && ( - - )} - ))} -
-
- - ); -} - -const getStyles = (theme: GrafanaTheme2, loading: boolean) => { - return { - pagesWrapper: css({ - height: '100%', - paddingLeft: theme.spacing(0.5), - display: 'flex', - flexDirection: 'column', - '&::after': { - content: "''", - display: 'block', - background: `repeating-linear-gradient(135deg, ${theme.colors.background.primary}, ${theme.colors.background.primary} 5px, ${theme.colors.background.secondary} 5px, ${theme.colors.background.secondary} 15px)`, - width: '3px', - height: 'inherit', - marginBottom: theme.spacing(1), - }, - }), - pagesContainer: css({ - display: 'flex', - padding: 0, - flexDirection: 'column', - }), - page: css({ - display: 'flex', - margin: theme.spacing(2, 0), - cursor: loading ? 'auto' : 'pointer', - whiteSpace: 'normal', - '.selectedBg': { - background: theme.colors.primary.main, - }, - '.selectedText': { - color: theme.colors.primary.main, - }, - }), - line: css({ - width: '3px', - height: '100%', - alignItems: 'center', - background: theme.colors.text.secondary, - }), - time: css({ - width: '60px', - minHeight: '80px', - fontSize: theme.v1.typography.size.sm, - paddingLeft: theme.spacing(0.5), - display: 'flex', - alignItems: 'center', - }), - }; -}; diff --git a/public/app/features/explore/Logs/LogsVolumePanelList.tsx b/public/app/features/explore/Logs/LogsVolumePanelList.tsx index 4d0556cfdb5..cfd35f7a73c 100644 --- a/public/app/features/explore/Logs/LogsVolumePanelList.tsx +++ b/public/app/features/explore/Logs/LogsVolumePanelList.tsx @@ -26,7 +26,7 @@ import { mergeLogsVolumeDataFrames, isLogsVolumeLimited, getLogsVolumeMaximumRan import { SupplementaryResultError } from '../SupplementaryResultError'; import { LogsVolumePanel } from './LogsVolumePanel'; -import { isTimeoutErrorResponse } from './utils/logsVolumeResponse'; +import { isMaxBytesErrorResponse, isTimeoutErrorResponse } from './utils/logsVolumeResponse'; type Props = { logsVolumeData: DataQueryResponse | undefined; @@ -93,7 +93,8 @@ export const LogsVolumePanelList = ({ const canShowPartialData = config.featureToggles.lokiShardSplitting && logsVolumeData && logsVolumeData.data.length > 0; const timeoutError = isTimeoutErrorResponse(logsVolumeData); - + const maxBytesError = isMaxBytesErrorResponse(logsVolumeData); + const queryTooLargeError = timeoutError || maxBytesError; const from = dateTime(Math.max(absoluteRange.from, allLogsVolumeMaximumRange.from)); const to = dateTime(Math.min(absoluteRange.to, allLogsVolumeMaximumRange.to)); const visibleRange: TimeRange = { from, to, raw: { from, to } }; @@ -123,7 +124,7 @@ export const LogsVolumePanelList = ({ Loading... ); - } else if (timeoutError && !canShowPartialData) { + } else if (queryTooLargeError && !canShowPartialData) { return (

- - The query is trying to access too much data. Try one or more of the following: - + {timeoutError && ( + + The query is trying to access too much data. Try one or more of the following: + + )} + {maxBytesError && ( + + The query would read too many bytes. Try one or more of the following: + + )}

  • @@ -184,10 +192,24 @@ export const LogsVolumePanelList = ({ return (
    - {timeoutError && canShowPartialData && ( + {queryTooLargeError && canShowPartialData && ( diff --git a/public/app/features/explore/Logs/utils/logsVolumeResponse.test.ts b/public/app/features/explore/Logs/utils/logsVolumeResponse.test.ts index b29f1798767..c383ec2e05b 100644 --- a/public/app/features/explore/Logs/utils/logsVolumeResponse.test.ts +++ b/public/app/features/explore/Logs/utils/logsVolumeResponse.test.ts @@ -1,6 +1,6 @@ import { DataQueryResponse } from '@grafana/data'; -import { isTimeoutErrorResponse } from './logsVolumeResponse'; +import { isMaxBytesErrorResponse, isTimeoutErrorResponse } from './logsVolumeResponse'; const errorA = 'Get "http://localhost:3100/loki/api/v1/query_range?direction=backward&end=1680001200000000000&limit=1000&query=sum+by+%28level%29+%28count_over_time%28%7Bcontainer_name%3D%22docker-compose-app-1%22%7D%5B1h%5D%29%29&start=1679914800000000000&step=3600000ms": net/http: request canceled (Client.Timeout exceeded while awaiting headers)'; @@ -17,6 +17,7 @@ describe('isTimeoutErrorResponse', () => { }, }; expect(isTimeoutErrorResponse(response)).toBe(true); + expect(isMaxBytesErrorResponse(response)).toBe(false); } ); test.each([errorA, errorB])( @@ -34,6 +35,7 @@ describe('isTimeoutErrorResponse', () => { ], }; expect(isTimeoutErrorResponse(response)).toBe(true); + expect(isMaxBytesErrorResponse(response)).toBe(false); } ); test.each([errorA, errorB])( @@ -55,6 +57,7 @@ describe('isTimeoutErrorResponse', () => { ], }; expect(isTimeoutErrorResponse(response)).toBe(true); + expect(isMaxBytesErrorResponse(response)).toBe(false); } ); test('does not report false positives', () => { @@ -62,5 +65,70 @@ describe('isTimeoutErrorResponse', () => { data: [], }; expect(isTimeoutErrorResponse(response)).toBe(false); + expect(isMaxBytesErrorResponse(response)).toBe(false); + }); +}); +describe('isMaxBytesErrorResponse', () => { + const error = `the query would read too many bytes (query: 49 MiB, limit: 1.0 MiB); consider adding more specific stream selectors or reduce the time range of the query`; + + it('identifies timeout errors in the error.message attribute when the message is `%s`', () => { + const response: DataQueryResponse = { + data: [], + error: { + message: error, + }, + }; + expect(isTimeoutErrorResponse(response)).toBe(false); + expect(isMaxBytesErrorResponse(response)).toBe(true); + }); + + it('identifies timeout errors in the errors.message attribute when the message is `%s`', () => { + const response: DataQueryResponse = { + data: [], + errors: [ + { + message: 'Something else', + }, + { + message: error, + }, + ], + }; + expect(isTimeoutErrorResponse(response)).toBe(false); + expect(isMaxBytesErrorResponse(response)).toBe(true); + }); + it('identifies timeout errors in the errors.data.message attribute when the message is `%s`', () => { + const response: DataQueryResponse = { + data: [], + errors: [ + { + data: { + message: 'Something else', + }, + }, + { + data: { + message: error, + }, + }, + ], + }; + expect(isTimeoutErrorResponse(response)).toBe(false); + expect(isMaxBytesErrorResponse(response)).toBe(true); + }); + test('does not report false positives', () => { + const response: DataQueryResponse = { + data: [], + errors: [ + { + status: 500, + data: { + message: 'Servr error', + }, + }, + ], + }; + expect(isTimeoutErrorResponse(response)).toBe(false); + expect(isMaxBytesErrorResponse(response)).toBe(false); }); }); diff --git a/public/app/features/explore/Logs/utils/logsVolumeResponse.ts b/public/app/features/explore/Logs/utils/logsVolumeResponse.ts index 2dcb5221331..f21ca9659bd 100644 --- a/public/app/features/explore/Logs/utils/logsVolumeResponse.ts +++ b/public/app/features/explore/Logs/utils/logsVolumeResponse.ts @@ -1,6 +1,8 @@ import { DataQueryError, DataQueryResponse } from '@grafana/data'; -// Currently we can only infer if an error response is a timeout or not. +export const LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX = 'the query would read too many bytes'; +export const LOKI_TIMEOUT_ERROR_REGEX = /timeout|timed out/; + export function isTimeoutErrorResponse(response: DataQueryResponse | undefined): boolean { if (!response) { return false; @@ -12,7 +14,27 @@ export function isTimeoutErrorResponse(response: DataQueryResponse | undefined): const errors = response.error ? [response.error] : response.errors || []; return errors.some((error: DataQueryError) => { - const message = `${error.message || error.data?.message}`?.toLowerCase(); - return message.includes('timeout'); + const message = normalizeMessage(error); + return message.match(LOKI_TIMEOUT_ERROR_REGEX); }); } + +export function isMaxBytesErrorResponse(response: DataQueryResponse | undefined): boolean { + if (!response) { + return false; + } + if (!response.error && !response.errors) { + return false; + } + + const errors = response.error ? [response.error] : response.errors || []; + + return errors.some((error: DataQueryError) => { + const message = normalizeMessage(error); + return message?.includes(LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX); + }); +} + +function normalizeMessage(error: DataQueryError) { + return ((error.message || error.data?.message) ?? '').toLowerCase(); +} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 238002e7975..3c14fb0c43e 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -157,7 +157,7 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'ServiceNameAndLinks', display: 'flex', width: '100%', - marginBottom: '16px', + marginBottom: theme.spacing(1), }), operationName: css({ label: 'SpanDetailOperationName', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx index 7312daaccab..bcc837b4b7b 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx @@ -77,6 +77,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { label: 'detailWrapper', flex: '1', minWidth: 0, + backgroundColor: theme.colors.background.canvas, + border: `1px solid ${theme.colors.border.weak}`, }), }; }); @@ -165,6 +167,7 @@ const UnthemedSpanDetailRow = React.memo((props) => { addHoverIndentGuideId={addHoverIndentGuideId} removeHoverIndentGuideId={removeHoverIndentGuideId} visibleSpanIds={visibleSpanIds} + removeLastIndentGuide={true} />
    diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx index e9c0ad9f9ad..5f52ac216e4 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx @@ -47,11 +47,12 @@ describe('SpanTreeOffset', () => { }); describe('.SpanTreeOffset--indentGuide', () => { - it('renders no indentGuide if span has no ancestors and no children', () => { + it('renders only the trace-level indentGuide if span has no ancestors and no children', () => { jest.mocked(spanAncestorIdsSpy).mockReturnValue([]); render(); const indentGuides = screen.queryAllByTestId('SpanTreeOffset--indentGuide'); - expect(indentGuides.length).toBe(0); + expect(indentGuides.length).toBe(1); + expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID); }); it('renders only one SpanTreeOffset--indentGuide for entire trace if span has no ancestors but has children', () => { @@ -66,9 +67,10 @@ describe('SpanTreeOffset', () => { it('renders one SpanTreeOffset--indentGuide per ancestor span when span has no children', () => { render(); const indentGuides = screen.getAllByTestId('SpanTreeOffset--indentGuide'); - expect(indentGuides.length).toBe(2); + expect(indentGuides.length).toBe(3); expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID); expect(indentGuides[1]).toHaveAttribute('data-ancestor-id', rootSpanID); + expect(indentGuides[2]).toHaveAttribute('data-ancestor-id', parentSpanID); }); it('renders one SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace when span has children', () => { @@ -112,16 +114,20 @@ describe('SpanTreeOffset', () => { props = { ...props, span: { ...props.span, hasChildren: true } }; }); - it('does not render icon if props.span.hasChildren is false', () => { + it('renders placeholder content when props.span.hasChildren is false', () => { props.span.hasChildren = false; render(); - expect(screen.queryByTestId('icon-wrapper')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-arrow-down')).not.toBeInTheDocument(); + expect(screen.getByTestId('icon-wrapper')).toHaveTextContent('-'); }); - it('does not render icon if props.span.hasChildren is true and showChildrenIcon is false', () => { + it('renders placeholder content when props.span.hasChildren is true but showChildrenIcon is false', () => { props.showChildrenIcon = false; render(); - expect(screen.queryByTestId('icon-wrapper')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-arrow-down')).not.toBeInTheDocument(); + expect(screen.getByTestId('icon-wrapper')).toHaveTextContent('-'); }); it('renders arrow-right if props.span.hasChildren is true and props.childrenVisible is false', () => { diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx index 1d2bea3105e..e2abaff8a70 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx @@ -66,6 +66,8 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ right: 0, height: '100%', paddingTop: '1px', + width: '1rem', + textAlign: 'center', }), })); @@ -80,6 +82,7 @@ export type TProps = { removeHoverIndentGuideId: (spanID: string) => void; theme: GrafanaTheme2; visibleSpanIds: string[]; + removeLastIndentGuide?: boolean; }; const UnthemedSpanTreeOffset = React.memo((props) => { @@ -93,6 +96,7 @@ const UnthemedSpanTreeOffset = React.memo((props) => { hoverIndentGuideIds, addHoverIndentGuideId, removeHoverIndentGuideId, + removeLastIndentGuide = false, } = props; const ancestorIds = React.useMemo(() => { @@ -101,8 +105,12 @@ const UnthemedSpanTreeOffset = React.memo((props) => { // necessary padding for the collapse icon on root-level spans. ids.push('root'); ids.reverse(); + + if (removeLastIndentGuide) { + ids.pop(); + } return ids; - }, [span]); + }, [span, removeLastIndentGuide]); /** * If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is @@ -156,20 +164,15 @@ const UnthemedSpanTreeOffset = React.memo((props) => { )); const styles = getStyles(theme); - // If span has no children, don't show the last indent guide - const displayedAncestorIds = hasChildren ? ancestorIds : ancestorIds.slice(0, -1); - return ( - {displayedAncestorIds.map((ancestorId, index) => ( + {ancestorIds.map((ancestorId, index) => ( ((props) => { onMouseLeave={(event) => handleMouseLeave(event, ancestorId)} /> ))} - {icon && ( - handleMouseEnter(event, spanID)} - onMouseLeave={(event) => handleMouseLeave(event, spanID)} - data-testid="icon-wrapper" - > - {icon} - - )} + icon && handleMouseEnter(event, spanID)} + onMouseLeave={(event) => icon && handleMouseLeave(event, spanID)} + data-testid="icon-wrapper" + > + {icon || (!removeLastIndentGuide && '-')} + ); }); diff --git a/public/app/features/explore/TraceView/components/common/LabeledList.tsx b/public/app/features/explore/TraceView/components/common/LabeledList.tsx index 1c09a2dda25..925579a1bcf 100644 --- a/public/app/features/explore/TraceView/components/common/LabeledList.tsx +++ b/public/app/features/explore/TraceView/components/common/LabeledList.tsx @@ -28,6 +28,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => { listStyle: 'none', margin: 0, padding: 0, + fontSize: theme.typography.size.sm, ...(divider ? { marginRight: '-8px', @@ -49,7 +50,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => { }), LabeledListLabel: css({ label: 'LabeledListLabel', - color: theme.isLight ? '#999' : '#666', + color: theme.colors.text.secondary, marginRight: '0.25rem', }), LabeledListValue: css({ @@ -66,7 +67,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => { LabeledListServiceLine: css({ label: 'LabeledListServiceLine', display: 'inline-block', - width: '1.25rem', + width: '1rem', height: '0.35rem', marginRight: '0.5rem', verticalAlign: 'middle', @@ -95,7 +96,7 @@ export default function LabeledList(props: LabeledListProps) { {label === 'Service:' && (
    )} - {icon && } + {icon && } {label} {value}
  • diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index 4094d4f515c..874234837ac 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -13,7 +13,6 @@ import { SplitOpen, TimeRange, } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { LogsVisualisationType } from '../../explore/Logs/Logs'; @@ -150,7 +149,7 @@ const LogRowsComponent = forwardRef { - config.featureToggles.logsInfiniteScrolling = true; -}); -afterAll(() => { - config.featureToggles.logsInfiniteScrolling = originalState; -}); - describe('InfiniteScroll', () => { test('Wraps components without adding DOM elements', async () => { const { container } = render( diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index e21ca4a4447..3c4ab5001b1 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -4,7 +4,7 @@ import { ReactNode, MutableRefObject, useCallback, useEffect, useRef, useState } import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange, rangeUtil } from '@grafana/data'; // import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/internal'; import { Trans } from '@grafana/i18n'; -import { config, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { LogsSortOrder, TimeZone } from '@grafana/schema'; import { Button, Icon } from '@grafana/ui'; @@ -86,7 +86,7 @@ export const InfiniteScroll = ({ } function handleScroll(event: Event | WheelEvent) { - if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) { + if (!scrollElement || !loadMoreLogs || !rows.length || loading) { return; } const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current); diff --git a/public/app/features/logs/components/panel/InfiniteScroll.test.tsx b/public/app/features/logs/components/panel/InfiniteScroll.test.tsx index 631c2831e9c..2507a3b5176 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.test.tsx @@ -2,7 +2,6 @@ import { act, render, screen } from '@testing-library/react'; import { VariableSizeList } from 'react-window'; import { createTheme, dateTimeForTimeZone, rangeUtil } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { LogsSortOrder } from '@grafana/schema'; import { ScrollDirection, SCROLLING_THRESHOLD } from '../InfiniteScroll'; @@ -110,14 +109,6 @@ function setup( return { element, events, scrollTo, wheel }; } -const originalState = config.featureToggles.logsInfiniteScrolling; -beforeAll(() => { - config.featureToggles.logsInfiniteScrolling = true; -}); -afterAll(() => { - config.featureToggles.logsInfiniteScrolling = originalState; -}); - describe('InfiniteScroll', () => { describe.each([LogsSortOrder.Descending, LogsSortOrder.Ascending])( 'When the sort order is descending', diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index 45cc616ea61..3c7de64b6be 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -4,7 +4,7 @@ import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window' import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { Spinner, useStyles2 } from '@grafana/ui'; import { canScrollBottom, canScrollTop, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll'; @@ -139,7 +139,7 @@ export const InfiniteScroll = ({ ); useEffect(() => { - if (!scrollElement || !loadMore || !config.featureToggles.logsInfiniteScrolling) { + if (!scrollElement || !loadMore) { return; } diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 7ad570acad8..21324f942ef 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -128,6 +128,7 @@ export class LogListModel implements LogRowModel { clone._wrapLogMessage = true; clone._body = undefined; clone._highlightTokens = undefined; + clone.collapsed = false; return clone; } diff --git a/public/app/features/logs/logsModel.test.ts b/public/app/features/logs/logsModel.test.ts index f6221b6c6af..23d0badb4f5 100644 --- a/public/app/features/logs/logsModel.test.ts +++ b/public/app/features/logs/logsModel.test.ts @@ -21,7 +21,6 @@ import { sortDataFrame, toDataFrame, } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; import { getMockFrames } from 'app/plugins/datasource/loki/mocks/frames'; @@ -292,7 +291,7 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ label: '', - value: `2 lines returned`, + value: `2 lines displayed`, kind: LogsMetaKind.String, }); expect(logsModel.meta![1]).toMatchObject({ @@ -374,7 +373,7 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ label: '', - value: `2 lines returned`, + value: `2 lines displayed`, kind: LogsMetaKind.String, }); expect(logsModel.meta![1]).toMatchObject({ @@ -386,9 +385,7 @@ describe('dataFrameToLogsModel', () => { }); }); - it('with infinite scrolling enabled it should return expected logs model', () => { - config.featureToggles.logsInfiniteScrolling = true; - + it('it should return expected logs model', () => { const series: DataFrame[] = [ createDataFrame({ fields: [ @@ -421,8 +418,6 @@ describe('dataFrameToLogsModel', () => { value: `1 line displayed`, kind: LogsMetaKind.String, }); - - config.featureToggles.logsInfiniteScrolling = false; }); it('given one series with limit as custom meta property should return correct limit', () => { @@ -430,7 +425,7 @@ describe('dataFrameToLogsModel', () => { const logsModel = dataFrameToLogsModel(series, 1); expect(logsModel.meta![0]).toMatchObject({ label: '', - value: `2 lines returned`, + value: `2 lines displayed`, kind: LogsMetaKind.String, }); }); @@ -639,7 +634,7 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ label: '', - value: `2 lines returned`, + value: `2 lines displayed`, kind: LogsMetaKind.String, }); expect(logsModel.meta![1]).toMatchObject({ @@ -758,7 +753,7 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.meta).toHaveLength(3); expect(logsModel.meta![0]).toMatchObject({ label: '', - value: `2 lines returned`, + value: `2 lines displayed`, kind: LogsMetaKind.String, }); expect(logsModel.meta![1]).toMatchObject({ diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index 87cdc031ffc..e60887518fe 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -41,7 +41,6 @@ import { } from '@grafana/data'; import { SIPrefix } from '@grafana/data/internal'; import { t } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; import { BarAlignment, GraphDrawStyle, StackingMode } from '@grafana/schema'; import { colors } from '@grafana/ui'; import { getThemeColor } from 'app/core/utils/colors'; @@ -576,8 +575,7 @@ function adjustMetaInfo(logsModel: LogsModel, visibleRangeMs?: number, requested metaLimitValue = `${limit} lines shown — ${coverage}% (${rangeUtil.msRangeToTimeString(visibleRangeMs)}) of ${rangeUtil.msRangeToTimeString(requestedRangeMs)}`; } } else { - const description = config.featureToggles.logsInfiniteScrolling ? 'displayed' : 'returned'; - metaLimitValue = `${logsModel.rows.length} ${logsModel.rows.length > 1 ? 'lines' : 'line'} ${description}`; + metaLimitValue = `${logsModel.rows.length} ${logsModel.rows.length > 1 ? 'lines' : 'line'} displayed`; } logsModelMeta[limitIndex] = { diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 46e5bdbe012..46232beff41 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -5,7 +5,8 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { useStyles2 } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { Icon, Text, useStyles2 } from '@grafana/ui'; import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; @@ -31,6 +32,21 @@ export function VisualizationSuggestions({ searchQuery, onChange, data, panel, t return result; }, [searchQuery, suggestions, trackSearch]); + const hasData = data?.series && data.series.length > 0 && !data.series.every((frame) => frame.length === 0); + + if (config.featureToggles.newVizSuggestions && !hasData && !searchQuery) { + return ( +
    + + + + Run a query to start seeing suggested visualizations + + +
    + ); + } + return ( // This div is needed in some places to make AutoSizer work
    @@ -116,5 +132,18 @@ const getStyles = (theme: GrafanaTheme2) => { marginBottom: theme.spacing(1), justifyContent: 'space-evenly', }), + emptyStateWrapper: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(4), + textAlign: 'center', + minHeight: '200px', + }), + emptyStateIcon: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + }), }; }; diff --git a/public/app/features/playlist/PlaylistNewPage.test.tsx b/public/app/features/playlist/PlaylistNewPage.test.tsx index 22a6bfb0117..fe33fada83c 100644 --- a/public/app/features/playlist/PlaylistNewPage.test.tsx +++ b/public/app/features/playlist/PlaylistNewPage.test.tsx @@ -24,7 +24,21 @@ jest.mock('app/core/components/TagFilter/TagFilter', () => ({ function getTestContext() { jest.clearAllMocks(); - const backendSrvMock = jest.spyOn(backendSrv, 'fetch').mockImplementation(() => of(createFetchResponse({}))); + + // Create separate spies for different HTTP methods + const postSpy = jest.fn(); + const otherSpy = jest.fn(); + + const backendSrvMock = jest.spyOn(backendSrv, 'fetch').mockImplementation((options) => { + if (options.method === 'POST') { + postSpy(options); + return of(createFetchResponse({})); + } + // Handle GET and other methods + otherSpy(options); + return of(createFetchResponse({ items: [] })); + }); + jest.spyOn(backendSrv, 'search').mockResolvedValue([]); const { rerender } = render( @@ -33,7 +47,7 @@ function getTestContext() { ); - return { rerender, backendSrvMock }; + return { rerender, backendSrvMock, postSpy, otherSpy }; } describe('PlaylistNewPage', () => { @@ -47,15 +61,17 @@ describe('PlaylistNewPage', () => { describe('when submitted', () => { it('then correct api should be called', async () => { - const { backendSrvMock } = getTestContext(); + const { postSpy } = getTestContext(); expect(locationService.getLocation().pathname).toEqual('/'); await userEvent.type(screen.getByRole('textbox', { name: selectors.pages.PlaylistForm.name }), 'A new name'); fireEvent.submit(screen.getByRole('button', { name: /save/i })); - await waitFor(() => expect(backendSrvMock).toHaveBeenCalledTimes(1)); - expect(backendSrvMock).toHaveBeenCalledWith( + await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1)); + + expect(postSpy).toHaveBeenCalledWith( expect.objectContaining({ + method: 'POST', body: expect.objectContaining({ spec: { title: 'A new name', diff --git a/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.test.ts b/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.test.ts index 88043e66dc2..9d6b0a15d8f 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.test.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.test.ts @@ -1,9 +1,10 @@ import { CustomVariableModel } from '@grafana/data'; +import { AzureLogsQuery, AzureQueryType, AzureTracesQuery } from '../dataquery.gen'; import { Context, createContext } from '../mocks/datasource'; import createMockQuery from '../mocks/query'; import { singleVariable } from '../mocks/variables'; -import { AzureLogsQuery, AzureMonitorQuery, AzureQueryType, AzureTracesQuery } from '../types/query'; +import { AzureMonitorQuery } from '../types/query'; import AzureLogAnalyticsDatasource from './azure_log_analytics_datasource'; import FakeSchemaData from './mocks/schema'; diff --git a/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.ts b/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.ts index 77733e2dfa3..97c121fecdd 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.ts @@ -6,7 +6,8 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run import ResponseParser from '../azure_monitor/response_parser'; import { getCredentials } from '../credentials'; -import { AzureMonitorQuery, AzureQueryType } from '../types/query'; +import { AzureQueryType } from '../dataquery.gen'; +import { AzureMonitorQuery } from '../types/query'; import { AzureMonitorDataSourceJsonData, AzureMonitorDataSourceInstanceSettings, diff --git a/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts index a4bb34dc5f9..7afc9b511f5 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts @@ -5,8 +5,9 @@ import { ScopedVars } from '@grafana/data'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { getCredentials } from '../credentials'; +import { AzureMetricQuery, AzureQueryType } from '../dataquery.gen'; import TimegrainConverter from '../time_grain_converter'; -import { AzureMetricQuery, AzureMonitorQuery, AzureQueryType } from '../types/query'; +import { AzureMonitorQuery } from '../types/query'; import { AzureAPIResponse, AzureMonitorDataSourceInstanceSettings, diff --git a/public/app/plugins/datasource/azuremonitor/azure_monitor/url_builder.ts b/public/app/plugins/datasource/azuremonitor/azure_monitor/url_builder.ts index 545c1648315..7fe6944d9f0 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_monitor/url_builder.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_monitor/url_builder.ts @@ -1,6 +1,6 @@ import { TemplateSrv } from '@grafana/runtime'; -import { AzureMonitorResource } from '../types/query'; +import { AzureMonitorResource } from '../dataquery.gen'; import { GetMetricNamespacesQuery, GetMetricNamesQuery } from '../types/types'; export default class UrlBuilder { diff --git a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts index 8fbb4cbe3e6..a26da4563cb 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts @@ -2,12 +2,12 @@ import { get, set } from 'lodash'; import { CustomVariableModel } from '@grafana/data'; +import { AzureQueryType } from '../dataquery.gen'; import { Context, createContext } from '../mocks/datasource'; import { createMockInstanceSetttings } from '../mocks/instanceSettings'; import createMockQuery from '../mocks/query'; import { createTemplateVariables } from '../mocks/utils'; import { multiVariable, singleVariable, subscriptionsVariable } from '../mocks/variables'; -import { AzureQueryType } from '../types/query'; import AzureResourceGraphDatasource from './azure_resource_graph_datasource'; diff --git a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts index c7b3877f429..1bc562ed391 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts @@ -4,9 +4,9 @@ import { ScopedVars } from '@grafana/data'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { resourceTypes } from '../azureMetadata/resourceTypes'; -import { ARGScope } from '../dataquery.gen'; +import { ARGScope, AzureQueryType } from '../dataquery.gen'; import { createFilter } from '../resourcePicker/resourcePickerData'; -import { AzureMonitorQuery, AzureQueryType } from '../types/query'; +import { AzureMonitorQuery } from '../types/query'; import { AzureGetResourceNamesQuery, AzureGraphResponse, diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx index 7ed4304b881..d61e8d03f5e 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx @@ -18,7 +18,8 @@ import { } from '@grafana/ui'; import AzureLogAnalyticsDatasource from '../../azure_log_analytics/azure_log_analytics_datasource'; -import { AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureQueryType } from '../../dataquery.gen'; +import { AzureMonitorQuery } from '../../types/query'; import { Category, CheatsheetQueries, CheatsheetQuery, DropdownCategories } from '../../types/types'; import { RawQuery } from './RawQuery'; diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx index 00125f293b7..bec68fee1c7 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx @@ -6,11 +6,11 @@ import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui'; import { config, getTemplateSrv } from '@grafana/runtime'; import { Alert, LinkButton, Space, Text, TextLink } from '@grafana/ui'; -import { LogsEditorMode } from '../../dataquery.gen'; +import { LogsEditorMode, ResultFormat } from '../../dataquery.gen'; import Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; import { AzureLogAnalyticsMetadataTable } from '../../types/logAnalyticsMetadata'; -import { AzureMonitorQuery, ResultFormat } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorErrorish, AzureMonitorOption, EngineSchema } from '../../types/types'; import { LogsQueryBuilder } from '../LogsQueryBuilder/LogsQueryBuilder'; import ResourceField from '../ResourceField/ResourceField'; diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/setQueryValue.ts b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/setQueryValue.ts index 9662afd52af..1e4422cd4b5 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/setQueryValue.ts +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/setQueryValue.ts @@ -1,6 +1,7 @@ import { SelectableValue } from '@grafana/data'; -import { AzureMonitorQuery, ResultFormat } from '../../types/query'; +import { ResultFormat } from '../../dataquery.gen'; +import { AzureMonitorQuery } from '../../types/query'; export function setKustoQuery(query: AzureMonitorQuery, kustoQuery: string): AzureMonitorQuery { return { diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx index abf4d91de90..1be90aafd26 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx @@ -6,8 +6,8 @@ import { Trans, t } from '@grafana/i18n'; import { AccessoryButton } from '@grafana/plugin-ui'; import { Input, Label, InlineField, Button, useStyles2 } from '@grafana/ui'; +import { AzureMonitorResource } from '../../dataquery.gen'; import { selectors } from '../../e2e/selectors'; -import { AzureMonitorResource } from '../../types/query'; export interface ResourcePickerProps { resources: T[]; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx index 6a7d3c22d37..c5d9e1626f5 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx @@ -5,7 +5,8 @@ import { t } from '@grafana/i18n'; import { EditorList, AccessoryButton } from '@grafana/plugin-ui'; import { Select, Stack, MultiSelect } from '@grafana/ui'; -import { AzureMetricDimension, AzureMonitorQuery } from '../../types/query'; +import { AzureMetricDimension } from '../../dataquery.gen'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorOption, AzureQueryEditorFieldProps } from '../../types/types'; import { Field } from '../shared/Field'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx index 3490908f4c5..52503350614 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -2,9 +2,10 @@ import { PanelData } from '@grafana/data'; import { EditorRows, EditorRow, EditorFieldGroup } from '@grafana/plugin-ui'; import { multiResourceCompatibleTypes } from '../../azureMetadata/resourceTypes'; +import { AzureMonitorResource } from '../../dataquery.gen'; import type Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; -import { AzureMonitorQuery, AzureMonitorResource } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorOption, AzureMonitorErrorish } from '../../types/types'; import ResourceField from '../ResourceField/ResourceField'; import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../ResourcePicker/types'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.test.ts index 47a6edf0a19..6ca4eaf27cb 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.test.ts +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.test.ts @@ -1,8 +1,9 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { AzureMetricQuery, AzureQueryType } from '../../dataquery.gen'; import Datasource from '../../datasource'; import createMockDatasource from '../../mocks/datasource'; -import { AzureMetricQuery, AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorOption } from '../../types/types'; import { diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.ts index 82115c289c4..97a0a6579b7 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.ts +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/dataHooks.ts @@ -2,9 +2,10 @@ import { useEffect, useState } from 'react'; import { rangeUtil } from '@grafana/data'; +import { AzureMonitorResource } from '../../dataquery.gen'; import Datasource from '../../datasource'; import TimegrainConverter from '../../time_grain_converter'; -import { AzureMonitorQuery, AzureMonitorResource } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorErrorish, AzureMonitorOption } from '../../types/types'; import { toOption } from '../../utils/common'; import { useAsyncState } from '../../utils/useAsyncState'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/setQueryValue.ts b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/setQueryValue.ts index 05b17493ca3..92671157dde 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/setQueryValue.ts +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/setQueryValue.ts @@ -1,4 +1,5 @@ -import { AzureMetricDimension, AzureMonitorQuery } from '../../types/query'; +import { AzureMetricDimension } from '../../dataquery.gen'; +import { AzureMonitorQuery } from '../../types/query'; export function setCustomNamespace(query: AzureMonitorQuery, selection: string | undefined): AzureMonitorQuery { if (query.azureMonitor?.customNamespace === selection) { diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx index 17ccab9ea64..f9df635473c 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx @@ -5,11 +5,11 @@ import { config } from '@grafana/runtime'; // eslint-disable-next-line no-restricted-imports import * as ui from '@grafana/ui'; +import { AzureQueryType, ResultFormat } from '../../dataquery.gen'; import { selectors } from '../../e2e/selectors'; import createMockDatasource from '../../mocks/datasource'; import { invalidNamespaceError } from '../../mocks/errors'; import createMockQuery from '../../mocks/query'; -import { AzureQueryType, ResultFormat } from '../../types/query'; import { selectOptionInTest } from '../../utils/testUtils'; import { createMockResourcePickerData } from '../LogsQueryEditor/mocks'; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx index 39968c166b5..dce90eecc02 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx @@ -7,9 +7,10 @@ import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { Alert, CodeEditor, Space, TextLink } from '@grafana/ui'; +import { AzureQueryType } from '../../dataquery.gen'; import AzureMonitorDatasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; -import { AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorDataSourceJsonData, AzureMonitorErrorish, AzureMonitorOption } from '../../types/types'; import useLastError from '../../utils/useLastError'; import ArgQueryEditor from '../ArgQueryEditor/ArgQueryEditor'; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx index 75d89a8fc47..ab7c817c935 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx @@ -6,9 +6,9 @@ import { EditorHeader, FlexItem, InlineSelect } from '@grafana/plugin-ui'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui'; -import { LogsEditorMode } from '../../dataquery.gen'; +import { AzureQueryType, LogsEditorMode } from '../../dataquery.gen'; import { selectors } from '../../e2e/selectors'; -import { AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; interface QueryTypeFieldProps { query: AzureMonitorQuery; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/usePreparedQuery.ts b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/usePreparedQuery.ts index c2da635fcd0..210fe47087b 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/usePreparedQuery.ts +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/usePreparedQuery.ts @@ -2,7 +2,8 @@ import deepEqual from 'fast-deep-equal'; import { defaults } from 'lodash'; import { useEffect, useMemo } from 'react'; -import { AzureMonitorQuery, AzureQueryType, ResultFormat } from '../../types/query'; +import { AzureQueryType, ResultFormat } from '../../dataquery.gen'; +import { AzureMonitorQuery } from '../../types/query'; import migrateQuery from '../../utils/migrateQuery'; const DEFAULT_QUERY = { diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx index 35af5ad31f6..85a1d69401a 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx @@ -5,10 +5,10 @@ import * as React from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, Icon, Modal, useStyles2, IconName } from '@grafana/ui'; +import { AzureMonitorResource } from '../../dataquery.gen'; import Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; import { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; -import { AzureMonitorResource } from '../../types/query'; import { AzureQueryEditorFieldProps } from '../../types/types'; import ResourcePicker from '../ResourcePicker/ResourcePicker'; import getStyles from '../ResourcePicker/styles'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx index f9d5cee1d55..6e8b6ed675e 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import { t } from '@grafana/i18n'; import { Collapse, Space } from '@grafana/ui'; +import { AzureMonitorResource } from '../../dataquery.gen'; import { selectors } from '../../e2e/selectors'; -import { AzureMonitorResource } from '../../types/query'; export interface ResourcePickerProps { resources: T[]; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx index 3bacbdc2a81..cbe2e8a3154 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx @@ -24,10 +24,10 @@ import { } from '@grafana/ui'; import { resourceTypeDisplayNames } from '../../azureMetadata/resourceTypes'; +import { AzureMonitorResource } from '../../dataquery.gen'; import Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; -import { AzureMonitorResource } from '../../types/query'; import { ResourceGraphFilters } from '../../types/types'; import messageFromError from '../../utils/messageFromError'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/utils.ts b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/utils.ts index bcc9c9d2d03..37c82058ba3 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/utils.ts +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/utils.ts @@ -3,8 +3,9 @@ import { produce } from 'immer'; import { getTemplateSrv } from '@grafana/runtime'; import UrlBuilder from '../../azure_monitor/url_builder'; +import { AzureMonitorResource } from '../../dataquery.gen'; import { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; -import { AzureMonitorQuery, AzureMonitorResource } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { ResourceRow, ResourceRowGroup } from './types'; diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx index 785c9fd4e12..e6e670cdc66 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx @@ -6,7 +6,7 @@ import { t } from '@grafana/i18n'; import { EditorList } from '@grafana/plugin-ui'; import { Field } from '@grafana/ui'; -import { AzureTracesFilter } from '../../types/query'; +import { AzureTracesFilter } from '../../dataquery.gen'; import { AzureQueryEditorFieldProps } from '../../types/types'; import { makeRenderItem } from './Filter'; diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx index 46729f22dda..d38a611ede1 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx @@ -7,9 +7,10 @@ import { t } from '@grafana/i18n'; import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui'; import { Input } from '@grafana/ui'; +import { AzureQueryType, ResultFormat } from '../../dataquery.gen'; import Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; -import { AzureMonitorQuery, AzureQueryType, ResultFormat } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorErrorish, AzureMonitorOption } from '../../types/types'; import AdvancedResourcePicker from '../LogsQueryEditor/AdvancedResourcePicker'; import ResourceField from '../ResourceField/ResourceField'; diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/setQueryValue.ts b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/setQueryValue.ts index b901c7b800d..33d98fc091c 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/setQueryValue.ts +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/setQueryValue.ts @@ -1,6 +1,7 @@ import { SelectableValue } from '@grafana/data'; -import { AzureMonitorQuery, AzureQueryType, AzureTracesFilter, ResultFormat } from '../../types/query'; +import { AzureQueryType, AzureTracesFilter, ResultFormat } from '../../dataquery.gen'; +import { AzureMonitorQuery } from '../../types/query'; // Used when switching from a traces exemplar query to a standard Azure Traces query export function setDefaultTracesQuery(query: AzureMonitorQuery): AzureMonitorQuery { diff --git a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/GrafanaTemplateVariableFn.tsx b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/GrafanaTemplateVariableFn.tsx index 1727cac59a7..b090561f716 100644 --- a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/GrafanaTemplateVariableFn.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/GrafanaTemplateVariableFn.tsx @@ -3,9 +3,10 @@ import { ChangeEvent, useCallback, useEffect, useState } from 'react'; import { t } from '@grafana/i18n'; import { InlineField, Input } from '@grafana/ui'; +import { AzureQueryType } from '../../dataquery.gen'; import DataSource from '../../datasource'; import { migrateStringQueriesToObjectQueries } from '../../grafanaTemplateVariableFns'; -import { AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; const GrafanaTemplateVariableFnInput = ({ query, diff --git a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.test.tsx index c400eb35a7c..3c103976311 100644 --- a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.test.tsx @@ -6,8 +6,9 @@ import { select, openMenu } from 'react-select-event'; // eslint-disable-next-line no-restricted-imports import * as ui from '@grafana/ui'; +import { AzureQueryType } from '../../dataquery.gen'; import createMockDatasource from '../../mocks/datasource'; -import { AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import VariableEditor from './VariableEditor'; diff --git a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx index 75ced4bacd0..a20e6cdb904 100644 --- a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx @@ -8,10 +8,11 @@ import { getTemplateSrv } from '@grafana/runtime'; import { Alert, Field, Select, Space } from '@grafana/ui'; import UrlBuilder from '../../azure_monitor/url_builder'; +import { AzureQueryType } from '../../dataquery.gen'; import DataSource from '../../datasource'; import { selectors } from '../../e2e/selectors'; import { migrateQuery } from '../../grafanaTemplateVariableFns'; -import { AzureMonitorQuery, AzureQueryType } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import { AzureMonitorOption } from '../../types/types'; import useLastError from '../../utils/useLastError'; import ArgQueryEditor from '../ArgQueryEditor/ArgQueryEditor'; diff --git a/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.test.tsx b/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.test.tsx index ed02f1b9ce3..3131a12cf17 100644 --- a/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.test.tsx @@ -2,9 +2,10 @@ import { render, screen } from '@testing-library/react'; import { SelectableValue } from '@grafana/data'; +import { ResultFormat } from '../../dataquery.gen'; import createMockDatasource from '../../mocks/datasource'; import createMockQuery from '../../mocks/query'; -import { AzureMonitorQuery, ResultFormat } from '../../types/query'; +import { AzureMonitorQuery } from '../../types/query'; import FormatAsField from './FormatAsField'; diff --git a/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.tsx b/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.tsx index 240db475305..65ed6e4334c 100644 --- a/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.tsx @@ -5,8 +5,8 @@ import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Select } from '@grafana/ui'; +import { ResultFormat } from '../../dataquery.gen'; import { selectors } from '../../e2e/selectors'; -import { ResultFormat } from '../../types/query'; import { FormatAsFieldProps } from '../../types/types'; import { Field } from './Field'; diff --git a/public/app/plugins/datasource/azuremonitor/datasource.test.ts b/public/app/plugins/datasource/azuremonitor/datasource.test.ts index be090fa6254..62256b8e592 100644 --- a/public/app/plugins/datasource/azuremonitor/datasource.test.ts +++ b/public/app/plugins/datasource/azuremonitor/datasource.test.ts @@ -1,8 +1,8 @@ +import { AzureQueryType } from './dataquery.gen'; import Datasource from './datasource'; import { createMockInstanceSetttings } from './mocks/instanceSettings'; import createMockQuery from './mocks/query'; import { singleVariable } from './mocks/variables'; -import { AzureQueryType } from './types/query'; jest.mock('@grafana/runtime', () => { return { diff --git a/public/app/plugins/datasource/azuremonitor/datasource.ts b/public/app/plugins/datasource/azuremonitor/datasource.ts index 104d4c2de86..4ea7738fffd 100644 --- a/public/app/plugins/datasource/azuremonitor/datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/datasource.ts @@ -17,8 +17,9 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource'; import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource'; import AzureResourceGraphDatasource from './azure_resource_graph/azure_resource_graph_datasource'; +import { AzureQueryType } from './dataquery.gen'; import ResourcePickerData from './resourcePicker/resourcePickerData'; -import { AzureMonitorQuery, AzureQueryType } from './types/query'; +import { AzureMonitorQuery } from './types/query'; import { AzureMonitorDataSourceJsonData } from './types/types'; import migrateAnnotation from './utils/migrateAnnotation'; import migrateQuery from './utils/migrateQuery'; diff --git a/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariableFns.ts b/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariableFns.ts index 3e3945da4f2..6e0eaa3c966 100644 --- a/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariableFns.ts +++ b/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariableFns.ts @@ -1,9 +1,8 @@ import { isGUIDish } from './components/ResourcePicker/utils'; -import DataSource from './datasource'; -import { AzureMonitorQuery, AzureQueryType } from './types/query'; import { AppInsightsGroupByQuery, AppInsightsMetricNameQuery, + AzureQueryType, GrafanaTemplateVariableQuery, MetricNamespaceQuery, MetricNamesQuery, @@ -11,7 +10,9 @@ import { ResourceNamesQuery, SubscriptionsQuery, WorkspacesQuery, -} from './types/templateVariables'; +} from './dataquery.gen'; +import DataSource from './datasource'; +import { AzureMonitorQuery } from './types/query'; /* Grafana Template Variable Functions diff --git a/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariables.test.ts b/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariables.test.ts index d5db96929e2..f391e84e17b 100644 --- a/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariables.test.ts +++ b/public/app/plugins/datasource/azuremonitor/grafanaTemplateVariables.test.ts @@ -1,6 +1,7 @@ +import { AzureQueryType } from './dataquery.gen'; import { migrateQuery, migrateStringQueriesToObjectQueries } from './grafanaTemplateVariableFns'; import createMockDatasource from './mocks/datasource'; -import { AzureMonitorQuery, AzureQueryType } from './types/query'; +import { AzureMonitorQuery } from './types/query'; describe('migrateStringQueriesToObjectQueries', () => { const expectedMigrations: Array<{ input: string; output: AzureMonitorQuery }> = [ diff --git a/public/app/plugins/datasource/azuremonitor/mocks/query.ts b/public/app/plugins/datasource/azuremonitor/mocks/query.ts index 6fbfb66b365..1e7662fd575 100644 --- a/public/app/plugins/datasource/azuremonitor/mocks/query.ts +++ b/public/app/plugins/datasource/azuremonitor/mocks/query.ts @@ -1,4 +1,5 @@ -import { AzureMonitorQuery, AzureQueryType, ResultFormat } from '../types/query'; +import { AzureQueryType, ResultFormat } from '../dataquery.gen'; +import { AzureMonitorQuery } from '../types/query'; export default function createMockQuery(overrides?: Partial): AzureMonitorQuery { return { diff --git a/public/app/plugins/datasource/azuremonitor/module.ts b/public/app/plugins/datasource/azuremonitor/module.ts index 5528db67d56..9c96f129cc2 100644 --- a/public/app/plugins/datasource/azuremonitor/module.ts +++ b/public/app/plugins/datasource/azuremonitor/module.ts @@ -4,10 +4,11 @@ import { getAppEvents } from '@grafana/runtime'; import { ConfigEditor } from './components/ConfigEditor/ConfigEditor'; import AzureMonitorQueryEditor from './components/QueryEditor/QueryEditor'; +import { AzureQueryType, ResultFormat } from './dataquery.gen'; import Datasource from './datasource'; import pluginJson from './plugin.json'; import { trackAzureMonitorDashboardLoaded } from './tracking'; -import { AzureMonitorQuery, AzureQueryType, ResultFormat } from './types/query'; +import { AzureMonitorQuery } from './types/query'; import { AzureMonitorDataSourceJsonData } from './types/types'; // don't load plugin translations in test environments diff --git a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts index 64215cc2f59..749b6295e53 100644 --- a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts +++ b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts @@ -13,7 +13,8 @@ import { parseResourceURI, resourceToString, } from '../components/ResourcePicker/utils'; -import { AzureMonitorQuery, AzureMonitorResource } from '../types/query'; +import { AzureMonitorResource } from '../dataquery.gen'; +import { AzureMonitorQuery } from '../types/query'; import { AzureMonitorDataSourceInstanceSettings, AzureMonitorDataSourceJsonData, diff --git a/public/app/plugins/datasource/azuremonitor/types/query.ts b/public/app/plugins/datasource/azuremonitor/types/query.ts index c9098ef91c7..1078e4bcc5e 100644 --- a/public/app/plugins/datasource/azuremonitor/types/query.ts +++ b/public/app/plugins/datasource/azuremonitor/types/query.ts @@ -1,17 +1,5 @@ import { AzureMonitorQuery as AzureMonitorQueryBase, AzureQueryType } from '../dataquery.gen'; -export { AzureQueryType }; -export type { - AzureMetricQuery, - AzureLogsQuery, - AzureResourceGraphQuery, - AzureTracesQuery, - AzureMonitorResource, - AzureMetricDimension, - AzureTracesFilter, -} from '../dataquery.gen'; -export { ResultFormat } from '../dataquery.gen'; - /** * Represents the query as it moves through the frontend query editor and datasource files. * It can represent new queries that are still being edited, so all properties are optional diff --git a/public/app/plugins/datasource/azuremonitor/types/templateVariables.ts b/public/app/plugins/datasource/azuremonitor/types/templateVariables.ts deleted file mode 100644 index 467a8d3a3c9..00000000000 --- a/public/app/plugins/datasource/azuremonitor/types/templateVariables.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type { - GrafanaTemplateVariableQueryType, - UnknownQuery, - AppInsightsMetricNameQuery, - AppInsightsGroupByQuery, - SubscriptionsQuery, - ResourceGroupsQuery, - ResourceNamesQuery, - MetricNamespaceQuery, - MetricDefinitionsQuery, - MetricNamesQuery, - WorkspacesQuery, - GrafanaTemplateVariableQuery, -} from '../dataquery.gen'; diff --git a/public/app/plugins/datasource/azuremonitor/types/types.ts b/public/app/plugins/datasource/azuremonitor/types/types.ts index 99ab54f5b54..7514d3df103 100644 --- a/public/app/plugins/datasource/azuremonitor/types/types.ts +++ b/public/app/plugins/datasource/azuremonitor/types/types.ts @@ -3,10 +3,11 @@ import { EntityGroup, Function, ScalarParameter, TabularParameter } from '@kusto import { AzureDataSourceJsonData, AzureDataSourceSecureJsonData } from '@grafana/azure-sdk'; import { DataSourceInstanceSettings, DataSourceSettings, PanelData, SelectableValue, TimeRange } from '@grafana/data'; +import { ResultFormat } from '../dataquery.gen'; import Datasource from '../datasource'; import { AzureLogAnalyticsMetadataTable } from './logAnalyticsMetadata'; -import { AzureMonitorQuery, ResultFormat } from './query'; +import { AzureMonitorQuery } from './query'; export type AzureMonitorDataSourceSettings = DataSourceSettings< AzureMonitorDataSourceJsonData, diff --git a/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.test.ts b/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.test.ts index b1642f250e2..8c994cbbd88 100644 --- a/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.test.ts +++ b/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.test.ts @@ -1,6 +1,7 @@ import { AnnotationQuery } from '@grafana/data'; -import { AzureMonitorQuery, AzureQueryType } from '../types/query'; +import { AzureQueryType } from '../dataquery.gen'; +import { AzureMonitorQuery } from '../types/query'; import migrateAnnotation from './migrateAnnotation'; diff --git a/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.ts b/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.ts index ce37019e15f..fe8351153b8 100644 --- a/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.ts +++ b/public/app/plugins/datasource/azuremonitor/utils/migrateAnnotation.ts @@ -1,6 +1,7 @@ import { AnnotationQuery } from '@grafana/data'; -import { AzureMonitorQuery, AzureQueryType } from '../types/query'; +import { AzureQueryType } from '../dataquery.gen'; +import { AzureMonitorQuery } from '../types/query'; // The old Angular annotations editor put some properties (rawQuery, workspace, subscription) // on the root annotation object, rather than down in the 'targets' query value diff --git a/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.test.ts b/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.test.ts index 7e5e1769660..2dd3863ba0b 100644 --- a/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.test.ts +++ b/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.test.ts @@ -1,4 +1,5 @@ -import { AzureMetricDimension, AzureMonitorQuery, AzureQueryType, ResultFormat } from '../types/query'; +import { AzureMetricDimension, AzureQueryType, ResultFormat } from '../dataquery.gen'; +import { AzureMonitorQuery } from '../types/query'; import migrateQuery from './migrateQuery'; diff --git a/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.ts b/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.ts index 6ebf853d8fa..6a5d3115133 100644 --- a/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.ts +++ b/public/app/plugins/datasource/azuremonitor/utils/migrateQuery.ts @@ -4,8 +4,9 @@ import { setTimeGrain as setMetricsTimeGrain, } from '../components/MetricsQueryEditor/setQueryValue'; import { parseResourceDetails } from '../components/ResourcePicker/utils'; +import { AzureMetricDimension, AzureQueryType } from '../dataquery.gen'; import TimegrainConverter from '../time_grain_converter'; -import { AzureMetricDimension, AzureMonitorQuery, AzureQueryType } from '../types/query'; +import { AzureMonitorQuery } from '../types/query'; const OLD_DEFAULT_DROPDOWN_VALUE = 'select'; diff --git a/public/app/plugins/datasource/azuremonitor/variables.test.ts b/public/app/plugins/datasource/azuremonitor/variables.test.ts index 55102e3fcfa..fe21e13af55 100644 --- a/public/app/plugins/datasource/azuremonitor/variables.test.ts +++ b/public/app/plugins/datasource/azuremonitor/variables.test.ts @@ -2,9 +2,10 @@ import { from, lastValueFrom } from 'rxjs'; import { DataQueryRequest, toDataFrame } from '@grafana/data'; +import { AzureQueryType } from './dataquery.gen'; import createMockDatasource from './mocks/datasource'; import { invalidSubscriptionError } from './mocks/errors'; -import { AzureMonitorQuery, AzureQueryType } from './types/query'; +import { AzureMonitorQuery } from './types/query'; import { VariableSupport } from './variables'; jest.mock('@grafana/runtime', () => ({ diff --git a/public/app/plugins/datasource/azuremonitor/variables.ts b/public/app/plugins/datasource/azuremonitor/variables.ts index 5477def0e2d..eafef180265 100644 --- a/public/app/plugins/datasource/azuremonitor/variables.ts +++ b/public/app/plugins/datasource/azuremonitor/variables.ts @@ -13,10 +13,10 @@ import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import UrlBuilder from './azure_monitor/url_builder'; import { parseResourceURI } from './components/ResourcePicker/utils'; import VariableEditor from './components/VariableEditor/VariableEditor'; +import { AzureQueryType, GrafanaTemplateVariableQuery } from './dataquery.gen'; import DataSource from './datasource'; import { migrateQuery } from './grafanaTemplateVariableFns'; -import { AzureMonitorQuery, AzureQueryType } from './types/query'; -import { GrafanaTemplateVariableQuery } from './types/templateVariables'; +import { AzureMonitorQuery } from './types/query'; import { RawAzureResourceItem } from './types/types'; import messageFromError from './utils/messageFromError'; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx index 3a7ffccdbb7..ce937f18cf3 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx @@ -11,7 +11,6 @@ import { import { ConfigSection, ConfigSubSection, DataSourceDescription, EditorStack } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; import { - ConnectionLimits, Divider, MaxLifetimeField, MaxOpenConnectionsField, @@ -19,16 +18,16 @@ import { useMigrateDatabaseFields, } from '@grafana/sql'; import { - Input, - Select, - SecretInput, - Field, - Tooltip, - Label, - Icon, - Switch, - SecureSocksProxySettings, Collapse, + Field, + Icon, + Input, + Label, + SecretInput, + SecureSocksProxySettings, + Select, + Switch, + Tooltip, } from '@grafana/ui'; import { PostgresOptions, PostgresTLSMethods, PostgresTLSModes, SecureJsonData } from '../types'; @@ -412,18 +411,15 @@ export const PostgresConfigEditor = (props: DataSourcePluginOptionsEditorProps

    - {config.featureToggles.postgresDSUsePGX ? ( - - - - - ) : ( - - )} + + + + + {config.secureSocksDSProxyEnabled && ( )} diff --git a/public/app/plugins/datasource/loki/dataquery.cue b/public/app/plugins/datasource/loki/dataquery.cue index 63dd1c0265a..0b4d4968db9 100644 --- a/public/app/plugins/datasource/loki/dataquery.cue +++ b/public/app/plugins/datasource/loki/dataquery.cue @@ -42,6 +42,14 @@ composableKinds: DataQuery: { instant?: bool // Used to set step value for range queries. step?: string + // The full query plan for split/shard queries. Encoded and sent to Loki via `X-Loki-Query-Limits-Context` header. Requires "lokiQueryLimitsContext" feature flag + limitsContext?: #LimitsContext + + #LimitsContext: { + expr: string + from: int64 + to: int64 + } #QueryEditorMode: "code" | "builder" @cuetsy(kind="enum") diff --git a/public/app/plugins/datasource/loki/dataquery.gen.ts b/public/app/plugins/datasource/loki/dataquery.gen.ts index 99047e02221..c52cece76db 100644 --- a/public/app/plugins/datasource/loki/dataquery.gen.ts +++ b/public/app/plugins/datasource/loki/dataquery.gen.ts @@ -48,6 +48,14 @@ export interface LokiDataQuery extends common.DataQuery { * Used to override the name of the series. */ legendFormat?: string; + /** + * The full query plan for split/shard queries. Encoded and sent to Loki via `X-Loki-Query-Limits-Context` header. Requires "lokiQueryLimitsContext" feature flag + */ + limitsContext?: { + expr: string; + from: number; + to: number; + }; /** * Used to limit the number of log rows returned. */ diff --git a/public/app/plugins/datasource/loki/mergeResponses.ts b/public/app/plugins/datasource/loki/mergeResponses.ts index 57697a04d78..5ec5704796c 100644 --- a/public/app/plugins/datasource/loki/mergeResponses.ts +++ b/public/app/plugins/datasource/loki/mergeResponses.ts @@ -36,6 +36,11 @@ function getFrameKey(frame: DataFrame): string | undefined { return frame.refId ?? frame.name; } +/** + * @todo test new response is error, current response is not + * @param currentResponse + * @param newResponse + */ export function combineResponses(currentResponse: DataQueryResponse | null, newResponse: DataQueryResponse) { if (!currentResponse) { return cloneQueryResponse(newResponse); @@ -65,6 +70,7 @@ export function combineResponses(currentResponse: DataQueryResponse | null, newR const mergedErrors = [...(currentResponse.errors ?? []), ...(newResponse.errors ?? [])]; if (mergedErrors.length > 0) { currentResponse.errors = mergedErrors; + currentResponse.state = LoadingState.Error; } // the `.error` attribute is obsolete now, @@ -75,6 +81,7 @@ export function combineResponses(currentResponse: DataQueryResponse | null, newR const mergedError = currentResponse.error ?? newResponse.error; if (mergedError != null) { currentResponse.error = mergedError; + currentResponse.state = LoadingState.Error; } const mergedTraceIds = [...(currentResponse.traceIds ?? []), ...(newResponse.traceIds ?? [])]; diff --git a/public/app/plugins/datasource/loki/mocks/frames.ts b/public/app/plugins/datasource/loki/mocks/frames.ts index 0aeda947306..bcc432078de 100644 --- a/public/app/plugins/datasource/loki/mocks/frames.ts +++ b/public/app/plugins/datasource/loki/mocks/frames.ts @@ -222,6 +222,33 @@ export function getMockFrames() { length: 2, }; + const metricFrameAB: DataFrame = { + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: [1000000, 2000000, 3000000, 4000000], + }, + { + name: 'Value', + type: FieldType.number, + config: {}, + values: [6, 7, 5, 4], + labels: { + level: 'debug', + }, + }, + ], + meta: { + notices: [], + type: DataFrameType.TimeSeriesMulti, + stats: [{ displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 33 }], + }, + length: 4, + }; + const metricFrameC: DataFrame = { refId: 'A', name: 'some-time-series', @@ -305,6 +332,7 @@ export function getMockFrames() { metricFrameA, metricFrameB, metricFrameC, + metricFrameAB, emptyFrame, }; } diff --git a/public/app/plugins/datasource/loki/querySplitting.test.ts b/public/app/plugins/datasource/loki/querySplitting.test.ts index 26abb0423b8..2d04b447005 100644 --- a/public/app/plugins/datasource/loki/querySplitting.test.ts +++ b/public/app/plugins/datasource/loki/querySplitting.test.ts @@ -1,12 +1,13 @@ import { of } from 'rxjs'; -import { DataQueryRequest, dateTime, LoadingState } from '@grafana/data'; +import { DataQueryError, DataQueryRequest, DataQueryResponse, dateTime, LoadingState } from '@grafana/data'; import { config } from '@grafana/runtime'; import { LokiDatasource } from './datasource'; import { createLokiDatasource } from './mocks/datasource'; import { getMockFrames } from './mocks/frames'; import { runSplitQuery } from './querySplitting'; +import { LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX, LOKI_TIMEOUT_ERROR_MSG } from './responseUtils'; import { trackGroupedQueries } from './tracking'; import { LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; @@ -16,6 +17,7 @@ jest.mock('uuid', () => ({ })); const originalShardingFlagState = config.featureToggles.lokiShardSplitting; +const originalLokiQueryLimitsContextState = config.featureToggles.lokiQueryLimitsContext; const originalErr = console.error; beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -26,22 +28,23 @@ beforeAll(() => { callback(); }); config.featureToggles.lokiShardSplitting = false; + config.featureToggles.lokiQueryLimitsContext = true; }); afterAll(() => { jest.mocked(global.setTimeout).mockReset(); config.featureToggles.lokiShardSplitting = originalShardingFlagState; + config.featureToggles.lokiQueryLimitsContext = originalLokiQueryLimitsContextState; console.error = originalErr; }); describe('runSplitQuery()', () => { let datasource: LokiDatasource; + const from = dateTime('2023-02-08T05:00:00.000Z'); + const to = dateTime('2023-02-10T06:00:00.000Z'); const range = { - from: dateTime('2023-02-08T05:00:00.000Z'), - to: dateTime('2023-02-10T06:00:00.000Z'), - raw: { - from: dateTime('2023-02-08T05:00:00.000Z'), - to: dateTime('2023-02-10T06:00:00.000Z'), - }, + from, + to, + raw: { from, to }, }; const createRequest = (targets: LokiQuery[], overrides?: Partial>) => { @@ -94,20 +97,59 @@ describe('runSplitQuery()', () => { }); }); - test('Retries retriable failed requests', async () => { + test('Retries retriable failed requests (timeout)', async () => { jest .mocked(datasource.runQuery) - .mockReturnValueOnce(of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'timeout' }], data: [] })); - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - // 3 days, 3 chunks, 1 retry, 4 requests. + .mockReturnValueOnce( + of({ state: LoadingState.Error, errors: [{ refId: 'A', message: LOKI_TIMEOUT_ERROR_MSG }], data: [] }) + ); + await expect(runSplitQuery(datasource, request)).toEmitValuesWith((response) => { + expect(response[0].state).toBe(LoadingState.Done); + // 3 days, 3 chunks, 1 retry = 4 requests. expect(datasource.runQuery).toHaveBeenCalledTimes(4); }); }); + test('Does not retry max bytes errors (error on initial)', async () => { + jest.mocked(datasource.runQuery).mockReturnValueOnce( + of({ + state: LoadingState.Error, + errors: [{ refId: 'A', message: LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX }], + data: [], + }) + ); + await expect(runSplitQuery(datasource, request)).toEmitValuesWith((response) => { + expect(response[0].state).toBe(LoadingState.Error); + // Stop immediately + expect(datasource.runQuery).toHaveBeenCalledTimes(1); + }); + }); + + test('Does not retry max bytes errors (error on subsequent)', async () => { + const { logFrameA } = getMockFrames(); + jest + .mocked(datasource.runQuery) + .mockReturnValueOnce(of({ data: [logFrameA], refId: 'A' })) + .mockReturnValueOnce( + of({ + state: LoadingState.Error, + errors: [{ refId: 'A', message: LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX }], + data: [], + }) + ); + await expect(runSplitQuery(datasource, request)).toEmitValuesWith((response) => { + expect(response[0].state).toBe(LoadingState.Error); + // Stop immediately after error + expect(datasource.runQuery).toHaveBeenCalledTimes(2); + }); + }); + test('Does not retry failed queries as an option', async () => { jest .mocked(datasource.runQuery) - .mockReturnValueOnce(of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'timeout' }], data: [] })); + .mockReturnValueOnce( + of({ state: LoadingState.Error, errors: [{ refId: 'A', message: LOKI_TIMEOUT_ERROR_MSG }], data: [] }) + ); await expect(runSplitQuery(datasource, request, { disableRetry: true })).toEmitValuesWith(() => { // No retries expect(datasource.runQuery).toHaveBeenCalledTimes(1); @@ -165,6 +207,19 @@ describe('runSplitQuery()', () => { _i: 1676008800000, }), }), + targets: [ + { + expr: 'count_over_time({a="b"}[1m])', + legendFormat: undefined, + refId: 'A', + step: undefined, + limitsContext: { + expr: 'count_over_time({a="b"}[1m])', + from: from.valueOf(), + to: to.valueOf(), + }, + }, + ], }) ); @@ -183,6 +238,15 @@ describe('runSplitQuery()', () => { _i: 1676005140000, }), }), + targets: [ + { + expr: 'count_over_time({a="b"}[1m])', + legendFormat: undefined, + refId: 'A', + step: undefined, + limitsContext: undefined, + }, + ], }) ); @@ -201,6 +265,15 @@ describe('runSplitQuery()', () => { _i: 1675918740000, }), }), + targets: [ + { + expr: 'count_over_time({a="b"}[1m])', + legendFormat: undefined, + refId: 'A', + step: undefined, + limitsContext: undefined, + }, + ], }) ); }); @@ -225,6 +298,19 @@ describe('runSplitQuery()', () => { _i: 1676008800000, }), }), + targets: [ + { + expr: 'count_over_time({a="b"}[1m])', + legendFormat: undefined, + refId: 'A', + step: '10s', + limitsContext: { + expr: 'count_over_time({a="b"}[1m])', + from: from.valueOf(), + to: to.valueOf(), + }, + }, + ], }) ); @@ -243,6 +329,15 @@ describe('runSplitQuery()', () => { _i: 1676005190000, }), }), + targets: [ + { + expr: 'count_over_time({a="b"}[1m])', + legendFormat: undefined, + refId: 'A', + step: '10s', + limitsContext: undefined, + }, + ], }) ); @@ -261,21 +356,87 @@ describe('runSplitQuery()', () => { _i: 1675918790000, }), }), + targets: [ + { + expr: 'count_over_time({a="b"}[1m])', + legendFormat: undefined, + refId: 'A', + step: '10s', + limitsContext: undefined, + }, + ], }) ); }); }); - test('Handles and reports errors', async () => { - jest - .spyOn(datasource, 'runQuery') - .mockReturnValue(of({ state: LoadingState.Error, error: { refId: 'A', message: 'Error' }, data: [] })); + test('Handles and reports 5xx error too many bytes', async () => { + const error: DataQueryError = { + message: `${LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX} ...`, + status: 500, + }; + const response: DataQueryResponse = { + state: LoadingState.Error, + data: [], + errors: [error], + }; + jest.spyOn(datasource, 'runQuery').mockReturnValue(of(response)); await expect(runSplitQuery(datasource, request)).toEmitValuesWith((values) => { expect(values).toHaveLength(1); expect(values[0]).toEqual( - expect.objectContaining({ error: { refId: 'A', message: 'Error' }, state: LoadingState.Streaming }) + expect.objectContaining({ + errors: [error], + state: LoadingState.Error, + }) ); }); + // Errors are not retried + expect(datasource.runQuery).toHaveBeenCalledTimes(1); + }); + test('Handles and reports 4xx errors', async () => { + const error: DataQueryError = { + message: 'BAD REQUEST', + status: 418, + }; + const response: DataQueryResponse = { + state: LoadingState.Error, + data: [], + errors: [error], + }; + jest.spyOn(datasource, 'runQuery').mockReturnValue(of(response)); + await expect(runSplitQuery(datasource, request)).toEmitValuesWith((values) => { + expect(values).toHaveLength(1); + expect(values[0]).toEqual( + expect.objectContaining({ + errors: [error], + state: LoadingState.Error, + }) + ); + }); + // Errors are not retried + expect(datasource.runQuery).toHaveBeenCalledTimes(1); + }); + + test('Handles and reports errors (deprecated error)', async () => { + jest.spyOn(datasource, 'runQuery').mockReturnValue( + of({ + state: LoadingState.Error, + error: { refId: 'A', message: `${LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX} ...` }, + data: [], + key: 'uuid', + }) + ); + await expect(runSplitQuery(datasource, request)).toEmitValuesWith((values) => { + expect(values).toHaveLength(1); + expect(values[0]).toEqual( + expect.objectContaining({ + error: { refId: 'A', message: `${LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX} ...` }, + state: LoadingState.Error, + }) + ); + }); + // Errors are not retried + expect(datasource.runQuery).toHaveBeenCalledTimes(1); }); describe('Hidden and empty queries', () => { diff --git a/public/app/plugins/datasource/loki/querySplitting.ts b/public/app/plugins/datasource/loki/querySplitting.ts index d0fd1678455..89a466b21d0 100644 --- a/public/app/plugins/datasource/loki/querySplitting.ts +++ b/public/app/plugins/datasource/loki/querySplitting.ts @@ -8,16 +8,18 @@ import { DataQueryResponse, DataTopic, dateTime, - rangeUtil, - TimeRange, LoadingState, + rangeUtil, + store, + TimeRange, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { LokiDatasource } from './datasource'; import { splitTimeRange as splitLogsTimeRange } from './logsTimeSplitting'; import { combineResponses } from './mergeResponses'; import { splitTimeRange as splitMetricTimeRange } from './metricTimeSplitting'; -import { isLogsQuery, isQueryWithRangeVariable } from './queryUtils'; +import { addQueryLimitsContext, isLogsQuery, isQueryWithRangeVariable } from './queryUtils'; import { isRetriableError } from './responseUtils'; import { trackGroupedQueries } from './tracking'; import { LokiGroupedRequest, LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; @@ -55,6 +57,10 @@ interface QuerySplittingOptions { * Do not retry failed queries. */ disableRetry?: boolean; + /** + * The current index of all query attempts + */ + shardQueryIndex?: number; } /** @@ -85,6 +91,25 @@ export function adjustTargetsFromResponseState(targets: LokiQuery[], response: D }) .filter((target) => target.maxLines === undefined || target.maxLines > 0); } + +const addLimitsToSplitRequests = (splitQueryIndex: number, shardQueryIndex: number, requests: LokiGroupedRequest[]) => { + // requests has already been mutated + return requests.map((r) => ({ + ...r, + request: { + ...r.request, + targets: r.request.targets.map((t) => { + // @todo if we retry the first request, we will strip out the query limits context + if (splitQueryIndex === 0 && shardQueryIndex === 0) { + // Don't pull from request if it has already been added by `addLimitsToShardGroups` + return t.limitsContext === undefined ? addQueryLimitsContext(t, r.request) : t; + } + return { ...t, limitsContext: undefined }; + }), + }, + })); +}; + export function runSplitGroupedQueries( datasource: LokiDatasource, requests: LokiGroupedRequest[], @@ -99,8 +124,15 @@ export function runSplitGroupedQueries( let subquerySubscription: Subscription | null = null; let retriesMap = new Map(); let retryTimer: ReturnType | null = null; + let splitQueryIndex = 0; + const shardQueryIndex = options.shardQueryIndex ?? 0; const runNextRequest = (subscriber: Subscriber, requestN: number, requestGroup: number) => { + if (config.featureToggles.lokiQueryLimitsContext) { + requests = addLimitsToSplitRequests(splitQueryIndex, shardQueryIndex, requests); + } + + splitQueryIndex++; let retrying = false; if (subquerySubscription != null) { @@ -114,7 +146,10 @@ export function runSplitGroupedQueries( } const done = () => { - mergedResponse.state = LoadingState.Done; + if (mergedResponse.state !== LoadingState.Error) { + mergedResponse.state = LoadingState.Done; + } + subscriber.next(mergedResponse); subscriber.complete(); }; @@ -189,6 +224,10 @@ export function runSplitGroupedQueries( if (!options.skipPartialUpdates) { mergedResponse = updateLoadingFrame(mergedResponse, subRequest, longestPartition, requestN); } + + if (mergedResponse.state === LoadingState.Error) { + done(); + } }, complete: () => { if (retrying) { @@ -301,7 +340,9 @@ export function runSplitQuery( const [logQueries, metricQueries] = partition(normalQueries, (query) => isLogsQuery(query.expr)); request.queryGroupId = uuidv4(); - const oneDayMs = 24 * 60 * 60 * 1000; + // Allow custom split durations for debugging, e.g. `localStorage.setItem('grafana.loki.querySplitInterval', 24 * 60 * 1000) // 1 hour` + const debugSplitDuration = parseInt(store.get('grafana.loki.querySplitInterval'), 10); + const oneDayMs = debugSplitDuration || 24 * 60 * 60 * 1000; const directionPartitionedLogQueries = groupBy(logQueries, (query) => query.direction === LokiQueryDirection.Forward ? LokiQueryDirection.Forward : LokiQueryDirection.Backward ); diff --git a/public/app/plugins/datasource/loki/queryUtils.ts b/public/app/plugins/datasource/loki/queryUtils.ts index 64ad1a8a4ae..a4a895feae5 100644 --- a/public/app/plugins/datasource/loki/queryUtils.ts +++ b/public/app/plugins/datasource/loki/queryUtils.ts @@ -1,6 +1,7 @@ import { SyntaxNode } from '@lezer/common'; import { escapeRegExp } from 'lodash'; +import { DataQueryRequest } from '@grafana/data'; import { parser, LineFilter, @@ -310,6 +311,7 @@ export function getStreamSelectorsFromQuery(query: string): string[] { export function requestSupportsSplitting(allQueries: LokiQuery[]) { const queries = allQueries .filter((query) => !query.hide) + .filter((query) => query.queryType !== LokiQueryType.Instant) .filter((query) => !query.refId.includes('do-not-chunk')) .filter((query) => query.expr); @@ -425,3 +427,21 @@ export const getSelectorForShardValues = (query: string) => { } return ''; }; + +/** + * Adds query plan to shard/split queries + * Must be called after interpolation step! + * + * @param lokiQuery + * @param request + */ +export const addQueryLimitsContext = (lokiQuery: LokiQuery, request: DataQueryRequest) => { + return { + ...lokiQuery, + limitsContext: { + expr: lokiQuery.expr, + from: request.range.from.toDate().getTime(), + to: request.range.to.toDate().getTime(), + }, + }; +}; diff --git a/public/app/plugins/datasource/loki/responseUtils.ts b/public/app/plugins/datasource/loki/responseUtils.ts index 95c9112b91c..ca65e218665 100644 --- a/public/app/plugins/datasource/loki/responseUtils.ts +++ b/public/app/plugins/datasource/loki/responseUtils.ts @@ -4,6 +4,11 @@ import { isBytesString, processLabels } from './languageUtils'; import { isLogLineJSON, isLogLineLogfmt, isLogLinePacked } from './lineParser'; import { LabelType } from './types'; +export const LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX = 'the query would read too many bytes'; +export const LOKI_TIMEOUT_ERROR_MSG = + 'request timed out, decrease the duration of the request or add more label matchers (prefer exact match over regex match) to reduce the amount of data processed'; +export const LOKI_TIMEOUT_ERROR_REGEX = /timeout|timed out/; + export function dataFrameHasLokiError(frame: DataFrame): boolean { const labelSets: Labels[] = frame.fields.find((f) => f.name === 'labels')?.values ?? []; return labelSets.some((labels) => labels.__error__ !== undefined); @@ -134,11 +139,20 @@ export function extractLevelLikeLabelFromDataFrame(frame: DataFrame): string | n export function isRetriableError(errorResponse: DataQueryResponse) { const message = errorResponse.errors - ? (errorResponse.errors[0].message ?? '').toLowerCase() - : (errorResponse.error?.message ?? ''); - if (message.includes('timeout')) { + ? errorResponse.errors + .map((err) => err.message ?? '') + .join() + .toLowerCase() + : (errorResponse.error?.message ?? '').toLowerCase(); + + // max_query_bytes_read exceeded + if (message.includes(LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX)) { + throw new Error(message); + } + if (message.match(LOKI_TIMEOUT_ERROR_REGEX)) { return true; - } else if (errorResponse.data.length > 0 && errorResponse.data[0].fields.length > 0) { + } + if (errorResponse.data.length > 0 && errorResponse.data[0].fields.length > 0) { // Error response but we're receiving data, continue querying. return false; } diff --git a/public/app/plugins/datasource/loki/shardQuerySplitting.test.ts b/public/app/plugins/datasource/loki/shardQuerySplitting.test.ts index 12bb8b04de0..cebee5bee94 100644 --- a/public/app/plugins/datasource/loki/shardQuerySplitting.test.ts +++ b/public/app/plugins/datasource/loki/shardQuerySplitting.test.ts @@ -1,10 +1,12 @@ import { of } from 'rxjs'; import { DataQueryRequest, DataQueryResponse, dateTime, LoadingState } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { LokiDatasource } from './datasource'; import { createLokiDatasource } from './mocks/datasource'; import { getMockFrames } from './mocks/frames'; +import { LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX, LOKI_TIMEOUT_ERROR_MSG } from './responseUtils'; import { runShardSplitQuery } from './shardQuerySplitting'; import { LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; @@ -12,6 +14,8 @@ jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid'), })); +const originalLokiQueryLimitsContextState = config.featureToggles.lokiQueryLimitsContext; + const originalLog = console.log; const originalWarn = console.warn; const originalErr = console.error; @@ -20,20 +24,26 @@ beforeEach(() => { jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); }); +beforeAll(() => { + config.featureToggles.lokiQueryLimitsContext = true; +}); afterAll(() => { console.log = originalLog; console.warn = originalWarn; console.error = originalErr; + config.featureToggles.lokiQueryLimitsContext = originalLokiQueryLimitsContextState; }); describe('runShardSplitQuery()', () => { let datasource: LokiDatasource; + const from = dateTime('2023-02-08T04:00:00.000Z'); + const to = dateTime('2023-02-08T11:00:00.000Z'); const range = { - from: dateTime('2023-02-08T04:00:00.000Z'), - to: dateTime('2023-02-08T11:00:00.000Z'), + from, + to, raw: { - from: dateTime('2023-02-08T04:00:00.000Z'), - to: dateTime('2023-02-08T11:00:00.000Z'), + from, + to, }, }; @@ -139,6 +149,11 @@ describe('runShardSplitQuery()', () => { targets: [ { expr: '{a="b", __stream_shard__=~"20|10"}', + limitsContext: { + expr: `{a="b"}`, + from: from.valueOf(), + to: to.valueOf(), + }, refId: 'A', direction: LokiQueryDirection.Scan, }, @@ -209,6 +224,11 @@ describe('runShardSplitQuery()', () => { targets: [ { expr: '{service_name="test", filter="true", __stream_shard__=~"20|10"}', + limitsContext: { + expr: `{service_name="test", filter="true"}`, + from: from.valueOf(), + to: to.valueOf(), + }, refId: 'A', direction: LokiQueryDirection.Scan, }, @@ -227,9 +247,13 @@ describe('runShardSplitQuery()', () => { test('Retries failed retriable requests', async () => { jest.mocked(datasource.languageProvider.fetchLabelValues).mockResolvedValue(['1']); - jest - .spyOn(datasource, 'runQuery') - .mockReturnValueOnce(of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'timeout' }], data: [] })); + jest.spyOn(datasource, 'runQuery').mockReturnValueOnce( + of({ + state: LoadingState.Error, + errors: [{ refId: 'A', message: LOKI_TIMEOUT_ERROR_MSG }], + data: [], + }) + ); // @ts-expect-error jest.spyOn(global, 'setTimeout').mockImplementationOnce((callback) => { callback(); @@ -241,28 +265,73 @@ describe('runShardSplitQuery()', () => { }); }); - test('Failed requests have loading state Error', async () => { - jest.mocked(datasource.languageProvider.fetchLabelValues).mockResolvedValue(['1']); - jest - .spyOn(datasource, 'runQuery') - .mockReturnValue(of({ state: LoadingState.Error, error: { refId: 'A', message: 'parse error' }, data: [] })); - await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith((response: DataQueryResponse[]) => { - expect(response[0].state).toBe(LoadingState.Error); + describe('Errors', () => { + beforeEach(() => { + const querySplittingRange = { + from: dateTime('2023-02-08T05:00:00.000Z'), + to: dateTime('2023-02-10T06:00:00.000Z'), + raw: { + from: dateTime('2023-02-08T05:00:00.000Z'), + to: dateTime('2023-02-10T06:00:00.000Z'), + }, + }; + request = createRequest([{ expr: '$SELECTOR', refId: 'A', direction: LokiQueryDirection.Scan }], { + range: querySplittingRange, + }); + // @ts-expect-error + jest.spyOn(global, 'setTimeout').mockImplementationOnce((callback) => { + callback(); + }); }); - }); - test('Does not retry on other errors', async () => { - jest.mocked(datasource.languageProvider.fetchLabelValues).mockResolvedValue(['1']); - jest - .spyOn(datasource, 'runQuery') - .mockReturnValueOnce(of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'nope nope' }], data: [] })); - // @ts-expect-error - jest.spyOn(global, 'setTimeout').mockImplementationOnce((callback) => { - callback(); - }); - await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith((response: DataQueryResponse[]) => { + test('Failed 4xx responses have loading state Error', async () => { + jest.mocked(datasource.languageProvider.fetchLabelValues).mockResolvedValue(['1', '12', '5']); + jest + .spyOn(datasource, 'runQuery') + .mockReturnValue( + of({ state: LoadingState.Error, error: { refId: 'A', message: 'client error', status: 400 }, data: [] }) + ); + await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith((response: DataQueryResponse[]) => { + expect(response[0].state).toBe(LoadingState.Error); + }); expect(datasource.runQuery).toHaveBeenCalledTimes(1); }); + + test('Max query bytes errors are not retried', async () => { + const errResp: DataQueryResponse = { + state: LoadingState.Error, + errors: [{ refId: 'A', message: `${LOKI_MAX_QUERY_BYTES_READ_ERROR_MSG_PREFIX} ...`, status: 500 }], + data: [], + }; + jest.mocked(datasource.languageProvider.fetchLabelValues).mockResolvedValue(['1', '10', '4']); + jest + .spyOn(datasource, 'runQuery') + .mockReturnValueOnce(of(errResp)) + .mockReturnValueOnce(of({ state: LoadingState.Done, data: [], status: 200 })); + + await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith((response: DataQueryResponse[]) => { + expect(response[0].state).toBe(LoadingState.Error); + }); + + // 5 shards, 3 groups + empty shard group, 4 requests * 3 days, 3 chunks, 3 requests + 1 retriable error = 13 requests + expect(datasource.runQuery).toHaveBeenCalledTimes(1); + }); + + test('Does not retry on other errors', async () => { + jest.mocked(datasource.languageProvider.fetchLabelValues).mockResolvedValue(['1']); + jest + .spyOn(datasource, 'runQuery') + .mockReturnValueOnce( + of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'nope nope' }], data: [] }) + ); + // @ts-expect-error + jest.spyOn(global, 'setTimeout').mockImplementationOnce((callback) => { + callback(); + }); + await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith((response: DataQueryResponse[]) => { + expect(datasource.runQuery).toHaveBeenCalledTimes(1); + }); + }); }); test('Adjusts the group size based on errors and execution time', async () => { @@ -302,9 +371,13 @@ describe('runShardSplitQuery()', () => { ); // sqrt(currentSize) - jest - .mocked(datasource.runQuery) - .mockReturnValueOnce(of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'timeout' }], data: [] })); + jest.mocked(datasource.runQuery).mockReturnValueOnce( + of({ + state: LoadingState.Error, + errors: [{ refId: 'A', message: LOKI_TIMEOUT_ERROR_MSG }], + data: [], + }) + ); // +10% jest.mocked(datasource.runQuery).mockReturnValueOnce( @@ -425,6 +498,11 @@ describe('runShardSplitQuery()', () => { targets: [ { expr: '{a="b", __stream_shard__=~"20|10|9"}', + limitsContext: { + expr: `{a="b"}`, + from: from.valueOf(), + to: to.valueOf(), + }, refId: 'A', direction: LokiQueryDirection.Scan, }, diff --git a/public/app/plugins/datasource/loki/shardQuerySplitting.ts b/public/app/plugins/datasource/loki/shardQuerySplitting.ts index 9cd5207da6d..605f5b9113b 100644 --- a/public/app/plugins/datasource/loki/shardQuerySplitting.ts +++ b/public/app/plugins/datasource/loki/shardQuerySplitting.ts @@ -2,15 +2,20 @@ import { groupBy, partition } from 'lodash'; import { Observable, Subscriber, Subscription } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; -import { DataQueryRequest, LoadingState, DataQueryResponse, QueryResultMetaStat } from '@grafana/data'; +import { DataQueryRequest, DataQueryResponse, LoadingState, QueryResultMetaStat } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { LokiDatasource } from './datasource'; import { combineResponses, replaceResponses } from './mergeResponses'; import { adjustTargetsFromResponseState, runSplitQuery } from './querySplitting'; -import { getSelectorForShardValues, interpolateShardingSelector, requestSupportsSharding } from './queryUtils'; +import { + addQueryLimitsContext, + getSelectorForShardValues, + interpolateShardingSelector, + requestSupportsSharding, +} from './queryUtils'; import { isRetriableError } from './responseUtils'; import { LokiQuery } from './types'; - /** * Query splitting by stream shards. * Query splitting was introduced in Loki to optimize querying for long intervals and high volume of data, @@ -54,6 +59,19 @@ export function runShardSplitQuery(datasource: LokiDatasource, request: DataQuer return splitQueriesByStreamShard(datasource, request, queries); } +const addLimitsToShardGroups = ( + queryIndex: number, + groups: ShardedQueryGroup[], + request: DataQueryRequest +) => { + return groups.map((g) => ({ + ...g, + targets: g.targets.map((t) => { + return queryIndex === 0 ? addQueryLimitsContext(t, request) : { ...t, limitsContext: undefined }; + }), + })); +}; + function splitQueriesByStreamShard( datasource: LokiDatasource, request: DataQueryRequest, @@ -64,8 +82,13 @@ function splitQueriesByStreamShard( let subquerySubscription: Subscription | null = null; let retriesMap = new Map(); let retryTimer: ReturnType | null = null; + let queryIndex = 0; const runNextRequest = (subscriber: Subscriber, group: number, groups: ShardedQueryGroup[]) => { + if (config.featureToggles.lokiQueryLimitsContext) { + groups = addLimitsToShardGroups(queryIndex, groups, request); + } + queryIndex++; let nextGroupSize = groups[group].groupSize; const { shards, groupSize, cycle } = groups[group]; let retrying = false; @@ -164,6 +187,7 @@ function splitQueriesByStreamShard( subquerySubscription = runSplitQuery(datasource, subRequest, { skipPartialUpdates: true, disableRetry: true, + shardQueryIndex: queryIndex - 1, }).subscribe({ next: (partialResponse: DataQueryResponse) => { if ((partialResponse.errors ?? []).length > 0 || partialResponse.error != null) { diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index c2659859b3e..191e503fd90 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -464,7 +464,7 @@ export const LogsPanel = ({ const loadMoreLogs = useCallback( async (scrollRange: AbsoluteTimeRange) => { - if (!data.request || !config.featureToggles.logsInfiniteScrolling || loadingRef.current) { + if (!data.request || loadingRef.current) { return; } diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 4af64af346d..0831cd4845d 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -377,7 +377,7 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats') ), }, - config.featureToggles.onPremToCloudMigrations && { + config.cloudMigrationEnabled && { path: '/admin/migrate-to-cloud', roles: () => contextSrv.evaluatePermission([AccessControlAction.MigrationAssistantMigrate]), component: SafeDynamicImport( diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 70cca883b8b..1e2530c7e6f 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -1994,9 +1994,11 @@ }, "notification-preview": { "alertmanager": "Správce výstrah:", - "disabled-tooltip": "", "error": "Nelze načíst náhled směrování pro {{alertmanager}}", "initialized": "Na základě přidaného štítku jsou instance výstrahy přesměrovány do následujících zásad oznamování. Další podrobnosti zobrazíte rozbalením jednotlivých zásad oznamování níže.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Zobrazit náhled směrování", "select-folder-tooltip": "", "text-loading-preview": "Načítání náhledu…", @@ -5138,8 +5140,12 @@ "new-panel": { "configure-button": "Konfigurovat", "configure-button-menu": "Přepnout nabídku", + "empty-state-message": "", "menu-open-panel-editor": "Konfigurovat", - "menu-use-library-panel": "Použít panel knihovny" + "menu-use-library-panel": "Použít panel knihovny", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Nový panel", "on-create-new-row": { @@ -7266,6 +7272,7 @@ "logs-volume": { "add-filters": "Chcete-li zúžit vyhledávání, přidejte do dotazu další štítky.", "decrease-timerange": "Zkraťte časový rozsah dotazu.", + "max-bytes-error": "", "much-data": "Dotaz se pokouší získat přístup k příliš velkému množství dat. Zkuste jeden nebo více následujících kroků:" }, "maximum-pinned-logs": "Bylo dosaženo maximálního počtu připnutých protokolů: {{PINNED_LOGS_LIMIT}}. Odepněte protokol pro přidání dalšího protokolu.", @@ -7315,12 +7322,15 @@ "tooltip-select-query-visualize-table": "Vyberte dotaz, který chcete vizualizovat v tabulce" }, "logs-volume-panel": { - "content-streaming": "Streamování" + "content-streaming": "Streamování", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Znovu načíst objem protokolu", "label-reload-log-volume": "Znovu načíst objem protokolu", "loading": "Načítání…", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Objem protokolu pro tento dotaz se nepodařilo načíst", "title-no-logs-volume-available": "Není k dispozici žádný objem protokolů", "title-showing-partial-data": "Zobrazují se částečná data", @@ -8661,7 +8671,13 @@ "saving": "Ukládání <1>" }, "axis-builder": { - "linear-threshold": "Lineární práh", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Základ protokolu" }, "builder": { @@ -9067,6 +9083,11 @@ "series-color-picker-popover": { "y-axis-usage": "Použít pravou osu y" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9358,6 +9379,7 @@ "toggle-panel-edit": "Přepnout zobrazení úprav panelu", "toggle-panel-fullscreen": "Přepnout zobrazení panelu na celou obrazovku", "toggle-panel-legend": "Přepnout legendu panelu", + "zoom-in-time-range": "", "zoom-out-time-range": "Oddálit časový rozsah" }, "title": "Zkratky" @@ -10049,11 +10071,7 @@ "wrap-lines": "Zalomit řádky" }, "logs-navigation": { - "newer-logs": "Novější protokoly", - "older-logs": "Starší protokoly", - "scroll-bottom": "Posunout dolů", - "scroll-top": "Posunout nahoru", - "start-of-range": "Začátek rozsahu" + "scroll-top": "Posunout nahoru" }, "logs-panel": { "render-common-labels": { @@ -12648,11 +12666,14 @@ "copy-clipboard": "Kopírovat do schránky", "copy-to-clipboard-and-close": "Kopírovat do schránky a zavřít", "description-name-to-easily-identify-the-token": "Název pro snadnou identifikaci tokenu", + "description-no-expiration-disabled": "", "description-token": "Zkopírujte token teď, protože ho znovu neuvidíte. Ztráta tokenu vyžaduje vytvoření nového tokenu.", "generate-token": "Generování tokenu", "label-display-name": "Zobrazované jméno", "label-expiration": "Expirace", "label-expiration-date": "Datum vypršení platnosti", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index d491c81700b..546fb6800d6 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "Die Weiterleitungsvorschau für {{alertmanager}} konnte nicht geladen werden", "initialized": "Basierend auf den hinzugefügten Labels werden Warninstanzen an die folgenden Benachrichtigungsrichtlinien weitergeleitet. Erweitern Sie unten die einzelnen Benachrichtigungsrichtlinien, um weitere Details anzuzeigen.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Weiterleitungsvorschau", "select-folder-tooltip": "", "text-loading-preview": "Vorschau wird geladen ...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Konfigurieren", "configure-button-menu": "Menü umschalten", + "empty-state-message": "", "menu-open-panel-editor": "Konfigurieren", - "menu-use-library-panel": "Bibliotheks-Panel verwenden" + "menu-use-library-panel": "Bibliotheks-Panel verwenden", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Neues Panel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Fügen Sie Ihrer Abfrage weitere Labels hinzu, um Ihre Suche einzugrenzen.", "decrease-timerange": "Verringern Sie den Zeitbereich Ihrer Abfrage.", + "max-bytes-error": "", "much-data": "Die Abfrage versucht, auf zu viele Daten zuzugreifen. Probieren Sie eine oder mehrere der folgenden Möglichkeiten aus:" }, "maximum-pinned-logs": "Maximale Anzahl von {{PINNED_LOGS_LIMIT}} fixierten Protokollen erreicht. Heben Sie die Fixierung eines Protokolls auf, um ein weiteres hinzuzufügen.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Wählen Sie eine Abfrage für die Visualisierung in der Tabelle" }, "logs-volume-panel": { - "content-streaming": "Streaming" + "content-streaming": "Streaming", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Log-Volumen neu laden", "label-reload-log-volume": "Log-Volumen neu laden", "loading": "Wird geladen ...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Das Log-Volumen für diese Abfrage konnte nicht geladen werden", "title-no-logs-volume-available": "Kein Logs-Volumen verfügbar", "title-showing-partial-data": "Teildaten werden angezeigt", @@ -8589,7 +8599,13 @@ "saving": "<1> wird gespeichert" }, "axis-builder": { - "linear-threshold": "Linearer Schwellenwert", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Logarithmische Basis" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Rechte y-Achse verwenden" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Fenster bearbeiten-Ansicht umschalten", "toggle-panel-fullscreen": "Vollbildansicht des Fensters umschalten", "toggle-panel-legend": "Legende des Fensters umschalten", + "zoom-in-time-range": "", "zoom-out-time-range": "Zeitbereich verkleinern" }, "title": "Shortcuts" @@ -9961,11 +9983,7 @@ "wrap-lines": "Zeilen umbrechen" }, "logs-navigation": { - "newer-logs": "Neuere Protokolle", - "older-logs": "Ältere Protokolle", - "scroll-bottom": "Nach unten scrollen", - "scroll-top": "Nach oben scrollen", - "start-of-range": "Anfang des Bereichs" + "scroll-top": "Nach oben scrollen" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "In die Zwischenablage kopieren", "copy-to-clipboard-and-close": "In Zwischenablage kopieren und schließen", "description-name-to-easily-identify-the-token": "Name zur einfachen Identifizierung des Tokens", + "description-no-expiration-disabled": "", "description-token": "Kopieren Sie den Token jetzt, denn Sie werden ihn später nicht mehr sehen können. Wenn Sie einen Token verlieren, müssen Sie einen neuen Token erstellen.", "generate-token": "Token generieren", "label-display-name": "Anzeigename", "label-expiration": "Ablauf", "label-expiration-date": "Ablaufdatum", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d29fe101bd6..874be92a233 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "You don't have sufficient permissions to preview", "error": "Could not load routing preview for {{alertmanager}}", "initialized": "Based on the labels added, alert instances are routed to the following notification policies. Expand each notification policy below to view more details.", + "no-condition-tooltip": "Select a query condition to preview routing", + "permission-warning": "Preview not available", + "permission-warning-message": "You don't have permission to view notification policies. Preview is not available.", "preview-routing": "Preview routing", "select-folder-tooltip": "Select a folder to preview routing", "text-loading-preview": "Loading preview...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configure", "configure-button-menu": "Toggle menu", + "empty-state-message": "Run a query to visualize it here or go to all visualizations to add other panel types", "menu-open-panel-editor": "Configure", - "menu-use-library-panel": "Use library panel" + "menu-use-library-panel": "Use library panel", + "suggestions": { + "empty-state-message": "Run a query to start seeing suggested visualizations" + } }, "new-panel-title": "New panel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Add more labels to your query to narrow down your search.", "decrease-timerange": "Decrease the time range of your query.", + "max-bytes-error": "The query would read too many bytes. Try one or more of the following:", "much-data": "The query is trying to access too much data. Try one or more of the following:" }, "maximum-pinned-logs": "Maximum of {{PINNED_LOGS_LIMIT}} pinned logs reached. Unpin a log to add another.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Select a query to visualize in the table" }, "logs-volume-panel": { - "content-streaming": "Streaming" + "content-streaming": "Streaming", + "partial-data-tips": "Try decreasing the time range or adding more labels to your query." }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Reload log volume", "label-reload-log-volume": "Reload log volume", "loading": "Loading...", + "message-showing-partial-data-max-bytes": "The query would read too many bytes and some sharded requests could not be completed. ", + "message-showing-partial-data-timeout": "The query is trying to access too much data and some sharded requests could not be completed. ", "title-failed-volume-query": "Failed to load log volume for this query", "title-no-logs-volume-available": "No logs volume available", "title-showing-partial-data": "Showing partial data", @@ -8589,7 +8599,13 @@ "saving": "Saving <1>" }, "axis-builder": { - "linear-threshold": "Linear threshold", + "linear-threshold": { + "label": "Linear threshold", + "warning": { + "nan": "Linear threshold must be a number", + "zero": "Linear threshold cannot be zero" + } + }, "log-base": "Log base" }, "builder": { @@ -9291,6 +9307,7 @@ "toggle-panel-edit": "Toggle panel edit view", "toggle-panel-fullscreen": "Toggle panel fullscreen view", "toggle-panel-legend": "Toggle panel legend", + "zoom-in-time-range": "Zoom in time range", "zoom-out-time-range": "Zoom out time range" }, "title": "Shortcuts" @@ -9966,11 +9983,7 @@ "wrap-lines": "Wrap lines" }, "logs-navigation": { - "newer-logs": "Newer logs", - "older-logs": "Older logs", - "scroll-bottom": "Scroll to bottom", - "scroll-top": "Scroll to top", - "start-of-range": "Start of range" + "scroll-top": "Scroll to top" }, "logs-panel": { "render-common-labels": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index adae8f28bf6..35b1730e477 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "No se ha podido cargar la vista previa del enrutamiento para {{alertmanager}}", "initialized": "Según las etiquetas añadidas, las instancias de alerta se enrutan a las siguientes políticas de notificación. Amplía cada política de notificación a continuación para ver más detalles.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Vista previa del enrutamiento", "select-folder-tooltip": "", "text-loading-preview": "Cargando vista previa...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configurar", "configure-button-menu": "Activar o desactivar menú", + "empty-state-message": "", "menu-open-panel-editor": "Configurar", - "menu-use-library-panel": "Usar panel de la librería" + "menu-use-library-panel": "Usar panel de la librería", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Nuevo panel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Añade más etiquetas a tu consulta para acotar la búsqueda.", "decrease-timerange": "Disminuye el intervalo de tiempo de tu consulta.", + "max-bytes-error": "", "much-data": "La consulta está intentando acceder a demasiados datos. Prueba alguna de las siguientes opciones:" }, "maximum-pinned-logs": "Se ha alcanzado el máximo de {{PINNED_LOGS_LIMIT}} registros anclados. Desancla un registro para añadir otro.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Selecciona una consulta para visualizar en la tabla" }, "logs-volume-panel": { - "content-streaming": "Transmisión" + "content-streaming": "Transmisión", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Volver a cargar el volumen de logs", "label-reload-log-volume": "Volver a cargar el volumen de logs", "loading": "Cargando...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Error al cargar el volumen de logs para esta consulta", "title-no-logs-volume-available": "No hay volumen de logs disponible", "title-showing-partial-data": "Mostrando datos parciales", @@ -8589,7 +8599,13 @@ "saving": "Guardando <1>" }, "axis-builder": { - "linear-threshold": "Umbral lineal", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Base de registro" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Usar eje y derecho" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Cambiar la vista de edición del panel", "toggle-panel-fullscreen": "Cambiar la vista de pantalla completa del panel", "toggle-panel-legend": "Cambiar leyenda del panel", + "zoom-in-time-range": "", "zoom-out-time-range": "Reducir el intervalo de tiempo" }, "title": "Accesos directos" @@ -9961,11 +9983,7 @@ "wrap-lines": "Ajustar líneas" }, "logs-navigation": { - "newer-logs": "Registros más recientes", - "older-logs": "Registros más antiguos", - "scroll-bottom": "Desplazarse al final", - "scroll-top": "Desplazarse al inicio", - "start-of-range": "Inicio del intervalo" + "scroll-top": "Desplazarse al inicio" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Copiar al portapapeles", "copy-to-clipboard-and-close": "Copiar al portapapeles y cerrar", "description-name-to-easily-identify-the-token": "Nombre para identificar fácilmente el token", + "description-no-expiration-disabled": "", "description-token": "Copia el token ahora, ya que no lo volverás a ver. Si lo pierdes, deberás crear uno nuevo.", "generate-token": "Generar token", "label-display-name": "Nombre para mostrar", "label-expiration": "Caducidad", "label-expiration-date": "Fecha de vencimiento", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index e0648accd63..74e96034e55 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager :", - "disabled-tooltip": "", "error": "Impossible de charger l'aperçu de l'acheminement pour {{alertmanager}}", "initialized": "En fonction des étiquettes ajoutées, les instances d'alerte sont acheminées vers les politiques de notification suivantes. Développez chaque politique de notification ci-dessous pour afficher plus de détails.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Aperçu de l'acheminement", "select-folder-tooltip": "", "text-loading-preview": "Chargement de l’aperçu...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configurer", "configure-button-menu": "Afficher/Masquer le menu", + "empty-state-message": "", "menu-open-panel-editor": "Configurer", - "menu-use-library-panel": "Utiliser le panneau de bibliothèque" + "menu-use-library-panel": "Utiliser le panneau de bibliothèque", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Nouveau panneau", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Ajoutez d'autres étiquettes à votre requête pour affiner votre recherche.", "decrease-timerange": "Réduisez la plage de temps de votre requête.", + "max-bytes-error": "", "much-data": "La requête tente d'accéder à trop de données. Essayez une ou plusieurs des opérations suivantes :" }, "maximum-pinned-logs": "Nombre maximum de {{PINNED_LOGS_LIMIT}} journaux épinglés atteint. Désépinglez un journal pour en ajouter un autre.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Sélectionner une requête à visualiser dans le tableau" }, "logs-volume-panel": { - "content-streaming": "Diffusion" + "content-streaming": "Diffusion", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Recharger le volume de journaux", "label-reload-log-volume": "Recharger le volume de journal", "loading": "Chargement en cours...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Échec du chargement du volume de journal pour cette requête", "title-no-logs-volume-available": "Aucun volume de journaux disponible", "title-showing-partial-data": "Affichage de données partielles", @@ -8589,7 +8599,13 @@ "saving": "Enregistrement en cours <1>" }, "axis-builder": { - "linear-threshold": "Seuil linéaire", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Base du journal" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Utiliser l'axe y droit" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Activer/Désactiver la vue Édition du panneau", "toggle-panel-fullscreen": "Activer/Désactiver la vue Plein écran", "toggle-panel-legend": "Activer/désactiver la légende du panneau", + "zoom-in-time-range": "", "zoom-out-time-range": "Dézoomer la plage de temps" }, "title": "Raccourcis" @@ -9961,11 +9983,7 @@ "wrap-lines": "Enrouler les lignes" }, "logs-navigation": { - "newer-logs": "Journaux plus récents", - "older-logs": "Journaux plus anciens", - "scroll-bottom": "Faire défiler vers le bas", - "scroll-top": "Retour en haut de page", - "start-of-range": "Jour de début de la plage" + "scroll-top": "Retour en haut de page" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Copier dans le presse-papiers", "copy-to-clipboard-and-close": "Copier dans le presse-papiers et fermer", "description-name-to-easily-identify-the-token": "Nom pour identifier facilement le jeton", + "description-no-expiration-disabled": "", "description-token": "Copiez le jeton maintenant, car vous ne pourrez plus le revoir. Si vous perdez un jeton, vous devrez 'en créer un nouveau.", "generate-token": "Générer un jeton", "label-display-name": "Pseudo", "label-expiration": "Expiration", "label-expiration-date": "Date d’expiration", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Jeton" }, "get-actions-cell": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 80e9b74c7a7..02d75808711 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Riasztáskezelő:", - "disabled-tooltip": "", "error": "Nem sikerült betölteni az irányítási előnézetet ehhez: {{alertmanager}}", "initialized": "A hozzáadott címkék alapján a riasztáspéldányok a következő értesítési házirendekhez vannak irányítva. További részletek megtekintéséhez bontsa ki az alábbi értesítési házirendeket.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Irányítás előnézete", "select-folder-tooltip": "", "text-loading-preview": "Előnézet betöltése…", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Konfigurálás", "configure-button-menu": "Menü átkapcsolása", + "empty-state-message": "", "menu-open-panel-editor": "Konfigurálás", - "menu-use-library-panel": "Könyvtárpanel használata" + "menu-use-library-panel": "Könyvtárpanel használata", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Új panel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Adjon hozzá több címkét a lekérdezéshez a keresés szűkítése érdekében.", "decrease-timerange": "Csökkentse a lekérdezés időtartományát.", + "max-bytes-error": "", "much-data": "A lekérdezés túl sok adathoz próbál hozzáférni. Próbálkozzon a következőkkel:" }, "maximum-pinned-logs": "Elérte a rögzített naplók maximális számát ({{PINNED_LOGS_LIMIT}}). Egy másik hozzáadásához oldja fel egy napló rögzítését.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Jelölje ki a táblázatban megjelenítendő lekérdezést" }, "logs-volume-panel": { - "content-streaming": "Adatfolyam" + "content-streaming": "Adatfolyam", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Naplómennyiség újratöltése", "label-reload-log-volume": "Naplómennyiség újratöltése", "loading": "Betöltés…", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "A lekérdezés naplómennyiségének betöltése nem sikerült", "title-no-logs-volume-available": "Nincs rendelkezésre álló naplómennyiség", "title-showing-partial-data": "Részadatok megjelenítése", @@ -8589,7 +8599,13 @@ "saving": "Mentés: <1>" }, "axis-builder": { - "linear-threshold": "Lineáris küszöbérték", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Naplóbázis" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Jobb y tengely használata" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Panelszerkesztési nézet ki- és bekapcsolása", "toggle-panel-fullscreen": "Panel teljes képernyős nézetének ki- és bekapcsolása", "toggle-panel-legend": "Panel jelmagyarázatának ki- és bekapcsolása", + "zoom-in-time-range": "", "zoom-out-time-range": "Időtartomány kicsinyítése" }, "title": "Billentyűparancsok" @@ -9961,11 +9983,7 @@ "wrap-lines": "Sortörés" }, "logs-navigation": { - "newer-logs": "Újabb naplók", - "older-logs": "Régebbi naplók", - "scroll-bottom": "Görgetés az aljára", - "scroll-top": "Görgetés a tetejére", - "start-of-range": "Tartomány kezdete" + "scroll-top": "Görgetés a tetejére" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Másolás vágólapra", "copy-to-clipboard-and-close": "Másolás vágólapra és bezárás", "description-name-to-easily-identify-the-token": "Név a token egyszerű azonosításához", + "description-no-expiration-disabled": "", "description-token": "Másolja át most a tokent, mert később nem lesz látható. A token elvesztése esetén új tokent kell létrehozni.", "generate-token": "Token generálása", "label-display-name": "Megjelenített név", "label-expiration": "Lejárat", "label-expiration-date": "Lejárati dátum", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 1d0d348f979..d9b58b75791 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -1976,9 +1976,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "Tidak dapat memuat pratinjau perutean untuk {{alertmanager}}", "initialized": "Berdasarkan label yang ditambahkan, instans peringatan dirutekan ke kebijakan pemberitahuan berikut. Perluas setiap kebijakan pemberitahuan di bawah ini untuk melihat detail selengkapnya.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Pratinjau perutean", "select-folder-tooltip": "", "text-loading-preview": "Memuat pratinjau...", @@ -5078,8 +5080,12 @@ "new-panel": { "configure-button": "Konfigurasikan", "configure-button-menu": "Alihkan tombol menu", + "empty-state-message": "", "menu-open-panel-editor": "Konfigurasikan", - "menu-use-library-panel": "Gunakan panel pustaka" + "menu-use-library-panel": "Gunakan panel pustaka", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Panel baru", "on-create-new-row": { @@ -7194,6 +7200,7 @@ "logs-volume": { "add-filters": "Tambahkan lebih banyak label ke kueri Anda untuk mempersempit pencarian Anda.", "decrease-timerange": "Kurangi rentang waktu kueri Anda.", + "max-bytes-error": "", "much-data": "Kueri mencoba mengakses terlalu banyak data. Coba salah satu atau beberapa hal berikut:" }, "maximum-pinned-logs": "Maksimum {{PINNED_LOGS_LIMIT}} log yang disematkan tercapai. Buka sematan log untuk menambahkan log lain.", @@ -7243,12 +7250,15 @@ "tooltip-select-query-visualize-table": "Pilih kueri untuk divisualisasikan pada tabel" }, "logs-volume-panel": { - "content-streaming": "Streaming" + "content-streaming": "Streaming", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Muat ulang volume log", "label-reload-log-volume": "Muat ulang volume log", "loading": "Memuat...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Gagal memuat volume log untuk kueri ini", "title-no-logs-volume-available": "Tidak ada volume log yang tersedia", "title-showing-partial-data": "Menampilkan data parsial", @@ -8553,7 +8563,13 @@ "saving": "Menyimpan <1>" }, "axis-builder": { - "linear-threshold": "Ambang linear", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Basis log" }, "builder": { @@ -8959,6 +8975,11 @@ "series-color-picker-popover": { "y-axis-usage": "Gunakan sumbu y kanan" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9250,6 +9271,7 @@ "toggle-panel-edit": "Alihkan tampilan edit panel", "toggle-panel-fullscreen": "Alihkan tampilan layar penuh panel", "toggle-panel-legend": "Alihkan legenda panel", + "zoom-in-time-range": "", "zoom-out-time-range": "Perkecil rentang waktu" }, "title": "Pintasan" @@ -9917,11 +9939,7 @@ "wrap-lines": "Terapkan wrap pada baris" }, "logs-navigation": { - "newer-logs": "Log baru", - "older-logs": "Log lama", - "scroll-bottom": "Gulir ke bawah", - "scroll-top": "Gulir ke atas", - "start-of-range": "Awal rentang" + "scroll-top": "Gulir ke atas" }, "logs-panel": { "render-common-labels": { @@ -12483,11 +12501,14 @@ "copy-clipboard": "Salin ke papan klip", "copy-to-clipboard-and-close": "Salin ke papan klip dan tutup", "description-name-to-easily-identify-the-token": "Nama untuk mengidentifikasi token dengan mudah", + "description-no-expiration-disabled": "", "description-token": "Salin token sekarang karena Anda tidak akan dapat melihatnya lagi. Kehilangan token membutuhkan pembuatan token baru.", "generate-token": "Buat token", "label-display-name": "Nama tampilan", "label-expiration": "Kedaluwarsa", "label-expiration-date": "Tanggal kedaluwarsa", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 31e179978f2..7ada28c6693 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "Impossibile caricare l'anteprima del routing per {{alertmanager}}", "initialized": "In base alle etichette aggiunte, le istanze di avviso vengono instradate alle seguenti politiche di notifica. Espandi ciascuna politica di notifica qui sotto per visualizzare ulteriori dettagli.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Anteprima routing", "select-folder-tooltip": "", "text-loading-preview": "Caricamento anteprima in corso...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configura", "configure-button-menu": "Attiva/disattiva menu", + "empty-state-message": "", "menu-open-panel-editor": "Configura", - "menu-use-library-panel": "Usa il pannello della libreria" + "menu-use-library-panel": "Usa il pannello della libreria", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Nuovo pannello", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Aggiungi altre etichette alla query per restringere la ricerca.", "decrease-timerange": "Riduci l'intervallo di tempo della query.", + "max-bytes-error": "", "much-data": "La query sta tentando di accedere a troppi dati. Esegui una o più delle operazioni seguenti:" }, "maximum-pinned-logs": "Numero massimo di {{PINNED_LOGS_LIMIT}} registri bloccati raggiunto. Sblocca un registro per aggiungerne un altro.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Seleziona una query da visualizzare nella tabella" }, "logs-volume-panel": { - "content-streaming": "Streaming" + "content-streaming": "Streaming", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Ricarica il volume del registro", "label-reload-log-volume": "Ricarica il volume del registro", "loading": "Caricamento in corso...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Impossibile caricare il volume dei registri per questa query", "title-no-logs-volume-available": "Nessun volume di registri disponibile", "title-showing-partial-data": "Visualizzazione di dati parziali", @@ -8589,7 +8599,13 @@ "saving": "Salvataggio in corso di <1>" }, "axis-builder": { - "linear-threshold": "Soglia lineare", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Base logaritmica" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Usa l'asse y destro" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Attiva/disattiva la vista di modifica del pannello", "toggle-panel-fullscreen": "Attiva/disattiva la vista a schermo intero del pannello", "toggle-panel-legend": "Attiva/disattiva la legenda del pannello", + "zoom-in-time-range": "", "zoom-out-time-range": "Riduci l'intervallo di tempo" }, "title": "Scelte rapide" @@ -9961,11 +9983,7 @@ "wrap-lines": "A capo" }, "logs-navigation": { - "newer-logs": "Registri più recenti", - "older-logs": "Registri meno recenti", - "scroll-bottom": "Scorri verso il basso", - "scroll-top": "Scorri verso l'alto", - "start-of-range": "Inizio dell'intervallo" + "scroll-top": "Scorri verso l'alto" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Copia negli appunti", "copy-to-clipboard-and-close": "Copia negli appunti e chiudi", "description-name-to-easily-identify-the-token": "Nome per identificare facilmente il token", + "description-no-expiration-disabled": "", "description-token": "Copia il token ora perché non potrai più visualizzarlo. La perdita di un token richiede la creazione di uno nuovo.", "generate-token": "Genera token", "label-display-name": "Visualizza nome", "label-expiration": "Scadenza", "label-expiration-date": "Data di scadenza", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 22a4029f62d..5043d63283e 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -1976,9 +1976,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "{{alertmanager}}のルーティングプレビューを読み込めませんでした", "initialized": "追加されたラベルに基づいて、アラートインスタンスは次の通知ポリシーにルーティングされます。詳細を表示するには、以下の各通知ポリシーを展開します。", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "ルーティングのプレビュー", "select-folder-tooltip": "", "text-loading-preview": "プレビュー読み込み中...", @@ -5078,8 +5080,12 @@ "new-panel": { "configure-button": "構成", "configure-button-menu": "メニューの切り替え", + "empty-state-message": "", "menu-open-panel-editor": "構成", - "menu-use-library-panel": "ライブラリパネルを使用" + "menu-use-library-panel": "ライブラリパネルを使用", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "新規パネル", "on-create-new-row": { @@ -7194,6 +7200,7 @@ "logs-volume": { "add-filters": "クエリにラベルを追加して、検索範囲を絞り込んでください。", "decrease-timerange": "クエリの時間範囲を短縮してください。", + "max-bytes-error": "", "much-data": "クエリがアクセスしようとしているデータが多すぎます。次の1つ以上:" }, "maximum-pinned-logs": "ピン固定されたログが最大数、{{PINNED_LOGS_LIMIT}}に達しました。別のログを追加するには、ログのピン留めを解除してください。", @@ -7243,12 +7250,15 @@ "tooltip-select-query-visualize-table": "テーブルで表示するクエリを選択" }, "logs-volume-panel": { - "content-streaming": "ストリーミング" + "content-streaming": "ストリーミング", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "ログ量を再読み込み", "label-reload-log-volume": "ログ量を再読み込み", "loading": "読み込み中...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "このクエリのログ量の読み込みに失敗しました", "title-no-logs-volume-available": "利用可能なログ量がありません", "title-showing-partial-data": "部分的なデータの表示", @@ -8553,7 +8563,13 @@ "saving": "<1>を保存しています" }, "axis-builder": { - "linear-threshold": "線形のしきい値", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "ログベース" }, "builder": { @@ -8959,6 +8975,11 @@ "series-color-picker-popover": { "y-axis-usage": "右y軸を使用" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9250,6 +9271,7 @@ "toggle-panel-edit": "パネル編集ビューを切り替え", "toggle-panel-fullscreen": "パネルの全画面表示を切り替え", "toggle-panel-legend": "パネルの凡例を切り替え", + "zoom-in-time-range": "", "zoom-out-time-range": "時間範囲をズームアウト" }, "title": "ショートカット" @@ -9917,11 +9939,7 @@ "wrap-lines": "行を折り返す" }, "logs-navigation": { - "newer-logs": "新しいログ", - "older-logs": "古いログ", - "scroll-bottom": "一番下までスクロール", - "scroll-top": "一番上までスクロール", - "start-of-range": "範囲の開始日" + "scroll-top": "一番上までスクロール" }, "logs-panel": { "render-common-labels": { @@ -12483,11 +12501,14 @@ "copy-clipboard": "クリップボードにコピー", "copy-to-clipboard-and-close": "クリップボードにコピーして閉じる", "description-name-to-easily-identify-the-token": "トークンを簡単に識別するための名前", + "description-no-expiration-disabled": "", "description-token": "トークンはもう表示できなくなるため、今すぐコピーしてください。トークンを失うと、新しいトークンを作成する必要があります。", "generate-token": "トークンを生成", "label-display-name": "表示名", "label-expiration": "有効期限", "label-expiration-date": "有効期日", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "トークン" }, "get-actions-cell": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 3174cc0be90..0b205d6c624 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -1976,9 +1976,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "{{alertmanager}}의 라우팅 미리보기를 로딩하지 못했습니다.", "initialized": "추가된 라벨을 토대로 경고 인스턴스가 다음 알림 정책으로 라우팅됩니다. 자세한 내용을 보려면 아래의 각 알림 정책을 펼치세요.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "라우팅 미리보기", "select-folder-tooltip": "", "text-loading-preview": "미리 보기 로딩 중...", @@ -5078,8 +5080,12 @@ "new-panel": { "configure-button": "구성", "configure-button-menu": "토글 메뉴", + "empty-state-message": "", "menu-open-panel-editor": "구성", - "menu-use-library-panel": "라이브러리 패널 사용" + "menu-use-library-panel": "라이브러리 패널 사용", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "새 패널", "on-create-new-row": { @@ -7194,6 +7200,7 @@ "logs-volume": { "add-filters": "검색 결과를 좁히려면 쿼리에 라벨을 추가하세요.", "decrease-timerange": "쿼리의 시간 범위를 줄입니다.", + "max-bytes-error": "", "much-data": "쿼리가 너무 많은 데이터에 액세스하려고 합니다. 다음 중 하나 이상의 방법을 시도해 보세요." }, "maximum-pinned-logs": "고정할 수 있는 최대 로그 수({{PINNED_LOGS_LIMIT}})에 도달했습니다. 다른 로그를 추가하려면 기존 로그의 고정을 해제하세요.", @@ -7243,12 +7250,15 @@ "tooltip-select-query-visualize-table": "표에서 시각화할 쿼리 선택" }, "logs-volume-panel": { - "content-streaming": "스트리밍" + "content-streaming": "스트리밍", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "로그 볼륨 다시 로딩", "label-reload-log-volume": "로그 볼륨 다시 로딩", "loading": "로딩 중...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "이 쿼리에 대한 로그 볼륨 로딩 실패", "title-no-logs-volume-available": "사용 가능한 로그 볼륨 없음", "title-showing-partial-data": "부분 데이터 표시 중", @@ -8553,7 +8563,13 @@ "saving": "<1> 저장 중" }, "axis-builder": { - "linear-threshold": "선형 임계값", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "로그 베이스" }, "builder": { @@ -8959,6 +8975,11 @@ "series-color-picker-popover": { "y-axis-usage": "오른쪽 y축 사용" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9250,6 +9271,7 @@ "toggle-panel-edit": "패널 편집 보기 토글", "toggle-panel-fullscreen": "패널 전체 화면 보기 토글", "toggle-panel-legend": "패널 범례 토글", + "zoom-in-time-range": "", "zoom-out-time-range": "시간 범위 확대" }, "title": "단축키" @@ -9917,11 +9939,7 @@ "wrap-lines": "줄 바꿈" }, "logs-navigation": { - "newer-logs": "최근 로그", - "older-logs": "이전 로그", - "scroll-bottom": "맨 아래로 스크롤", - "scroll-top": "맨 위로 스크롤", - "start-of-range": "범위의 시작" + "scroll-top": "맨 위로 스크롤" }, "logs-panel": { "render-common-labels": { @@ -12483,11 +12501,14 @@ "copy-clipboard": "클립보드로 복사", "copy-to-clipboard-and-close": "클립보드로 복사하고 닫기", "description-name-to-easily-identify-the-token": "토큰을 쉽게 식별할 수 있는 이름", + "description-no-expiration-disabled": "", "description-token": "토큰이 다시 표시되지 않으므로 지금 복사해 두세요. 토큰을 분실하면 새 토큰을 생성해야 합니다.", "generate-token": "토큰 생성", "label-display-name": "표시 이름", "label-expiration": "만료", "label-expiration-date": "만료일", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "토큰" }, "get-actions-cell": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index ebef81b6199..fae593379b6 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Waarschuwingsmanager:", - "disabled-tooltip": "", "error": "Kon routeringsvoorbeeld niet laden voor {{alertmanager}}", "initialized": "Op basis van de toegevoegde labels worden waarschuwingsinstanties gerouteerd naar het volgende meldingsbeleid. Vouw elk meldingsbeleid hieronder uit voor meer informatie.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Voorbeeld routering", "select-folder-tooltip": "", "text-loading-preview": "Voorbeeldweergave laden...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configureren", "configure-button-menu": "Navigatiemenu", + "empty-state-message": "", "menu-open-panel-editor": "Configureren", - "menu-use-library-panel": "Bibliotheekpaneel gebruiken" + "menu-use-library-panel": "Bibliotheekpaneel gebruiken", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Nieuw paneel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Voeg meer labels toe aan je query om je zoekopdracht te verfijnen.", "decrease-timerange": "Verkort het tijdsbereik van je query.", + "max-bytes-error": "", "much-data": "De query probeert toegang te krijgen tot te veel gegevens. Probeer een of meer van de volgende oplossingen:" }, "maximum-pinned-logs": "Maximum aantal van {{PINNED_LOGS_LIMIT}} vastgezette logs bereikt. Maak een logboek los om een ander toe te voegen.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Selecteer een query om te visualiseren in de tabel" }, "logs-volume-panel": { - "content-streaming": "Streamen" + "content-streaming": "Streamen", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Logvolume opnieuw laden", "label-reload-log-volume": "Logvolume opnieuw laden", "loading": "Laden...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Kan het logvolume voor deze query niet laden", "title-no-logs-volume-available": "Geen logvolume beschikbaar", "title-showing-partial-data": "Gedeeltelijke gegevens weergeven", @@ -8589,7 +8599,13 @@ "saving": "Opslaan <1>" }, "axis-builder": { - "linear-threshold": "Lineaire drempel", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Logboekbase" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Rechter y-as gebruiken" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Paneelbewerkingsweergave in-/uitschakelen", "toggle-panel-fullscreen": "Paneelweergave volledig scherm in-/uitschakelen", "toggle-panel-legend": "Paneellegenda in-/uitschakelen", + "zoom-in-time-range": "", "zoom-out-time-range": "Tijdsbereik uitzoomen" }, "title": "Snelkoppelingen" @@ -9961,11 +9983,7 @@ "wrap-lines": "Lijnen omsluiten" }, "logs-navigation": { - "newer-logs": "Nieuwere logboeken", - "older-logs": "Oudere logboeken", - "scroll-bottom": "Naar beneden scrollen", - "scroll-top": "Naar boven scrollen", - "start-of-range": "Begin van het bereik" + "scroll-top": "Naar boven scrollen" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Kopiëren naar klembord", "copy-to-clipboard-and-close": "Kopiëren naar klembord en sluiten", "description-name-to-easily-identify-the-token": "Naam om het token gemakkelijk te identificeren", + "description-no-expiration-disabled": "", "description-token": "Kopieer het token nu, want hierna kun je het niet meer zien. Als je een token verliest, moet je een nieuw token maken.", "generate-token": "Token genereren", "label-display-name": "Weergavenaam", "label-expiration": "Vervaldatum", "label-expiration-date": "Vervaldatum", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 4ea5e15c281..581e3de181b 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -1994,9 +1994,11 @@ }, "notification-preview": { "alertmanager": "Menedżer alertów:", - "disabled-tooltip": "", "error": "Nie udało się załadować podglądu przekierowania dla {{alertmanager}}", "initialized": "Instancje alertów są kierowane do następujących zasad powiadomień na podstawie dodanych etykiet. Rozwiń poniższe zasady powiadomień, aby wyświetlić więcej informacji.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Podgląd przekierowania", "select-folder-tooltip": "", "text-loading-preview": "Wczytywanie podglądu…", @@ -5138,8 +5140,12 @@ "new-panel": { "configure-button": "Konfiguruj", "configure-button-menu": "Przełącz menu", + "empty-state-message": "", "menu-open-panel-editor": "Konfiguruj", - "menu-use-library-panel": "Użyj panelu biblioteki" + "menu-use-library-panel": "Użyj panelu biblioteki", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Nowy panel", "on-create-new-row": { @@ -7266,6 +7272,7 @@ "logs-volume": { "add-filters": "Dodaj więcej etykiet do zapytania, aby zawęzić wyszukiwanie.", "decrease-timerange": "Zmniejsz zakres czasu zapytania.", + "max-bytes-error": "", "much-data": "Zapytanie próbuje uzyskać dostęp do zbyt dużej ilości danych. Spróbuj skorzystać z co najmniej jednego z podanych sposobów:" }, "maximum-pinned-logs": "Osiągnięto maksymalną liczbę przypiętych logów: {{PINNED_LOGS_LIMIT}}. Odepnij log, aby dodać kolejny.", @@ -7315,12 +7322,15 @@ "tooltip-select-query-visualize-table": "Wybierz zapytanie do wizualizacji w tabeli" }, "logs-volume-panel": { - "content-streaming": "Strumieniowanie" + "content-streaming": "Strumieniowanie", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Wczytaj ponownie wolumin logów", "label-reload-log-volume": "Wczytaj ponownie wolumin logów", "loading": "Ładowanie…", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Nie udało się załadować woluminu logów dla tego zapytania", "title-no-logs-volume-available": "Brak dostępnego woluminu logów", "title-showing-partial-data": "Wyświetlanie częściowych danych", @@ -8661,7 +8671,13 @@ "saving": "Zapisywanie <1>" }, "axis-builder": { - "linear-threshold": "Próg liniowy", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Baza logów" }, "builder": { @@ -9067,6 +9083,11 @@ "series-color-picker-popover": { "y-axis-usage": "Użyj prawej półosi OY" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9358,6 +9379,7 @@ "toggle-panel-edit": "Przełącz widok edycji panelu", "toggle-panel-fullscreen": "Przełącz widok pełnoekranowy panelu", "toggle-panel-legend": "Przełącz legendę panelu", + "zoom-in-time-range": "", "zoom-out-time-range": "Oddal zakres czasu" }, "title": "Skróty" @@ -10049,11 +10071,7 @@ "wrap-lines": "Zawijaj wiersze" }, "logs-navigation": { - "newer-logs": "Nowsze logi", - "older-logs": "Starsze logi", - "scroll-bottom": "Przewiń w dół", - "scroll-top": "Przewiń w górę", - "start-of-range": "Początek zakresu" + "scroll-top": "Przewiń w górę" }, "logs-panel": { "render-common-labels": { @@ -12648,11 +12666,14 @@ "copy-clipboard": "Kopiuj do schowka", "copy-to-clipboard-and-close": "Kopiuj do schowka i zamknij", "description-name-to-easily-identify-the-token": "Nazwa ułatwiająca identyfikację tokena", + "description-no-expiration-disabled": "", "description-token": "Skopiuj token teraz, ponieważ później nie będzie go można ponownie wyświetlić. Utrata tokena wymaga utworzenia nowego.", "generate-token": "Wygeneruj token", "label-display-name": "Nazwa wyświetlana", "label-expiration": "Wygaśnięcie", "label-expiration-date": "Data ważności", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index c6b347e785f..9fb0c60ec94 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "Não foi possível carregar a visualização de encaminhamento para {{alertmanager}}", "initialized": "Com base nas etiquetas adicionadas, as instâncias de alerta são encaminhadas para as seguintes políticas de notificação. Expanda cada política de notificação abaixo para ver mais detalhes.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Visualização do encaminhamento", "select-folder-tooltip": "", "text-loading-preview": "Carregando pré-visualização…", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configuração", "configure-button-menu": "Ativar ou desativar o menu", + "empty-state-message": "", "menu-open-panel-editor": "Configuração", - "menu-use-library-panel": "Usar painel da biblioteca" + "menu-use-library-panel": "Usar painel da biblioteca", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Novo painel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Adicione mais etiquetas à sua consulta para tornar a pesquisa mais restritiva.", "decrease-timerange": "Diminua o intervalo de tempo da sua consulta.", + "max-bytes-error": "", "much-data": "A consulta está tentando acessar dados demais. Tente uma ou mais das seguintes soluções:" }, "maximum-pinned-logs": "Máximo de {{PINNED_LOGS_LIMIT}} logs fixados atingidos. Remova um dos logs fixados para adicionar outro.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Selecione uma consulta para visualizar na tabela" }, "logs-volume-panel": { - "content-streaming": "Streaming" + "content-streaming": "Streaming", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Recarregar volume de log", "label-reload-log-volume": "Recarregar volume de log", "loading": "Carregando...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Falha ao carregar o volume de log para esta consulta", "title-no-logs-volume-available": "Nenhum volume de logs disponível", "title-showing-partial-data": "Exibindo dados parciais", @@ -8589,7 +8599,13 @@ "saving": "Salvando <1>" }, "axis-builder": { - "linear-threshold": "Limite linear", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Base de log" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Usar o eixo y à direita" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Alternar visualização de edição de painel", "toggle-panel-fullscreen": "Alternar visualização de painel em tela cheia", "toggle-panel-legend": "Alternar legenda de painel", + "zoom-in-time-range": "", "zoom-out-time-range": "Diminuir o intervalo de tempo" }, "title": "Atalhos" @@ -9961,11 +9983,7 @@ "wrap-lines": "Aplicar quebra de linha" }, "logs-navigation": { - "newer-logs": "Logs mais recentes", - "older-logs": "Logs mais antigos", - "scroll-bottom": "Rolar para baixo", - "scroll-top": "Rolar para cima", - "start-of-range": "Início do intervalo" + "scroll-top": "Rolar para cima" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Copiar para a área de transferência", "copy-to-clipboard-and-close": "Copiar para a área de transferência e fechar", "description-name-to-easily-identify-the-token": "Nome para identificar facilmente o token", + "description-no-expiration-disabled": "", "description-token": "Copie o token agora, já que você não será capaz de vê-lo novamente. Perder um token requer criar um novo.", "generate-token": "Gerar token", "label-display-name": "Nome de exibição", "label-expiration": "Validade", "label-expiration-date": "Data de vencimento", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 26db09f82dd..edb3fcc3a16 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "Não foi possível carregar a pré-visualização de encaminhamento para {{alertmanager}}", "initialized": "Com base nas etiquetas adicionadas, as instâncias de alerta são encaminhadas para as seguintes políticas de notificação. Expanda cada política de notificação abaixo para ver mais detalhes.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Pré-visualizar encaminhamento", "select-folder-tooltip": "", "text-loading-preview": "A carregar a pré-visualização...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Configurar", "configure-button-menu": "Alternar menu", + "empty-state-message": "", "menu-open-panel-editor": "Configurar", - "menu-use-library-panel": "Utilizar o painel de biblioteca" + "menu-use-library-panel": "Utilizar o painel de biblioteca", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Novo painel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Adicione mais etiquetas à sua consulta para restringir a sua pesquisa.", "decrease-timerange": "Diminua o intervalo de tempo da sua consulta.", + "max-bytes-error": "", "much-data": "A consulta está a tentar aceder a demasiados dados. Tente um ou mais dos seguintes procedimentos:" }, "maximum-pinned-logs": "Máximo de registos {{PINNED_LOGS_LIMIT}} fixados atingidos. Retire um registo para adicionar outro.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Selecione uma consulta para visualizar na tabela" }, "logs-volume-panel": { - "content-streaming": "Transmissão" + "content-streaming": "Transmissão", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Recarregar volume de registo", "label-reload-log-volume": "Recarregar volume de registo", "loading": "A carregar...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Falha ao carregar o volume de registos para esta consulta", "title-no-logs-volume-available": "Nenhum volume de registos disponível", "title-showing-partial-data": "A mostrar dados parciais", @@ -8589,7 +8599,13 @@ "saving": "A guardar <1>" }, "axis-builder": { - "linear-threshold": "Limite linear", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Base de registo" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Utilizar o eixo y direito" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Alternar vista de edição do painel", "toggle-panel-fullscreen": "Alternar vista de ecrã inteiro do painel", "toggle-panel-legend": "Alternar legenda do painel", + "zoom-in-time-range": "", "zoom-out-time-range": "Diminuir o zoom do intervalo de tempo" }, "title": "Atalhos" @@ -9961,11 +9983,7 @@ "wrap-lines": "Quebra de linhas" }, "logs-navigation": { - "newer-logs": "Registos mais recentes", - "older-logs": "Registos mais antigos", - "scroll-bottom": "Rolar para baixo", - "scroll-top": "Rolar para o topo", - "start-of-range": "Início do intervalo" + "scroll-top": "Rolar para o topo" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Copiar para a área de transferência", "copy-to-clipboard-and-close": "Copiar para a área de transferência e fechar", "description-name-to-easily-identify-the-token": "Nome para identificar facilmente o token", + "description-no-expiration-disabled": "", "description-token": "Copie o token agora, pois não poderá vê-lo novamente. Perder um token requer a criação de um novo.", "generate-token": "Gerar token", "label-display-name": "Nome de exibição", "label-expiration": "Validade", "label-expiration-date": "Data de vencimento", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index dec4add29ca..a7a264ece15 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -1994,9 +1994,11 @@ }, "notification-preview": { "alertmanager": "Обработчик оповещений Alertmanager:", - "disabled-tooltip": "", "error": "Не удалось загрузить предварительный просмотр маршрутизации для {{alertmanager}}", "initialized": "На основе добавленных меток экземпляры оповещений направляются к следующим политикам уведомления. Для просмотра более подробной информации разверните каждую политику уведомления, приведенную ниже.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Предварительный просмотр маршрутизации", "select-folder-tooltip": "", "text-loading-preview": "Загрузка просмотра...", @@ -5138,8 +5140,12 @@ "new-panel": { "configure-button": "Настроить", "configure-button-menu": "Переключить меню", + "empty-state-message": "", "menu-open-panel-editor": "Настроить", - "menu-use-library-panel": "Использовать панель библиотеки" + "menu-use-library-panel": "Использовать панель библиотеки", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Новая панель", "on-create-new-row": { @@ -7266,6 +7272,7 @@ "logs-volume": { "add-filters": "Чтобы сузить область поиска, добавьте больше меток к запросу.", "decrease-timerange": "Уменьшите временной диапазон запроса.", + "max-bytes-error": "", "much-data": "Запрос пытается получить доступ к слишком большому количеству данных. Попробуйте воспользоваться одним из следующих способов." }, "maximum-pinned-logs": "Достигнуто максимальное количество закрепленных журналов ({{PINNED_LOGS_LIMIT}}). Открепите журнал, чтобы добавить другой.", @@ -7315,12 +7322,15 @@ "tooltip-select-query-visualize-table": "Выберите запрос для визуализации в таблице" }, "logs-volume-panel": { - "content-streaming": "Потоковая передача" + "content-streaming": "Потоковая передача", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Перезагрузить объем журналов", "label-reload-log-volume": "Перезагрузить объем журналов", "loading": "Загрузка…", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Не удалось загрузить объем журналов для этого запроса", "title-no-logs-volume-available": "Нет доступного объема журналов", "title-showing-partial-data": "Отображение частичных данных", @@ -8661,7 +8671,13 @@ "saving": "Сохранение <1>" }, "axis-builder": { - "linear-threshold": "Линейный порог", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "База журналов" }, "builder": { @@ -9067,6 +9083,11 @@ "series-color-picker-popover": { "y-axis-usage": "Использовать правую ось y" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9358,6 +9379,7 @@ "toggle-panel-edit": "Включение/выключение представления редактирования панели", "toggle-panel-fullscreen": "Включение/выключение полноэкранного представления панели", "toggle-panel-legend": "Включение/выключение условных обозначений панели", + "zoom-in-time-range": "", "zoom-out-time-range": "Уменьшение масштаба временного диапазона" }, "title": "Сочетания клавиш" @@ -10049,11 +10071,7 @@ "wrap-lines": "Переносить строки" }, "logs-navigation": { - "newer-logs": "Новые журналы", - "older-logs": "Старые журналы", - "scroll-bottom": "Прокрутить вниз", - "scroll-top": "Прокрутить вверх", - "start-of-range": "Начало диапазона" + "scroll-top": "Прокрутить вверх" }, "logs-panel": { "render-common-labels": { @@ -12648,11 +12666,14 @@ "copy-clipboard": "Копировать в буфер обмена", "copy-to-clipboard-and-close": "Копировать в буфер обмена и закрыть", "description-name-to-easily-identify-the-token": "Название для удобной идентификации токена", + "description-no-expiration-disabled": "", "description-token": "Скопируйте токен сейчас, так как вы не сможете увидеть его снова. При потере токена требуется создать новый.", "generate-token": "Создать токен", "label-display-name": "Отображаемое имя", "label-expiration": "Срок действия", "label-expiration-date": "Срок действия", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Токен" }, "get-actions-cell": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 65178bb1cc1..9a1940aa2b4 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Varningshanterare:", - "disabled-tooltip": "", "error": "Kunde inte ladda förhandsgranskning av routing för {{alertmanager}}", "initialized": "Baserat på de etiketter som lagts till dirigeras varningsinstanser till följande aviseringspolicyer. Utöka varje aviseringspolicy nedan för att se mer information.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Förhandsgranska dirigering", "select-folder-tooltip": "", "text-loading-preview": "Laddar förhandsgranskning …", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Konfigurera", "configure-button-menu": "Växla meny", + "empty-state-message": "", "menu-open-panel-editor": "Konfigurera", - "menu-use-library-panel": "Använd bibliotekspanel" + "menu-use-library-panel": "Använd bibliotekspanel", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Ny panel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Lägg till fler etiketter för din fråga för att begränsa din sökning.", "decrease-timerange": "Minska tidsintervallet för din fråga.", + "max-bytes-error": "", "much-data": "Frågan försöker komma åt för mycket data. Prova ett eller flera av följande:" }, "maximum-pinned-logs": "Maximalt antal {{PINNED_LOGS_LIMIT}} fästa loggar har uppnåtts. Frigör en logg om du vill lägga till en annan.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Välj en fråga att visualisera i tabellen" }, "logs-volume-panel": { - "content-streaming": "Streaming" + "content-streaming": "Streaming", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "Ladda om loggvolym", "label-reload-log-volume": "Ladda om loggvolym", "loading": "Laddar …", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Det gick inte att ladda loggvolym för denna fråga", "title-no-logs-volume-available": "Ingen loggvolym tillgänglig", "title-showing-partial-data": "Visar partiella data", @@ -8589,7 +8599,13 @@ "saving": "Sparar <1>" }, "axis-builder": { - "linear-threshold": "Lineär tröskel", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Loggbas" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Använd höger y-axel" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Växla panelredigeringsvy", "toggle-panel-fullscreen": "Växla panelens helskärmsvy", "toggle-panel-legend": "Växla panelförklaring", + "zoom-in-time-range": "", "zoom-out-time-range": "Zooma ut tidsintervall" }, "title": "Genvägar" @@ -9961,11 +9983,7 @@ "wrap-lines": "Radbryt linjer" }, "logs-navigation": { - "newer-logs": "Nyare loggar", - "older-logs": "Äldre loggar", - "scroll-bottom": "Skrolla längst ner", - "scroll-top": "Skrolla till toppen", - "start-of-range": "Start av intervall" + "scroll-top": "Skrolla till toppen" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Kopiera till urklippet", "copy-to-clipboard-and-close": "Kopiera till urklippet och stäng", "description-name-to-easily-identify-the-token": "Namn för att enkelt identifiera token", + "description-no-expiration-disabled": "", "description-token": "Kopiera token nu eftersom du inte kommer att kunna visa den igen. Om du förlorar denna token behöver du skapa en ny.", "generate-token": "Generera token", "label-display-name": "Visningsnamn", "label-expiration": "Utgång", "label-expiration-date": "Utgångsdatum", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Token" }, "get-actions-cell": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 9674e80d4de..a081fdbd8d3 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -1982,9 +1982,11 @@ }, "notification-preview": { "alertmanager": "Uyarı yöneticisi:", - "disabled-tooltip": "", "error": "{{alertmanager}} için yönlendirme ön izlemesi yüklenemedi", "initialized": "Eklenen etiketlere bağlı olarak uyarı örnekleri aşağıdaki bildirim politikalarına yönlendirilir. Daha fazla ayrıntı görmek için her bir bildirim politikasını genişletin.", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "Yönlendirmeyi ön izle", "select-folder-tooltip": "", "text-loading-preview": "Ön izleme yükleniyor...", @@ -5098,8 +5100,12 @@ "new-panel": { "configure-button": "Yapılandır", "configure-button-menu": "Menüyü aç/kapat", + "empty-state-message": "", "menu-open-panel-editor": "Yapılandır", - "menu-use-library-panel": "Kütüphane panelini kullan" + "menu-use-library-panel": "Kütüphane panelini kullan", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "Yeni panel", "on-create-new-row": { @@ -7218,6 +7224,7 @@ "logs-volume": { "add-filters": "Sorgunuzu daraltmak için sorgunuza daha fazla etiket ekleyin.", "decrease-timerange": "Sorgunuzun zaman aralığını kısaltın.", + "max-bytes-error": "", "much-data": "Sorgu çok fazla veriye erişmeye çalışıyor. Aşağıdakilerden biri veya daha fazlasını yerine getirin:" }, "maximum-pinned-logs": "Maksimum {{PINNED_LOGS_LIMIT}} sabitlenmiş günlük ulaşıldı. Başka bir tane eklemek için bir günlüğün sabitlemesini kaldırın.", @@ -7267,12 +7274,15 @@ "tooltip-select-query-visualize-table": "Tabloda görselleştirmek için bir sorgu seçin" }, "logs-volume-panel": { - "content-streaming": "Akış" + "content-streaming": "Akış", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "", "label-reload-log-volume": "Günlük kaydı hacmini yeniden yükle", "loading": "Yükleniyor...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "Bu sorgu için günlük kaydı birimi yüklenemedi", "title-no-logs-volume-available": "Günlük kaydı birimi yok", "title-showing-partial-data": "Kısmi veriler gösteriliyor", @@ -8589,7 +8599,13 @@ "saving": "<1> kaydediliyor" }, "axis-builder": { - "linear-threshold": "Doğrusal eşik", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "Günlük tabanı" }, "builder": { @@ -8995,6 +9011,11 @@ "series-color-picker-popover": { "y-axis-usage": "Sağ Y eksenini kullan" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9286,6 +9307,7 @@ "toggle-panel-edit": "Panel düzenleme görünümünü aç/kapat", "toggle-panel-fullscreen": "Panel tam ekran görünümünü aç/kapat", "toggle-panel-legend": "Panel açıklamasını aç/kapat", + "zoom-in-time-range": "", "zoom-out-time-range": "Zaman aralığını büyüt" }, "title": "Kısayollar" @@ -9961,11 +9983,7 @@ "wrap-lines": "Satırları kaydır" }, "logs-navigation": { - "newer-logs": "Yeni günlükler", - "older-logs": "Eski günlükler", - "scroll-bottom": "En alta kaydır", - "scroll-top": "En üste kaydır", - "start-of-range": "Aralık başlangıcı" + "scroll-top": "En üste kaydır" }, "logs-panel": { "render-common-labels": { @@ -12538,11 +12556,14 @@ "copy-clipboard": "Panoya kopyala", "copy-to-clipboard-and-close": "Panoya kopyala ve kapat", "description-name-to-easily-identify-the-token": "Belirteci kolayca tanımlamak için ad", + "description-no-expiration-disabled": "", "description-token": "Bu noktada belirteci kopyalayın çünkü tekrar göremeyeceksiniz. Belirtecin kaybedilmesi yeni bir belirteç oluşturulmasını gerektirir.", "generate-token": "Belirteç oluştur", "label-display-name": "Görünen ad", "label-expiration": "Son", "label-expiration-date": "Son kullanma tarihi", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "Belirteç" }, "get-actions-cell": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index d0302660f99..481399a0bcb 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1976,9 +1976,11 @@ }, "notification-preview": { "alertmanager": "Alertmanager:", - "disabled-tooltip": "", "error": "无法加载 {{alertmanager}} 的路由预览", "initialized": "根据添加的标签,提醒实例会被路由到以下通知策略。展开下面的每个通知策略以查看更多详细信息。", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "预览路由", "select-folder-tooltip": "", "text-loading-preview": "正在加载预览...", @@ -5078,8 +5080,12 @@ "new-panel": { "configure-button": "配置", "configure-button-menu": "切换菜单", + "empty-state-message": "", "menu-open-panel-editor": "配置", - "menu-use-library-panel": "使用库面板" + "menu-use-library-panel": "使用库面板", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "新面板", "on-create-new-row": { @@ -7194,6 +7200,7 @@ "logs-volume": { "add-filters": "向您的查询添加更多标签以缩小搜索范围。", "decrease-timerange": "缩小查询的时间范围。", + "max-bytes-error": "", "much-data": "该查询正在尝试访问过多的数据。请尝试以下一种或多种方法:" }, "maximum-pinned-logs": "已达到固定日志的最大数量限制 {{PINNED_LOGS_LIMIT}}。取消固定某个日志,以添加另一个日志。", @@ -7243,12 +7250,15 @@ "tooltip-select-query-visualize-table": "选择要在表中可视化的查询" }, "logs-volume-panel": { - "content-streaming": "流媒体传输" + "content-streaming": "流媒体传输", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "重新加载日志卷", "label-reload-log-volume": "重新加载日志卷", "loading": "加载中...", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "加载此查询的日志卷失败", "title-no-logs-volume-available": "没有可用的日志卷", "title-showing-partial-data": "显示部分数据", @@ -8553,7 +8563,13 @@ "saving": "正在保存<1>" }, "axis-builder": { - "linear-threshold": "线性阈值", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "记录基础" }, "builder": { @@ -8959,6 +8975,11 @@ "series-color-picker-popover": { "y-axis-usage": "使用右侧 Y 轴" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9250,6 +9271,7 @@ "toggle-panel-edit": "切换面板编辑视图", "toggle-panel-fullscreen": "切换面板全屏视图", "toggle-panel-legend": "切换面板图例", + "zoom-in-time-range": "", "zoom-out-time-range": "缩放时间范围" }, "title": "快捷键" @@ -9917,11 +9939,7 @@ "wrap-lines": "多行显示" }, "logs-navigation": { - "newer-logs": "新日志", - "older-logs": "旧日志", - "scroll-bottom": "滚动到底部", - "scroll-top": "滚动到顶部", - "start-of-range": "范围的开始" + "scroll-top": "滚动到顶部" }, "logs-panel": { "render-common-labels": { @@ -12483,11 +12501,14 @@ "copy-clipboard": "复制到剪贴板", "copy-to-clipboard-and-close": "复制到剪贴板并关闭", "description-name-to-easily-identify-the-token": "用于轻松识别令牌的名称", + "description-no-expiration-disabled": "", "description-token": "现在就复制令牌,因为您将无法再次看到它。丢失令牌后需要创建一个新令牌。", "generate-token": "生成令牌", "label-display-name": "显示名称", "label-expiration": "到期", "label-expiration-date": "到期日期", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "令牌" }, "get-actions-cell": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index ad6c511d1f8..061e2cb2f50 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -1976,9 +1976,11 @@ }, "notification-preview": { "alertmanager": "警報管理:", - "disabled-tooltip": "", "error": "無法載入路由預覽:{{alertmanager}}", "initialized": "根據新增的標籤,警報執行個體被路由到以下通知策略。展開下方的每個通知政策以檢視更多詳細資訊。", + "no-condition-tooltip": "", + "permission-warning": "", + "permission-warning-message": "", "preview-routing": "預覽路由", "select-folder-tooltip": "", "text-loading-preview": "正在載入預覽…", @@ -5078,8 +5080,12 @@ "new-panel": { "configure-button": "設定", "configure-button-menu": "切換選單", + "empty-state-message": "", "menu-open-panel-editor": "設定", - "menu-use-library-panel": "使用資料庫面板" + "menu-use-library-panel": "使用資料庫面板", + "suggestions": { + "empty-state-message": "" + } }, "new-panel-title": "新的面板", "on-create-new-row": { @@ -7194,6 +7200,7 @@ "logs-volume": { "add-filters": "在您的查詢中新增更多標籤,以縮小搜尋範圍。", "decrease-timerange": "縮小查詢的時間範圍。", + "max-bytes-error": "", "much-data": "查詢嘗試存取的資料過多。請嘗試以下一個或多個方法:" }, "maximum-pinned-logs": "已達到固定日誌的最大值 {{PINNED_LOGS_LIMIT}}。取消固定一個日誌以便新增另一個。", @@ -7243,12 +7250,15 @@ "tooltip-select-query-visualize-table": "選擇要在表格中視覺化呈現的查詢項目" }, "logs-volume-panel": { - "content-streaming": "串流" + "content-streaming": "串流", + "partial-data-tips": "" }, "logs-volume-panel-list": { "aria-label-reload-log-volume": "重新載入紀錄容量", "label-reload-log-volume": "重新載入紀錄容量", "loading": "正在載入…", + "message-showing-partial-data-max-bytes": "", + "message-showing-partial-data-timeout": "", "title-failed-volume-query": "無法載入此查詢的紀錄容量", "title-no-logs-volume-available": "沒有可用的紀錄容量", "title-showing-partial-data": "顯示部分資料", @@ -8553,7 +8563,13 @@ "saving": "正在儲存<1>" }, "axis-builder": { - "linear-threshold": "線性臨界值", + "linear-threshold": { + "label": "", + "warning": { + "nan": "", + "zero": "" + } + }, "log-base": "日誌基礎" }, "builder": { @@ -8959,6 +8975,11 @@ "series-color-picker-popover": { "y-axis-usage": "使用右 y 軸" }, + "sidebar": { + "close": "", + "dock": "", + "undock": "" + }, "slider": { "drag-handle-aria-label": "" }, @@ -9250,6 +9271,7 @@ "toggle-panel-edit": "切換面板編輯檢視", "toggle-panel-fullscreen": "切換面板全螢幕檢視", "toggle-panel-legend": "切換面板圖例", + "zoom-in-time-range": "", "zoom-out-time-range": "縮小時間範圍" }, "title": "捷徑" @@ -9917,11 +9939,7 @@ "wrap-lines": "換行" }, "logs-navigation": { - "newer-logs": "較新的日誌", - "older-logs": "較舊的日誌", - "scroll-bottom": "滾動到底部", - "scroll-top": "捲動回頂部", - "start-of-range": "範圍開始" + "scroll-top": "捲動回頂部" }, "logs-panel": { "render-common-labels": { @@ -12483,11 +12501,14 @@ "copy-clipboard": "複製至剪貼簿", "copy-to-clipboard-and-close": "複製至剪貼簿並關閉", "description-name-to-easily-identify-the-token": "輕鬆識別權杖的名稱", + "description-no-expiration-disabled": "", "description-token": "請立即複製權杖,因為您將無法再次看到它。若您遺失權杖,則需要建立新的權杖。", "generate-token": "產生權杖", "label-display-name": "顯示名稱", "label-expiration": "過期", "label-expiration-date": "過期日期", + "label-no-expiration": "", + "label-set-expiration-date": "", "label-token": "權杖" }, "get-actions-cell": { diff --git a/yarn.lock b/yarn.lock index 1e810d79f5c..778173eec14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1473,7 +1473,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7": version: 7.28.4 resolution: "@babel/runtime@npm:7.28.4" checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163 @@ -2324,15 +2324,15 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.3.6": - version: 2.3.6 - resolution: "@formatjs/ecma402-abstract@npm:2.3.6" +"@formatjs/ecma402-abstract@npm:2.3.4": + version: 2.3.4 + resolution: "@formatjs/ecma402-abstract@npm:2.3.4" dependencies: "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/intl-localematcher": "npm:0.6.2" + "@formatjs/intl-localematcher": "npm:0.6.1" decimal.js: "npm:^10.4.3" tslib: "npm:^2.8.0" - checksum: 10/30b1b5cd6b62ba46245f934429936592df5500bc1b089dc92dd49c826757b873dd92c305dcfe370701e4df6b057bf007782113abb9b65db550d73be4961718bc + checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2 languageName: node linkType: hard @@ -2376,13 +2376,13 @@ __metadata: linkType: hard "@formatjs/intl-durationformat@npm:^0.7.0": - version: 0.7.6 - resolution: "@formatjs/intl-durationformat@npm:0.7.6" + version: 0.7.4 + resolution: "@formatjs/intl-durationformat@npm:0.7.4" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.6" - "@formatjs/intl-localematcher": "npm:0.6.2" + "@formatjs/ecma402-abstract": "npm:2.3.4" + "@formatjs/intl-localematcher": "npm:0.6.1" tslib: "npm:^2.8.0" - checksum: 10/442236ba85bcd9cb7296c43a708271fa09f110b1ca9d5899066d00812fc2965eaeaec6b5240be421b80daba62860352131088449ba0fcd2061f671cec6240f0b + checksum: 10/d62273ecd635475ca91e9b501301f3f396403fa91b584c550734b19b2d194ba1316b27303fed985c1d42ae933d54eb220da6540edfdf376b0d9371ecfd0d4e15 languageName: node linkType: hard @@ -2395,12 +2395,12 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.6.2": - version: 0.6.2 - resolution: "@formatjs/intl-localematcher@npm:0.6.2" +"@formatjs/intl-localematcher@npm:0.6.1": + version: 0.6.1 + resolution: "@formatjs/intl-localematcher@npm:0.6.1" dependencies: tslib: "npm:^2.8.0" - checksum: 10/eb12a7f5367bbecdfafc20d7f005559ce840f420e970f425c5213d35e94e86dfe75bde03464971a26494bf8427d4961269db22ecad2834f2a19d888b5d9cc064 + checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998 languageName: node linkType: hard @@ -3596,11 +3596,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^6.46.0": - version: 6.46.1 - resolution: "@grafana/scenes-react@npm:6.46.1" +"@grafana/scenes-react@npm:6.47.1": + version: 6.47.1 + resolution: "@grafana/scenes-react@npm:6.47.1" dependencies: - "@grafana/scenes": "npm:6.46.1" + "@grafana/scenes": "npm:6.47.1" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3612,7 +3612,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/47007ae998dd52c774f1b53cc1cbd62cbff6c077886ba36381f371454918bd7fbe9ea9373e021a38ba0144e507c8c51a435a2be1aa8250a36c37d7bbfa72fb9d + checksum: 10/dc20f9ee80eaf648665f7449e3ccb3b640a931f8f4a1be89599dce17eb0f52e763e3a603a4d491d9886b3e6cdf2ad3634124252c223315917206200f7cd6da16 languageName: node linkType: hard @@ -3642,9 +3642,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.46.1, @grafana/scenes@npm:^6.46.0": - version: 6.46.1 - resolution: "@grafana/scenes@npm:6.46.1" +"@grafana/scenes@npm:6.47.1": + version: 6.47.1 + resolution: "@grafana/scenes@npm:6.47.1" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3664,7 +3664,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/e72a90833a94307c6a4633d9f637e407f681d2be9fa27938158b0bdbb9520e31a3ff804eaab3f5e146673af5a21822b6a8279b4b8fd2ead79b94e2593e366144 + checksum: 10/bc0c76258955058e7493b04e7cdd5d59dcc4159adf06da0837e992716ea15700b54f8403614df04326350363dc3344fb2602a2e8f7807724571659b4bd95aded languageName: node linkType: hard @@ -4118,13 +4118,20 @@ __metadata: languageName: node linkType: hard -"@inquirer/figures@npm:^1.0.14, @inquirer/figures@npm:^1.0.3": +"@inquirer/figures@npm:^1.0.14": version: 1.0.14 resolution: "@inquirer/figures@npm:1.0.14" checksum: 10/39df361eb607cea5a020d457e25f9c6aee3a1de8975c6295a4b3bfe86ba7e7f7bfbefa6a52b145b1790f2690e5c8f10eb822e5bc764aff7ba00a6cd24eec5a25 languageName: node linkType: hard +"@inquirer/figures@npm:^1.0.3": + version: 1.0.11 + resolution: "@inquirer/figures@npm:1.0.11" + checksum: 10/357ddd2e83718bc3c9189d518b93fd69099af9c860354df9a5ac0ec024cb5df1228ae4608d2de7625624d2adcd047db813f29426a610eaae7b9e449f8c753c6b + languageName: node + linkType: hard + "@inquirer/input@npm:^4.2.5": version: 4.2.5 resolution: "@inquirer/input@npm:4.2.5" @@ -6096,9 +6103,9 @@ __metadata: linkType: hard "@openfeature/core@npm:^1.9.0": - version: 1.9.1 - resolution: "@openfeature/core@npm:1.9.1" - checksum: 10/6099e16b1b4cd6e3c45c05ab4acd44c9cb4ab501b676ab6f3e77f0be1b56abc7506c5629187381333a02975d315d831e0905582435b356d9676255e72a465899 + version: 1.9.0 + resolution: "@openfeature/core@npm:1.9.0" + checksum: 10/c6d20edc09053afd99752fe46d8328158680950bca4b86679f67f79249d7226eea127b31fffdc38e26ecb729f2bab5a4a5a7c1db708ae76b7fbbac68cd56f094 languageName: node linkType: hard @@ -6123,11 +6130,11 @@ __metadata: linkType: hard "@openfeature/web-sdk@npm:^1.6.1": - version: 1.7.1 - resolution: "@openfeature/web-sdk@npm:1.7.1" + version: 1.6.1 + resolution: "@openfeature/web-sdk@npm:1.6.1" peerDependencies: "@openfeature/core": ^1.9.0 - checksum: 10/358cabfddda8bf67a9bd96ce28f0f84a3218416907f8df4f2a58750ee840d164d86a1c859e8c9aa92d132fa28fcf72a0f17ceece798ff8af297d3e1c1e30cdab + checksum: 10/8bd7d1ea386e21cdd7492cab2fd1d2b138b4e6a376a4c0a40244633e5955f6452039bc2633fc5230bd7b494506a4137ba7210d40850634f9618f77a0ee435f9d languageName: node linkType: hard @@ -7107,7 +7114,7 @@ __metadata: languageName: node linkType: hard -"@reduxjs/toolkit@npm:2.10.1, @reduxjs/toolkit@npm:^2.9.0": +"@reduxjs/toolkit@npm:2.10.1": version: 2.10.1 resolution: "@reduxjs/toolkit@npm:2.10.1" dependencies: @@ -7129,6 +7136,28 @@ __metadata: languageName: node linkType: hard +"@reduxjs/toolkit@npm:^2.9.0": + version: 2.9.2 + resolution: "@reduxjs/toolkit@npm:2.9.2" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/utils": "npm:^0.3.0" + immer: "npm:^10.0.3" + redux: "npm:^5.0.1" + redux-thunk: "npm:^3.1.0" + reselect: "npm:^5.1.0" + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 10/7a8fa57086bc44dafe2f04428d6557edad46ee6eb46b2fe80468ebf934a441a7d4d750b50a6d41bc360d9ab61baadb1dddb5a8f556cfac68977f56e832f509a2 + languageName: node + linkType: hard + "@remix-run/router@npm:1.19.1": version: 1.19.1 resolution: "@remix-run/router@npm:1.19.1" @@ -9007,7 +9036,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:1.13.19, @swc/core@npm:^1.10.8, @swc/core@npm:^1.5.22": +"@swc/core@npm:1.13.19": version: 1.13.19 resolution: "@swc/core@npm:1.13.19" dependencies: @@ -9053,7 +9082,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:1.13.3": +"@swc/core@npm:1.13.3, @swc/core@npm:^1.10.8, @swc/core@npm:^1.5.22": version: 1.13.3 resolution: "@swc/core@npm:1.13.3" dependencies: @@ -12287,7 +12316,18 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1, axios@npm:^1.10.0, axios@npm:^1.12.2, axios@npm:^1.6.1, axios@npm:^1.8.3": +"axios@npm:^1, axios@npm:^1.10.0, axios@npm:^1.6.1, axios@npm:^1.8.3": + version: 1.12.2 + resolution: "axios@npm:1.12.2" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10/886a79770594eaad76493fecf90344b567bd956240609b5dcd09bd0afe8d3e6f1ad6d3257a93a483b6192b409d4b673d9515a34619e3e3ed1b2c0ec2a83b20ba + languageName: node + linkType: hard + +"axios@npm:^1.12.2": version: 1.13.2 resolution: "axios@npm:1.13.2" dependencies: @@ -13211,7 +13251,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:5.6.2, chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1, chalk@npm:^5.6.2": +"chalk@npm:5.6.2, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 @@ -13249,6 +13289,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1": + version: 5.4.1 + resolution: "chalk@npm:5.4.1" + checksum: 10/29df3ffcdf25656fed6e95962e2ef86d14dfe03cd50e7074b06bad9ffbbf6089adbb40f75c00744d843685c8d008adaf3aed31476780312553caf07fa86e5bc7 + languageName: node + linkType: hard + "chance@npm:^1.1.13": version: 1.1.13 resolution: "chance@npm:1.1.13" @@ -13852,7 +13899,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:14.0.1, commander@npm:~14.0.0": +"commander@npm:14.0.1": version: 14.0.1 resolution: "commander@npm:14.0.1" checksum: 10/783115e9403caeca29c0fcbd4e0358f70c67760e4e4933f3453fcdd5ddba2ec44173c8da5213d7ce5e404f51c7e71203a42c548164dbe27b668b32a8981577f1 @@ -13929,6 +13976,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:~14.0.0": + version: 14.0.0 + resolution: "commander@npm:14.0.0" + checksum: 10/c05418bfc35a3e8b5c67bd9f75f5b773f386f9b85f83e70e7c926047f270929cb06cf13cd68f387dd6e7e23c6157de8171b28ba606abd3e6256028f1f789becf + languageName: node + linkType: hard + "comment-parser@npm:1.4.1": version: 1.4.1 resolution: "comment-parser@npm:1.4.1" @@ -16176,7 +16230,16 @@ __metadata: languageName: node linkType: hard -"enquirer@npm:^2.3.6, enquirer@npm:^2.4.1": +"enquirer@npm:^2.3.6, enquirer@npm:~2.3.6": + version: 2.3.6 + resolution: "enquirer@npm:2.3.6" + dependencies: + ansi-colors: "npm:^4.1.1" + checksum: 10/751d14f037eb7683997e696fb8d5fe2675e0b0cde91182c128cf598acf3f5bd9005f35f7c2a9109e291140af496ebec237b6dac86067d59a9b44f3688107f426 + languageName: node + linkType: hard + +"enquirer@npm:^2.4.1": version: 2.4.1 resolution: "enquirer@npm:2.4.1" dependencies: @@ -16186,15 +16249,6 @@ __metadata: languageName: node linkType: hard -"enquirer@npm:~2.3.6": - version: 2.3.6 - resolution: "enquirer@npm:2.3.6" - dependencies: - ansi-colors: "npm:^4.1.1" - checksum: 10/751d14f037eb7683997e696fb8d5fe2675e0b0cde91182c128cf598acf3f5bd9005f35f7c2a9109e291140af496ebec237b6dac86067d59a9b44f3688107f426 - languageName: node - linkType: hard - "ensure-posix-path@npm:^1.1.0": version: 1.1.1 resolution: "ensure-posix-path@npm:1.1.1" @@ -18604,19 +18658,19 @@ __metadata: languageName: node linkType: hard -"glob@npm:*": - version: 12.0.0 - resolution: "glob@npm:12.0.0" +"glob@npm:*, glob@npm:11.0.3": + version: 11.0.3 + resolution: "glob@npm:11.0.3" dependencies: foreground-child: "npm:^3.3.1" jackspeak: "npm:^4.1.1" - minimatch: "npm:^10.1.1" + minimatch: "npm:^10.0.3" minipass: "npm:^7.1.2" package-json-from-dist: "npm:^1.0.0" path-scurry: "npm:^2.0.0" bin: glob: dist/esm/bin.mjs - checksum: 10/6e21b3f1f1fa635836d45e54bbe50704884cc3e310e0cc011cfb5429db65a030e12936d99b07e66236370efe45dc8c8b26fa5334dbf555d6f8709e0315c77c30 + checksum: 10/2ae536c1360c0266b523b2bfa6aadc10144a8b7e08869b088e37ac3c27cd30774f82e4bfb291cde796776e878f9e13200c7ff44010eb7054e00f46f649397893 languageName: node linkType: hard @@ -18651,22 +18705,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:11.0.3": - version: 11.0.3 - resolution: "glob@npm:11.0.3" - dependencies: - foreground-child: "npm:^3.3.1" - jackspeak: "npm:^4.1.1" - minimatch: "npm:^10.0.3" - minipass: "npm:^7.1.2" - package-json-from-dist: "npm:^1.0.0" - path-scurry: "npm:^2.0.0" - bin: - glob: dist/esm/bin.mjs - checksum: 10/2ae536c1360c0266b523b2bfa6aadc10144a8b7e08869b088e37ac3c27cd30774f82e4bfb291cde796776e878f9e13200c7ff44010eb7054e00f46f649397893 - languageName: node - linkType: hard - "glob@npm:11.1.0, glob@npm:^11.0.0": version: 11.1.0 resolution: "glob@npm:11.1.0" @@ -18926,8 +18964,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:^6.46.0" - "@grafana/scenes-react": "npm:^6.46.0" + "@grafana/scenes": "npm:6.47.1" + "@grafana/scenes-react": "npm:6.47.1" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" @@ -20051,22 +20089,22 @@ __metadata: linkType: hard "i18next@npm:^23.5.1 || ^24.2.0": - version: 24.2.3 - resolution: "i18next@npm:24.2.3" + version: 24.2.2 + resolution: "i18next@npm:24.2.2" dependencies: - "@babel/runtime": "npm:^7.26.10" + "@babel/runtime": "npm:^7.23.2" peerDependencies: typescript: ^5 peerDependenciesMeta: typescript: optional: true - checksum: 10/6c73d964f2a98b1aa2c2717fe6da66fc265bcbbc5fcd52b2bfff51ff013d30d4f7d7449c4eb7f464d27af43e2e73f2e7f1d46a144d731bd3bdb1385d4c199e4c + checksum: 10/f66ed9e56d9412e59502f5df39163631daf9f1264774732fb21edbd66a528ca7a6b67dc2e2aec95683c6c7956e42c651587a54bd8ee082bd12008880ce6cd326 languageName: node linkType: hard "i18next@npm:^25.0.0, i18next@npm:^25.5.2": - version: 25.6.2 - resolution: "i18next@npm:25.6.2" + version: 25.5.2 + resolution: "i18next@npm:25.5.2" dependencies: "@babel/runtime": "npm:^7.27.6" peerDependencies: @@ -20074,7 +20112,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/bb7f3e200caac72bacaead766287dbad5ed5945430bd9f2be1c14fbccd49cf78ab751f78cf53cd0be3ec063bc4c40837d7d771f7b14d50fcb9022e1ed5d6e72f + checksum: 10/8d52e82386722a228f4465aa5cf39d82bd9861ea9cc8b31d7cc4d22d5e70b2740ee006068b09f486d5273cabd227a2ac14f37d68fab26344524200083f679bcd languageName: node linkType: hard @@ -20169,7 +20207,7 @@ __metadata: languageName: node linkType: hard -"immer@npm:10.2.0, immer@npm:^10.2.0": +"immer@npm:10.2.0, immer@npm:^10.0.3, immer@npm:^10.2.0": version: 10.2.0 resolution: "immer@npm:10.2.0" checksum: 10/d73e218c8f8ffbb39f9290dfafa478b94af73403dcf26b5672eef35233bb30f09ffe231f8a78a6c9cb442968510edd89e851776ec90a5ddfa82cee6db6b35137 @@ -23175,9 +23213,9 @@ __metadata: linkType: hard "lossless-json@npm:^4.1.1": - version: 4.3.0 - resolution: "lossless-json@npm:4.3.0" - checksum: 10/a984a882c79b6e62a917d0202518472c17587bdee002f1427d7ec61115f9fbdeb5f0baa9f0e9fff8d9dbacd17da6b6c910012b2dcab69dd33d151cb7c13d5a37 + version: 4.2.0 + resolution: "lossless-json@npm:4.2.0" + checksum: 10/b29cbf9a90b8f3108219ff410223a90a3a6ad14cf902eed4038571d9421c69ade7077fb59737e1ca20f3f404ddd68fad69be11365b0d79b4b61421b054759551 languageName: node linkType: hard @@ -23652,9 +23690,9 @@ __metadata: linkType: hard "micro-memoize@npm:^4.1.2": - version: 4.2.0 - resolution: "micro-memoize@npm:4.2.0" - checksum: 10/260e27a5c15809f7dc435a89a424d93e49ccca62b743fa96ee72f254264df58c95edacbd847a2899a6d7f8ad0daef188548a8b12add5ccff83e88869a50a762c + version: 4.1.3 + resolution: "micro-memoize@npm:4.1.3" + checksum: 10/4e9c7767911cc76ae9c9779584ec87844437af9446b295a01774640a732c2c7f91944794027f44625031f7330ab7f9147740d0a9fb612680d1d2d858dad43402 languageName: node linkType: hard @@ -26460,6 +26498,15 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.54.1": + version: 1.54.1 + resolution: "playwright-core@npm:1.54.1" + bin: + playwright-core: cli.js + checksum: 10/c0acfb7ecb48e9fb6c22f71298b966244bd4f8659093a798cc7f9a509deee22109560e44f2d507124e7718779640e0b4cb05a05c068b034405418d8740ec31ff + languageName: node + linkType: hard + "playwright-core@npm:1.56.1, playwright-core@npm:>=1.2.0": version: 1.56.1 resolution: "playwright-core@npm:1.56.1" @@ -26469,7 +26516,7 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.56.1, playwright@npm:^1.14.0": +"playwright@npm:1.56.1": version: 1.56.1 resolution: "playwright@npm:1.56.1" dependencies: @@ -26484,6 +26531,21 @@ __metadata: languageName: node linkType: hard +"playwright@npm:^1.14.0": + version: 1.54.1 + resolution: "playwright@npm:1.54.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.54.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/ebad2924b589a4cfedf48288e67b4afc81047222e607321c9452a37610025e6e249bdfeff010c1c70cf9a9d35c0b9e5cb7934a58986f67928dd469d9fde1a035 + languageName: node + linkType: hard + "plop@npm:^4.0.1": version: 4.0.1 resolution: "plop@npm:4.0.1"