Merge branch 'main' of https://github.com/grafana/grafana into kristina/rtk-corr

This commit is contained in:
Kristina Durivage
2025-11-24 08:27:14 -06:00
333 changed files with 10185 additions and 9009 deletions
@@ -11,11 +11,13 @@ import (
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, _ schemaversion.LibraryElementIndexProvider) error {
func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
// Wrap the provider once with 10s caching for all conversions.
// This prevents repeated DB queries across multiple conversion calls while allowing
// the cache to refresh periodically, making it suitable for long-lived singleton usage.
dsIndexProvider = schemaversion.WrapIndexProviderWithCache(dsIndexProvider)
// Wrap library element provider with caching as well
leIndexProvider = schemaversion.WrapLibraryElementProviderWithCache(leIndexProvider)
// v0 conversions
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil),
@@ -26,13 +28,13 @@ func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSo
}
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil),
withConversionMetrics(dashv0.APIVERSION, dashv2alpha1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2alpha1(a.(*dashv0.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider)
return Convert_V0_to_V2alpha1(a.(*dashv0.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil),
withConversionMetrics(dashv0.APIVERSION, dashv2beta1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider)
return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
@@ -46,13 +48,13 @@ func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSo
}
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil),
withConversionMetrics(dashv1.APIVERSION, dashv2alpha1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1beta1_to_V2alpha1(a.(*dashv1.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider)
return Convert_V1beta1_to_V2alpha1(a.(*dashv1.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil),
withConversionMetrics(dashv1.APIVERSION, dashv2beta1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1beta1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider)
return Convert_V1beta1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider, leIndexProvider)
})); err != nil {
return err
}
@@ -33,7 +33,8 @@ import (
func TestConversionMatrixExist(t *testing.T) {
// Initialize the migrator with a test data source provider
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
versions := []metav1.Object{
@@ -86,7 +87,8 @@ func TestDeepCopyValid(t *testing.T) {
func TestDashboardConversionToAllVersions(t *testing.T) {
// Initialize the migrator with a test data source provider
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Set up conversion scheme
@@ -246,7 +248,8 @@ func TestDashboardConversionToAllVersions(t *testing.T) {
func TestMigratedDashboardsConversion(t *testing.T) {
// Initialize the migrator with a test data source provider
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Set up conversion scheme
@@ -381,7 +384,8 @@ func testConversion(t *testing.T, convertedDash metav1.Object, filename, outputD
func TestConversionMetrics(t *testing.T) {
// Initialize migration with test providers
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -509,7 +513,8 @@ func TestConversionMetrics(t *testing.T) {
// TestConversionMetricsWrapper tests the withConversionMetrics wrapper function
func TestConversionMetricsWrapper(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -678,7 +683,8 @@ func TestSchemaVersionExtraction(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Test the schema version extraction logic by creating a wrapper and checking the metrics labels
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -723,7 +729,8 @@ func TestSchemaVersionExtraction(t *testing.T) {
// TestConversionLogging tests that conversion-level logging works correctly
func TestConversionLogging(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
// Create a test registry for metrics
@@ -815,7 +822,8 @@ func TestConversionLogging(t *testing.T) {
// TestConversionLogLevels tests that appropriate log levels are used
func TestConversionLogLevels(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
t.Run("log levels and structured fields verification", func(t *testing.T) {
@@ -887,7 +895,8 @@ func TestConversionLogLevels(t *testing.T) {
// TestConversionLoggingFields tests that all expected fields are included in log messages
func TestConversionLoggingFields(t *testing.T) {
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
leProvider := migrationtestutil.NewLibraryElementProvider()
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
leProvider := migrationtestutil.NewTestLibraryElementProvider()
migration.Initialize(dsProvider, leProvider)
t.Run("verify all log fields are present", func(t *testing.T) {
@@ -0,0 +1,93 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"title": "Library Panel Repeat Options Test Dashboard",
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"tags": ["test", "library-panels", "repeat"],
"schemaVersion": 38,
"panels": [
{
"id": 1,
"title": "Library Panel with Horizontal Repeat",
"type": "library-panel-ref",
"gridPos": {
"x": 0,
"y": 0,
"w": 12,
"h": 8
},
"libraryPanel": {
"uid": "lib-panel-repeat-h",
"name": "Library Panel with Horizontal Repeat"
}
},
{
"id": 2,
"title": "Library Panel with Vertical Repeat",
"type": "library-panel-ref",
"gridPos": {
"x": 0,
"y": 8,
"w": 6,
"h": 4
},
"libraryPanel": {
"uid": "lib-panel-repeat-v",
"name": "Library Panel with Vertical Repeat"
}
},
{
"id": 3,
"title": "Library Panel Instance Override",
"type": "library-panel-ref",
"gridPos": {
"x": 6,
"y": 8,
"w": 12,
"h": 8
},
"libraryPanel": {
"uid": "lib-panel-repeat-h",
"name": "Library Panel with Horizontal Repeat"
},
"repeat": "instance-var",
"repeatDirection": "v",
"maxPerRow": 5
},
{
"id": 4,
"title": "Library Panel without Repeat",
"type": "library-panel-ref",
"gridPos": {
"x": 0,
"y": 12,
"w": 6,
"h": 3
},
"libraryPanel": {
"uid": "lib-panel-no-repeat",
"name": "Library Panel without Repeat"
}
}
],
"time": {
"from": "now-1h",
"to": "now"
},
"templating": {
"list": []
},
"annotations": {
"list": []
},
"links": []
}
}
@@ -0,0 +1,102 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"annotations": {
"list": []
},
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"links": [],
"panels": [
{
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
},
"title": "Library Panel with Horizontal Repeat",
"type": "library-panel-ref"
},
{
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 8
},
"id": 2,
"libraryPanel": {
"name": "Library Panel with Vertical Repeat",
"uid": "lib-panel-repeat-v"
},
"title": "Library Panel with Vertical Repeat",
"type": "library-panel-ref"
},
{
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 8
},
"id": 3,
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
},
"maxPerRow": 5,
"repeat": "instance-var",
"repeatDirection": "v",
"title": "Library Panel Instance Override",
"type": "library-panel-ref"
},
{
"gridPos": {
"h": 3,
"w": 6,
"x": 0,
"y": 12
},
"id": 4,
"libraryPanel": {
"name": "Library Panel without Repeat",
"uid": "lib-panel-no-repeat"
},
"title": "Library Panel without Repeat",
"type": "library-panel-ref"
}
],
"schemaVersion": 38,
"tags": [
"test",
"library-panels",
"repeat"
],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"title": "Library Panel Repeat Options Test Dashboard"
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}
@@ -0,0 +1,169 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"editable": true,
"elements": {
"panel-1": {
"kind": "LibraryPanel",
"spec": {
"id": 1,
"title": "Library Panel with Horizontal Repeat",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-2": {
"kind": "LibraryPanel",
"spec": {
"id": 2,
"title": "Library Panel with Vertical Repeat",
"libraryPanel": {
"name": "Library Panel with Vertical Repeat",
"uid": "lib-panel-repeat-v"
}
}
},
"panel-3": {
"kind": "LibraryPanel",
"spec": {
"id": 3,
"title": "Library Panel Instance Override",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-4": {
"kind": "LibraryPanel",
"spec": {
"id": 4,
"title": "Library Panel without Repeat",
"libraryPanel": {
"name": "Library Panel without Repeat",
"uid": "lib-panel-no-repeat"
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"repeat": {
"mode": "variable",
"value": "server",
"direction": "h",
"maxPerRow": 3
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 6,
"height": 4,
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"repeat": {
"mode": "variable",
"value": "datacenter",
"direction": "v"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 6,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
},
"repeat": {
"mode": "variable",
"value": "instance-var",
"direction": "v",
"maxPerRow": 5
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 12,
"width": 6,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [
"test",
"library-panels",
"repeat"
],
"timeSettings": {
"timezone": "browser",
"from": "now-1h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Library Panel Repeat Options Test Dashboard",
"variables": []
},
"status": {}
}
@@ -0,0 +1,169 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "library-panel-repeat-options-test",
"labels": {
"test": "library-panel-repeat"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
"editable": true,
"elements": {
"panel-1": {
"kind": "LibraryPanel",
"spec": {
"id": 1,
"title": "Library Panel with Horizontal Repeat",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-2": {
"kind": "LibraryPanel",
"spec": {
"id": 2,
"title": "Library Panel with Vertical Repeat",
"libraryPanel": {
"name": "Library Panel with Vertical Repeat",
"uid": "lib-panel-repeat-v"
}
}
},
"panel-3": {
"kind": "LibraryPanel",
"spec": {
"id": 3,
"title": "Library Panel Instance Override",
"libraryPanel": {
"name": "Library Panel with Horizontal Repeat",
"uid": "lib-panel-repeat-h"
}
}
},
"panel-4": {
"kind": "LibraryPanel",
"spec": {
"id": 4,
"title": "Library Panel without Repeat",
"libraryPanel": {
"name": "Library Panel without Repeat",
"uid": "lib-panel-no-repeat"
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"repeat": {
"mode": "variable",
"value": "server",
"direction": "h",
"maxPerRow": 3
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 6,
"height": 4,
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"repeat": {
"mode": "variable",
"value": "datacenter",
"direction": "v"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 6,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
},
"repeat": {
"mode": "variable",
"value": "instance-var",
"direction": "v",
"maxPerRow": 5
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 12,
"width": 6,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [
"test",
"library-panels",
"repeat"
],
"timeSettings": {
"timezone": "browser",
"from": "now-1h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Library Panel Repeat Options Test Dashboard",
"variables": []
},
"status": {}
}
@@ -25,7 +25,7 @@ func Convert_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope co
return nil
}
func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
v1beta1 := &dashv1.Dashboard{}
if err := ConvertDashboard_V0_to_V1beta1(in, v1beta1, scope); err != nil {
out.Status = dashv2alpha1.DashboardStatus{
@@ -48,7 +48,7 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s
return nil
}
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, out, scope, dsIndexProvider); err != nil {
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, out, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2alpha1.DashboardStatus{
Conversion: &dashv2alpha1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv0.VERSION),
@@ -72,7 +72,7 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s
return nil
}
func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
v1beta1 := &dashv1.Dashboard{}
if err := ConvertDashboard_V0_to_V1beta1(in, v1beta1, scope); err != nil {
out.Status = dashv2beta1.DashboardStatus{
@@ -86,7 +86,7 @@ func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, sco
}
v2alpha1 := &dashv2alpha1.Dashboard{}
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, v2alpha1, scope, dsIndexProvider); err != nil {
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, v2alpha1, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2beta1.DashboardStatus{
Conversion: &dashv2beta1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv0.VERSION),
@@ -109,9 +109,9 @@ func TestV0ConversionErrorHandling(t *testing.T) {
case *dashv1.Dashboard:
err = Convert_V0_to_V1beta1(tt.source, target, nil)
case *dashv2alpha1.Dashboard:
err = Convert_V0_to_V2alpha1(tt.source, target, nil, dsProvider)
err = Convert_V0_to_V2alpha1(tt.source, target, nil, dsProvider, leProvider)
case *dashv2beta1.Dashboard:
err = Convert_V0_to_V2beta1(tt.source, target, nil, dsProvider)
err = Convert_V0_to_V2beta1(tt.source, target, nil, dsProvider, leProvider)
default:
t.Fatalf("unexpected target type: %T", target)
}
@@ -192,7 +192,7 @@ func TestV0ConversionErrorPropagation(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
require.Error(t, err, "expected error to be returned on first step failure")
require.NotNil(t, target.Status.Conversion)
@@ -243,7 +243,7 @@ func TestV0ConversionSuccessPaths(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
require.NoError(t, err, "expected successful conversion")
// Layout should be set even on success
@@ -264,7 +264,7 @@ func TestV0ConversionSuccessPaths(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
require.NoError(t, err, "expected successful conversion")
})
@@ -293,7 +293,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
// Convert_V0_to_V2alpha1 doesn't return error, just sets status
require.NoError(t, err, "Convert_V0_to_V2alpha1 doesn't return error")
@@ -327,7 +327,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
// Convert_V0_to_V2alpha1 doesn't return error, just sets status
require.NoError(t, err, "Convert_V0_to_V2alpha1 doesn't return error")
@@ -357,7 +357,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on second step, status should be set
@@ -383,7 +383,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on third step, status should be set
@@ -25,8 +25,8 @@ func Convert_V1beta1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope co
return nil
}
func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, out, scope, dsIndexProvider); err != nil {
func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, out, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2alpha1.DashboardStatus{
Conversion: &dashv2alpha1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv1.VERSION),
@@ -60,9 +60,9 @@ func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboa
return nil
}
func Convert_V1beta1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func Convert_V1beta1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
v2alpha1 := &dashv2alpha1.Dashboard{}
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, v2alpha1, scope, dsIndexProvider); err != nil {
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, v2alpha1, scope, dsIndexProvider, leIndexProvider); err != nil {
out.Status = dashv2beta1.DashboardStatus{
Conversion: &dashv2beta1.DashboardConversionStatus{
StoredVersion: ptr.To(dashv1.VERSION),
@@ -37,7 +37,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2alpha1.Dashboard{}
err := Convert_V1beta1_to_V2alpha1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2alpha1(source, target, nil, dsProvider, leProvider)
// Convert_V1beta1_to_V2alpha1 doesn't return error, just sets status
require.NoError(t, err, "Convert_V1beta1_to_V2alpha1 doesn't return error")
@@ -64,7 +64,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on first step, status should be set with correct StoredVersion
@@ -91,7 +91,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
// May or may not error depending on dashboard content
// But if it does error on second step, status should be set with correct StoredVersion
@@ -117,7 +117,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
}
target := &dashv2beta1.Dashboard{}
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
// Should succeed if dashboard is valid
if err == nil {
@@ -80,7 +80,7 @@ func prepareV1beta1ConversionContext(in *dashv1.Dashboard, dsIndexProvider schem
return ctx, &nsInfo, nil
}
func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
out.ObjectMeta = in.ObjectMeta
out.APIVersion = dashv2alpha1.APIVERSION
out.Kind = in.Kind
@@ -94,10 +94,10 @@ func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha
return fmt.Errorf("failed to prepare conversion context: %w", err)
}
return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider)
return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider, leIndexProvider)
}
func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
// Parse the unstructured spec into a dashboard JSON structure
dashboardJSON, ok := in.Object["dashboard"]
if !ok {
@@ -161,7 +161,7 @@ func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *das
out.Links = transformLinks(dashboard)
// Transform panels to elements and layout
elements, layout, err := transformPanelsToElementsAndLayout(ctx, dashboard, dsIndexProvider)
elements, layout, err := transformPanelsToElementsAndLayout(ctx, dashboard, dsIndexProvider, leIndexProvider)
if err != nil {
return fmt.Errorf("failed to transform panels: %w", err)
}
@@ -387,7 +387,7 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa
// Panel transformation constants
const GRID_ROW_HEIGHT = 1
func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
panels, ok := dashboard["panels"].([]interface{})
if !ok {
// Return empty elements and default grid layout
@@ -415,13 +415,13 @@ func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[strin
}
if hasRowPanels {
return convertToRowsLayout(ctx, panels, dsIndexProvider)
return convertToRowsLayout(ctx, panels, dsIndexProvider, leIndexProvider)
}
return convertToGridLayout(ctx, panels, dsIndexProvider)
return convertToGridLayout(ctx, panels, dsIndexProvider, leIndexProvider)
}
func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
elements := make(map[string]dashv2alpha1.DashboardElement)
items := make([]dashv2alpha1.DashboardGridLayoutItemKind, 0, len(panels))
@@ -437,7 +437,7 @@ func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvi
}
elements[elementName] = element
items = append(items, buildGridItemKind(panelMap, elementName, nil))
items = append(items, buildGridItemKind(ctx, panelMap, elementName, nil, leIndexProvider))
}
layout := dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
@@ -452,7 +452,7 @@ func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvi
return elements, layout, nil
}
func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
elements := make(map[string]dashv2alpha1.DashboardElement)
rows := make([]dashv2alpha1.DashboardRowsLayoutRowKind, 0)
@@ -491,7 +491,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
element, name, err := buildElement(ctx, collapsedPanelMap, dsIndexProvider)
if err == nil {
elements[name] = element
rowElements = append(rowElements, buildGridItemKind(collapsedPanelMap, name, int64Ptr(yOffsetInRows(collapsedPanelMap, legacyRowY))))
rowElements = append(rowElements, buildGridItemKind(ctx, collapsedPanelMap, name, int64Ptr(yOffsetInRows(collapsedPanelMap, legacyRowY)), leIndexProvider))
}
}
}
@@ -512,7 +512,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
if currentRow.Spec.Layout.GridLayoutKind != nil {
currentRow.Spec.Layout.GridLayoutKind.Spec.Items = append(
currentRow.Spec.Layout.GridLayoutKind.Spec.Items,
buildGridItemKind(panelMap, elementName, int64Ptr(yOffsetInRows(panelMap, legacyRowY))),
buildGridItemKind(ctx, panelMap, elementName, int64Ptr(yOffsetInRows(panelMap, legacyRowY)), leIndexProvider),
)
}
} else {
@@ -521,7 +521,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
// The Y position does not matter for the rows layout, but it's used to calculate the position of the panels in the grid layout in the row.
legacyRowY = -1
gridItems := []dashv2alpha1.DashboardGridLayoutItemKind{
buildGridItemKind(panelMap, elementName, int64Ptr(0)),
buildGridItemKind(ctx, panelMap, elementName, int64Ptr(0), leIndexProvider),
}
hideHeader := true
@@ -645,7 +645,7 @@ func buildPanelKind(ctx context.Context, panelMap map[string]interface{}, dsInde
return panelKind, nil
}
func buildGridItemKind(panelMap map[string]interface{}, elementName string, yOverride *int64) dashv2alpha1.DashboardGridLayoutItemKind {
func buildGridItemKind(ctx context.Context, panelMap map[string]interface{}, elementName string, yOverride *int64, leIndexProvider schemaversion.LibraryElementIndexProvider) dashv2alpha1.DashboardGridLayoutItemKind {
// Default grid position (matches frontend PanelModel defaults: w=6, h=3)
x, y, width, height := int64(0), int64(0), int64(6), int64(3)
@@ -677,34 +677,78 @@ func buildGridItemKind(panelMap map[string]interface{}, elementName string, yOve
}
// Handle repeat options
if repeat := schemaversion.GetStringValue(panelMap, "repeat"); repeat != "" {
repeatOptions := &dashv2alpha1.DashboardRepeatOptions{
Mode: "variable",
Value: repeat,
}
// First check if repeat options are set on the panel itself (dashboard instance level)
repeatOptions := getRepeatOptionsFromPanel(panelMap)
if repeatDirection := schemaversion.GetStringValue(panelMap, "repeatDirection"); repeatDirection != "" {
switch repeatDirection {
case "h":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionH
repeatOptions.Direction = &direction
case "v":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionV
repeatOptions.Direction = &direction
// If no repeat options on the panel and it's a library panel, try to get them from the library panel definition
if repeatOptions == nil {
if libraryPanel, ok := panelMap["libraryPanel"].(map[string]interface{}); ok {
libraryPanelUID := schemaversion.GetStringValue(libraryPanel, "uid")
if libraryPanelUID != "" && leIndexProvider != nil {
repeatOptions = getRepeatOptionsFromLibraryPanel(ctx, libraryPanelUID, leIndexProvider)
}
}
}
if maxPerRow := getIntField(panelMap, "maxPerRow", 0); maxPerRow > 0 {
maxPerRowInt64 := int64(maxPerRow)
repeatOptions.MaxPerRow = &maxPerRowInt64
}
if repeatOptions != nil {
item.Spec.Repeat = repeatOptions
}
return item
}
// getRepeatOptionsFromPanel extracts repeat options from a panel map (dashboard instance level)
func getRepeatOptionsFromPanel(panelMap map[string]any) *dashv2alpha1.DashboardRepeatOptions {
repeat := schemaversion.GetStringValue(panelMap, "repeat")
if repeat == "" {
return nil
}
repeatOptions := &dashv2alpha1.DashboardRepeatOptions{
Mode: "variable",
Value: repeat,
}
if repeatDirection := schemaversion.GetStringValue(panelMap, "repeatDirection"); repeatDirection != "" {
switch repeatDirection {
case "h":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionH
repeatOptions.Direction = &direction
case "v":
direction := dashv2alpha1.DashboardRepeatOptionsDirectionV
repeatOptions.Direction = &direction
}
}
if maxPerRow := getIntField(panelMap, "maxPerRow", 0); maxPerRow > 0 {
maxPerRowInt64 := int64(maxPerRow)
repeatOptions.MaxPerRow = &maxPerRowInt64
}
return repeatOptions
}
// getRepeatOptionsFromLibraryPanel retrieves repeat options from a library panel by UID
func getRepeatOptionsFromLibraryPanel(ctx context.Context, libraryPanelUID string, leIndexProvider schemaversion.LibraryElementIndexProvider) *dashv2alpha1.DashboardRepeatOptions {
libraryElements := leIndexProvider.GetLibraryElementInfo(ctx)
// Find the library panel by UID
var libraryPanelModel map[string]any
for _, elem := range libraryElements {
if elem.UID == libraryPanelUID {
libraryPanelModel = elem.Model.Object
break
}
}
if libraryPanelModel == nil {
return nil
}
// Extract repeat options from the library panel model
return getRepeatOptionsFromPanel(libraryPanelModel)
}
func buildRowKind(rowPanelMap map[string]interface{}, elements []dashv2alpha1.DashboardGridLayoutItemKind) *dashv2alpha1.DashboardRowsLayoutRowKind {
collapsed := getBoolField(rowPanelMap, "collapsed", false)
title := schemaversion.GetStringValue(rowPanelMap, "title")
@@ -216,3 +216,60 @@ func MigrateDatasourceNameToRef(nameOrRef interface{}, options map[string]bool,
return nil
}
// cachedLibraryElementProvider wraps a LibraryElementIndexProvider with time-based caching.
// This prevents multiple DB queries during operations that may call GetLibraryElementInfo()
// multiple times (e.g., dashboard conversions with many library panel lookups).
// The cache expires after 10 seconds, allowing it to be used as a long-lived singleton
// while still refreshing periodically.
//
// Thread-safe: Uses sync.RWMutex to guarantee safe concurrent access.
type cachedLibraryElementProvider struct {
provider LibraryElementIndexProvider
mu sync.RWMutex
elements []LibraryElementInfo
cachedAt time.Time
cacheTTL time.Duration
}
// GetLibraryElementInfo returns the cached library elements if they're still valid (< 10s old), otherwise rebuilds the cache.
// Uses RWMutex for efficient concurrent reads when cache is valid.
func (p *cachedLibraryElementProvider) GetLibraryElementInfo(ctx context.Context) []LibraryElementInfo {
// Fast path: check if cache is still valid using read lock
p.mu.RLock()
if p.elements != nil && time.Since(p.cachedAt) < p.cacheTTL {
elements := p.elements
p.mu.RUnlock()
return elements
}
p.mu.RUnlock()
// Slow path: cache expired or not yet built, acquire write lock
p.mu.Lock()
defer p.mu.Unlock()
// Double-check: another goroutine might have refreshed the cache
// while we were waiting for the write lock
if p.elements != nil && time.Since(p.cachedAt) < p.cacheTTL {
return p.elements
}
// Rebuild the cache
p.elements = p.provider.GetLibraryElementInfo(ctx)
p.cachedAt = time.Now()
return p.elements
}
// WrapLibraryElementProviderWithCache wraps a provider to cache library elements with a 10-second TTL.
// Useful for conversions or migrations that may call GetLibraryElementInfo() multiple times.
// The cache expires after 10 seconds, making it suitable for use as a long-lived singleton
// at the top level of dependency injection while still refreshing periodically.
func WrapLibraryElementProviderWithCache(provider LibraryElementIndexProvider) LibraryElementIndexProvider {
if provider == nil {
return nil
}
return &cachedLibraryElementProvider{
provider: provider,
cacheTTL: 10 * time.Second,
}
}
@@ -3,6 +3,8 @@ package schemaversion
import (
"context"
"strconv"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
const (
@@ -34,6 +36,7 @@ type LibraryElementInfo struct {
Type string
Description string
FolderUID string
Model common.Unstructured // JSON model of the library element, used to extract repeat options during migration
}
type LibraryElementIndexProvider interface {
@@ -4,6 +4,7 @@ import (
"context"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// EmptyLibraryElementProvider provides an empty library element list for tests
@@ -187,3 +188,101 @@ func (p *ConfigurableDataSourceProvider) getDevDashboardDataSources() []schemave
},
}
}
// TestLibraryElementProvider provides library elements with models for testing repeat options migration
type TestLibraryElementProvider struct {
elements []schemaversion.LibraryElementInfo
}
// NewTestLibraryElementProvider creates a new test library element provider with sample library panels
func NewTestLibraryElementProvider() *TestLibraryElementProvider {
// Create library panel models with repeat options
libPanelWithRepeatH := map[string]any{
"id": 1,
"type": "timeseries",
"title": "Library Panel with Horizontal Repeat",
"repeat": "server",
"repeatDirection": "h",
"maxPerRow": 3,
"gridPos": map[string]any{
"x": 0,
"y": 0,
"w": 12,
"h": 8,
},
"targets": []any{},
"options": map[string]any{},
}
libPanelWithRepeatV := map[string]any{
"id": 2,
"type": "stat",
"title": "Library Panel with Vertical Repeat",
"repeat": "datacenter",
"repeatDirection": "v",
"gridPos": map[string]any{
"x": 0,
"y": 0,
"w": 6,
"h": 4,
},
"targets": []any{},
"options": map[string]any{},
}
libPanelWithoutRepeat := map[string]any{
"id": 3,
"type": "text",
"title": "Library Panel without Repeat",
"gridPos": map[string]any{
"x": 0,
"y": 0,
"w": 6,
"h": 3,
},
"targets": []any{},
"options": map[string]any{},
}
// Convert models to Unstructured
modelWithRepeatH := v0alpha1.Unstructured{Object: libPanelWithRepeatH}
modelWithRepeatV := v0alpha1.Unstructured{Object: libPanelWithRepeatV}
modelWithoutRepeat := v0alpha1.Unstructured{Object: libPanelWithoutRepeat}
return &TestLibraryElementProvider{
elements: []schemaversion.LibraryElementInfo{
{
UID: "lib-panel-repeat-h",
Name: "Library Panel with Horizontal Repeat",
Kind: 1, // Panel element
Type: "timeseries",
Description: "A library panel with horizontal repeat options",
FolderUID: "",
Model: modelWithRepeatH,
},
{
UID: "lib-panel-repeat-v",
Name: "Library Panel with Vertical Repeat",
Kind: 1, // Panel element
Type: "stat",
Description: "A library panel with vertical repeat options",
FolderUID: "",
Model: modelWithRepeatV,
},
{
UID: "lib-panel-no-repeat",
Name: "Library Panel without Repeat",
Kind: 1, // Panel element
Type: "text",
Description: "A library panel without repeat options",
FolderUID: "",
Model: modelWithoutRepeat,
},
},
}
}
// GetLibraryElementInfo returns the test library elements
func (p *TestLibraryElementProvider) GetLibraryElementInfo(_ context.Context) []schemaversion.LibraryElementInfo {
return p.elements
}
+3 -7
View File
@@ -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
}
@@ -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)
@@ -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
@@ -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"`
}
+11 -11
View File
@@ -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{
+2
View File
@@ -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
+2
View File
@@ -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
@@ -29,7 +29,7 @@ refs:
- pattern: /docs/grafana/
destination: docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#create-access-policies
rbac-role-definitions:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/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
@@ -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.
@@ -62,9 +62,9 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/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
@@ -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 |
@@ -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
+20 -10
View File
@@ -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}`);
}
}
});
}
-10
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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:*",
@@ -306,6 +306,7 @@ export interface GrafanaConfig {
sharedWithMeFolderUID: string;
rootFolderUID: string;
localFileSystemAvailable: boolean;
cloudMigrationEnabled: boolean;
cloudMigrationIsTarget: boolean;
cloudMigrationPollIntervalMs: number;
pluginCatalogURL: string;
@@ -646,7 +646,7 @@ export interface MetricFindValue {
}
export interface DataSourceGetDrilldownsApplicabilityOptions<TQuery extends DataQuery = DataQuery> {
filters: AdHocVariableFilter[];
filters?: AdHocVariableFilter[];
groupByKeys?: string[];
timeRange?: TimeRange;
queries?: TQuery[];
+23 -21
View File
@@ -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
*/
@@ -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',
+1
View File
@@ -241,6 +241,7 @@ export class GrafanaBootConfig {
sharedWithMeFolderUID?: string;
rootFolderUID?: string;
localFileSystemAvailable?: boolean;
cloudMigrationEnabled?: boolean;
cloudMigrationIsTarget?: boolean;
cloudMigrationPollIntervalMs = 2000;
reportingStaticContext?: Record<string, string>;
@@ -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.
*/
@@ -421,6 +421,70 @@ Component used for rendering content wrapped in the same style as grafana panels
</PanelChrome>
</ExampleFrame>
### 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
<PanelChrome
title="My awesome panel title"
subHeaderContent={
<div style={{ display: 'flex', gap: '8px', padding: '4px 8px' }}>
<span style={{ fontSize: '12px', color: '#999' }}>Additional info</span>
</div>
}
width={400}
height={200}
>
{(innerwidth, innerheight) => {
return (
<div
style={{
width: innerwidth,
height: innerheight,
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Content
</div>
);
}}
</PanelChrome>
```
<ExampleFrame>
<PanelChrome
title="My awesome panel title"
subHeaderContent={
<div style={{ display: 'flex', gap: '8px', padding: '4px 8px' }}>
<span style={{ fontSize: '12px', color: '#999' }}>Additional info</span>
</div>
}
width={400}
height={200}
>
{(innerwidth, innerheight) => {
return (
<div
style={{
width: innerwidth,
height: innerheight,
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Content
</div>
);
}}
</PanelChrome>
</ExampleFrame>
### Collapsible
The panel can be collapsed/expanded by clicking on the chevron or the title.
@@ -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: <div key="sub-header-test">This should be a sub-header node</div>,
});
expect(screen.getByTestId(selectors.components.Panels.Panel.headerContainer)).toBeInTheDocument();
});
it('renders panel with sub-header content in place if prop subHeaderContent', () => {
setup({
subHeaderContent: <div key="sub-header-test">This should be a sub-header node</div>,
});
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: <div key="sub-header-test">This should be a sub-header node</div>,
});
expect(screen.queryByText('This should be a sub-header node')).not.toBeInTheDocument();
});
@@ -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<HTMLDivElement>();
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 && (
<div
className={cx(styles.headerContainer, dragClass)}
style={headerStyles}
data-testid={selectors.components.Panels.Panel.headerContainer}
onPointerDown={onPointerDown}
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
onPointerUp={onPointerUp}
>
{statusMessage && (
<div className={dragClassCancel}>
<PanelStatus
message={statusMessage}
onClick={statusMessageOnClick}
ariaLabel={t('grafana-ui.panel-chrome.ariaLabel-panel-status', 'Panel status')}
<>
<div
className={cx(styles.headerContainer, dragClass)}
style={headerStyles}
data-testid={selectors.components.Panels.Panel.headerContainer}
onPointerDown={onPointerDown}
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
onPointerUp={onPointerUp}
>
{statusMessage && (
<div className={dragClassCancel}>
<PanelStatus
message={statusMessage}
onClick={statusMessageOnClick}
ariaLabel={t('grafana-ui.panel-chrome.ariaLabel-panel-status', 'Panel status')}
/>
</div>
)}
{headerContent}
{menu && (
<PanelMenu
menu={menu}
title={typeof title === 'string' ? title : undefined}
placement="bottom-end"
menuButtonClass={cx(styles.menuItem, dragClassCancel, showOnHoverClass)}
onOpenMenu={onOpenMenu}
/>
)}
</div>
{!collapsed && subHeaderContent && (
<div className={styles.subHeader} ref={subHeaderRef}>
{subHeaderContent}
</div>
)}
{headerContent}
{menu && (
<PanelMenu
menu={menu}
title={typeof title === 'string' ? title : undefined}
placement="bottom-end"
menuButtonClass={cx(styles.menuItem, dragClassCancel, showOnHoverClass)}
onOpenMenu={onOpenMenu}
/>
)}
</div>
</>
)}
{!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',
}),
@@ -26,6 +26,7 @@ class UnthemedValueContainer<Option, isMulti extends boolean, Group extends Grou
if (
this.ref.current &&
this.props.selectProps.autoWidth &&
!this.props.selectProps.maxVisibleValues &&
!isEqual(prevProps.selectProps.value, this.props.selectProps.value)
) {
// Reset in order to measure the new width
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ScaleDistribution } from '@grafana/schema';
import { ScaleDistributionEditor } from './axis';
describe('ScaleDistributionEditor', () => {
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(<ScaleDistributionEditor value={{ type: ScaleDistribution.Symlog, log: 10 }} onChange={onChange} />);
// 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);
});
});
});
@@ -1,3 +1,5 @@
import { useState } from 'react';
import {
FieldConfigEditorBuilder,
FieldType,
@@ -126,12 +128,31 @@ const LOG_DISTRIBUTION_OPTIONS: Array<SelectableValue<number>> = [
},
];
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<ScaleDistributionConfig>) => {
export const ScaleDistributionEditor = ({
value,
onChange,
}: Pick<StandardEditorProps<ScaleDistributionConfig>, 'value' | 'onChange'>) => {
const type = value?.type ?? ScaleDistribution.Linear;
const log = value?.log ?? 2;
const [localLinearThreshold, setLocalLinearThreshold] = useState<string>(
value?.linearThreshold != null ? String(value.linearThreshold) : ''
);
const [linearThresholdWarning, setLinearThresholdWarning] = useState<string | undefined>();
const DISTRIBUTION_OPTIONS: Array<SelectableValue<ScaleDistribution>> = [
{
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) && (
<Field label={t('grafana-ui.axis-builder.log-base', 'Log base')}>
<Field label={t('grafana-ui.axis-builder.log-base', 'Log base')} noMargin>
<Select
options={LOG_DISTRIBUTION_OPTIONS}
value={log}
@@ -175,16 +196,43 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps
</Field>
)}
{type === ScaleDistribution.Symlog && (
<Field label={t('grafana-ui.axis-builder.linear-threshold', 'Linear threshold')} style={{ marginBottom: 0 }}>
// ADD error and invalid to field when needed
<Field
label={t('grafana-ui.axis-builder.linear-threshold.label', 'Linear threshold')}
invalid={!!linearThresholdWarning}
error={linearThresholdWarning}
style={{ marginBottom: 0 }}
noMargin
>
<Input
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1"
value={value?.linearThreshold}
value={localLinearThreshold}
invalid={!!linearThresholdWarning}
type="number"
onBlur={(ev) => {
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,
});
}
}}
/>
</Field>
+2
View File
@@ -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{
+9
View File
@@ -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)
+1 -2
View File
@@ -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)
}
+1
View File
@@ -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 {
+1
View File
@@ -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"`
+5 -1
View File
@@ -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
+2
View File
@@ -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"
+1
View File
@@ -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() {
+1
View File
@@ -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 += "/"
}
+2
View File
@@ -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.
@@ -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") {
+1
View File
@@ -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)
+1
View File
@@ -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:
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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"
)
+1
View File
@@ -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()))
}
+1
View File
@@ -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)
}
@@ -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:
@@ -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,
})
}
@@ -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")
}
@@ -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")
}
@@ -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)
@@ -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
@@ -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")
}
+6 -1
View File
@@ -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.
@@ -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 {
@@ -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
@@ -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)
}
@@ -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
}
@@ -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"))
}
@@ -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)
})
}
@@ -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
}
@@ -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) {
@@ -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)
+6 -6
View File
@@ -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
@@ -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
@@ -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}...)
}
@@ -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
}
@@ -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
@@ -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 {
+1
View File
@@ -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 {
@@ -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(),
@@ -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)
@@ -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}
+28 -20
View File
@@ -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)
}
+7 -1
View File
@@ -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)
+38 -31
View File
@@ -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",
+5 -4
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
73 alertmanagerRemoteSecondary experimental @grafana/alerting-squad false false false
74 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
75 alertingUIUseBackendFilters experimental @grafana/alerting-squad false false false
76 alertingUIUseFullyCompatBackendFilters experimental @grafana/alerting-squad false false false
77 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
78 annotationPermissionUpdate GA @grafana/identity-access-team false false false
79 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts experimental @grafana/dashboards-squad false false false
83 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
84 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
85 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
86 panelFilterVariable experimental @grafana/dashboards-squad false false true
87 pdfTables preview @grafana/grafana-operator-experience-squad false false false
88 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
89 timeComparison experimental @grafana/dataviz-squad false false true
logsInfiniteScrolling GA @grafana/observability-logs false false true
90 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
91 kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
92 cloudRBACRoles preview @grafana/identity-access-team false true false
93 alertingQueryOptimization GA @grafana/alerting-squad false false false
94 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false
onPremToCloudMigrations GA @grafana/grafana-operator-experience-squad false false false
95 secretsManagementAppPlatform experimental @grafana/grafana-operator-experience-squad false false false
96 secretsManagementAppPlatformUI experimental @grafana/grafana-operator-experience-squad false false false
97 alertingSaveStatePeriodic privatePreview @grafana/alerting-squad false false false
115 queryLibrary preview @grafana/sharing-squad false false false
116 dashboardLibrary experimental @grafana/sharing-squad false false false
117 suggestedDashboards experimental @grafana/sharing-squad false false false
118 dashboardTemplates preview @grafana/sharing-squad false false false
119 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
120 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
121 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
152 unifiedStorageBigObjectsSupport experimental @grafana/search-and-storage false false false
153 timeRangeProvider experimental @grafana/grafana-frontend-platform false false false
154 timeRangePan experimental @grafana/dataviz-squad false false true
155 newTimeRangeZoomShortcuts experimental @grafana/dataviz-squad false false true
156 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
157 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false
158 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
208 logsPanelControls preview @grafana/observability-logs false false true
209 metricsFromProfiles experimental @grafana/observability-traces-and-profiling false false true
210 grafanaAssistantInProfilesDrilldown GA @grafana/observability-traces-and-profiling false false true
postgresDSUsePGX experimental @grafana/oss-big-tent false false false
211 tempoAlerting experimental @grafana/observability-traces-and-profiling false false false
212 pluginsAutoUpdate experimental @grafana/plugins-platform-backend false false false
213 alertingListViewV2PreviewToggle privatePreview @grafana/alerting-squad false false true
259 newPanelPadding experimental @grafana/dashboards-squad false false false
260 onlyStoreActionSets GA @grafana/identity-access-team false false false
261 panelTimeSettings experimental @grafana/dashboards-squad false false false
dashboardTemplates experimental @grafana/sharing-squad false false false
262 kubernetesAnnotations experimental @grafana/grafana-backend-services-squad false false false
263 awsDatasourcesHttpProxy experimental @grafana/aws-datasources false false false
264 transformationsEmptyPlaceholder preview @grafana/datapro false false true
265 ttlPluginInstanceManager experimental @grafana/plugins-platform-backend false false true
266 lokiQueryLimitsContext experimental @grafana/observability-logs false false true
267 rudderstackUpgrade experimental @grafana/grafana-frontend-platform false false true
+10 -14
View File
@@ -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"
+453 -1297
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -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);
+1
View File
@@ -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
+3 -1
View File
@@ -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(
@@ -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",
@@ -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)
@@ -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) {
+3
View File
@@ -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")
}
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More