diff --git a/package.json b/package.json index e97932640cf..3f6ac927b08 100644 --- a/package.json +++ b/package.json @@ -253,7 +253,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@fingerprintjs/fingerprintjs": "^3.4.2", - "@floating-ui/react": "0.27.2", + "@floating-ui/react": "0.27.3", "@formatjs/intl-durationformat": "^0.7.0", "@glideapps/glide-data-grid": "^6.0.0", "@grafana/aws-sdk": "0.6.0", diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index b84d081e84a..9f816dcd695 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -881,6 +881,7 @@ export const versionedComponents = { Select: { menu: { '11.4.0': 'data-testid Select menu', + [MIN_GRAFANA_VERSION]: 'Select options menu', }, option: { '11.1.0': 'data-testid Select option', @@ -1234,6 +1235,11 @@ export const versionedComponents = { '11.2.0': 'data-testid entity-not-found', }, }, + Portal: { + container: { + '11.4.1': 'data-testid portal-container', + }, + }, } satisfies VersionedSelectorGroup; export type VersionedComponents = typeof versionedComponents; diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 64d7fd36360..14e060c5863 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@emotion/css": "11.13.5", - "@floating-ui/react": "0.27.2", + "@floating-ui/react": "0.27.3", "@grafana/data": "11.5.0-pre", "@grafana/experimental": "2.1.6", "@grafana/faro-web-sdk": "1.12.2", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index fd4d9de3172..d8fac079ad9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -49,7 +49,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/serialize": "1.3.3", - "@floating-ui/react": "0.27.2", + "@floating-ui/react": "0.27.3", "@grafana/data": "11.5.0-pre", "@grafana/e2e-selectors": "11.5.0-pre", "@grafana/faro-web-sdk": "^1.3.6", diff --git a/packages/grafana-ui/src/components/Portal/Portal.tsx b/packages/grafana-ui/src/components/Portal/Portal.tsx index 67cf3caf5d4..4cc232e331a 100644 --- a/packages/grafana-ui/src/components/Portal/Portal.tsx +++ b/packages/grafana-ui/src/components/Portal/Portal.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { useStyles2, useTheme2 } from '../../themes'; @@ -51,7 +52,13 @@ export function getPortalContainer() { /** @internal */ export function PortalContainer() { const styles = useStyles2(getStyles); - return
; + return ( +
+ ); } const getStyles = (theme: GrafanaTheme2) => { diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index decb1642657..4d89fb51a4a 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -12,6 +12,9 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -25,7 +28,7 @@ func TestMain(m *testing.M) { func TestConcurrentUsersMetrics(t *testing.T) { sqlStore, cfg := db.InitTestDBWithCfg(t) - statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) + statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore, &dashboards.FakeDashboardService{}, &foldertest.FakeService{}, &orgtest.FakeOrgService{}) s := createService(t, cfg, sqlStore, statsService) createConcurrentTokens(t, sqlStore) @@ -43,7 +46,7 @@ func TestConcurrentUsersMetrics(t *testing.T) { func TestConcurrentUsersStats(t *testing.T) { sqlStore, cfg := db.InitTestDBWithCfg(t) - statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) + statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore, &dashboards.FakeDashboardService{}, &foldertest.FakeService{}, &orgtest.FakeOrgService{}) s := createService(t, cfg, sqlStore, statsService) createConcurrentTokens(t, sqlStore) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index d40ba27d45b..a87f2a41ca4 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -1,18 +1,26 @@ package dashboard import ( + "context" + "errors" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + "github.com/grafana/authlib/claims" dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1" "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" ) @@ -22,13 +30,18 @@ var ( ) // This is used just so wire has something unique to return -type DashboardsAPIBuilder struct{} +type DashboardsAPIBuilder struct { + ProvisioningDashboardService dashboards.DashboardProvisioningService +} func RegisterAPIService( features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, + provisioningDashboardService dashboards.DashboardProvisioningService, ) *DashboardsAPIBuilder { - builder := &DashboardsAPIBuilder{} + builder := &DashboardsAPIBuilder{ + ProvisioningDashboardService: provisioningDashboardService, + } apiregistration.RegisterAPI(builder) return builder } @@ -63,3 +76,37 @@ func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefiniti func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { return oas, nil } + +// Validate will prevent deletion of provisioned dashboards, unless the grace period is set to 0, indicating a force deletion +func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { + op := a.GetOperation() + if op == admission.Delete { + obj := a.GetOperationOptions() + deleteOptions, ok := obj.(*metav1.DeleteOptions) + if !ok { + return fmt.Errorf("expected v1.DeleteOptions") + } + + if deleteOptions.GracePeriodSeconds == nil || *deleteOptions.GracePeriodSeconds != 0 { + nsInfo, err := claims.ParseNamespace(a.GetNamespace()) + if err != nil { + return fmt.Errorf("%v: %w", "failed to parse namespace", err) + } + + provisioningData, err := b.ProvisioningDashboardService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, a.GetName()) + if err != nil { + if errors.Is(err, dashboards.ErrProvisionedDashboardNotFound) { + return nil + } + + return fmt.Errorf("%v: %w", "failed to check if dashboard is provisioned", err) + } + + if provisioningData != nil { + return dashboards.ErrDashboardCannotDeleteProvisionedDashboard + } + } + } + + return nil +} diff --git a/pkg/registry/apis/dashboard/register_test.go b/pkg/registry/apis/dashboard/register_test.go new file mode 100644 index 00000000000..31f3c2d801a --- /dev/null +++ b/pkg/registry/apis/dashboard/register_test.go @@ -0,0 +1,160 @@ +package dashboard + +import ( + "context" + "fmt" + "testing" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/admission" +) + +func TestDashboardAPIBuilder_Validate(t *testing.T) { + oneInt64 := int64(1) + zeroInt64 := int64(0) + tests := []struct { + name string + inputObj *v0alpha1.Dashboard + deletionOptions metav1.DeleteOptions + dashboardResponse *dashboards.DashboardProvisioning + dashboardErrorResponse error + checkRan bool + expectedError bool + }{ + { + name: "should return an error if data is found", + inputObj: &v0alpha1.Dashboard{ + Spec: common.Unstructured{}, + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + deletionOptions: metav1.DeleteOptions{ + GracePeriodSeconds: nil, + }, + dashboardResponse: &dashboards.DashboardProvisioning{ID: 1}, + dashboardErrorResponse: nil, + checkRan: true, + expectedError: true, + }, + { + name: "should return an error if unable to check", + inputObj: &v0alpha1.Dashboard{ + Spec: common.Unstructured{}, + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + deletionOptions: metav1.DeleteOptions{ + GracePeriodSeconds: nil, + }, + dashboardResponse: nil, + dashboardErrorResponse: fmt.Errorf("generic error"), + checkRan: true, + expectedError: true, + }, + { + name: "should be okay if error is provisioned dashboard not found", + inputObj: &v0alpha1.Dashboard{ + Spec: common.Unstructured{}, + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + deletionOptions: metav1.DeleteOptions{ + GracePeriodSeconds: nil, + }, + dashboardResponse: nil, + dashboardErrorResponse: dashboards.ErrProvisionedDashboardNotFound, + checkRan: true, + expectedError: false, + }, + { + name: "Should still run the check for delete if grace period is not 0", + inputObj: &v0alpha1.Dashboard{ + Spec: common.Unstructured{}, + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + deletionOptions: metav1.DeleteOptions{ + GracePeriodSeconds: &oneInt64, + }, + dashboardResponse: nil, + dashboardErrorResponse: nil, + checkRan: true, + expectedError: false, + }, + { + name: "should not run the check for delete if grace period is set to 0", + inputObj: &v0alpha1.Dashboard{ + Spec: common.Unstructured{}, + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + deletionOptions: metav1.DeleteOptions{ + GracePeriodSeconds: &zeroInt64, + }, + dashboardResponse: nil, + dashboardErrorResponse: nil, + checkRan: false, + expectedError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeService := &dashboards.FakeDashboardProvisioning{} + fakeService.On("GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything).Return(tt.dashboardResponse, tt.dashboardErrorResponse).Once() + b := &DashboardsAPIBuilder{ + ProvisioningDashboardService: fakeService, + } + err := b.Validate(context.Background(), admission.NewAttributesRecord( + tt.inputObj, + nil, + v0alpha1.DashboardResourceInfo.GroupVersionKind(), + "stacks-123", + tt.inputObj.Name, + v0alpha1.DashboardResourceInfo.GroupVersionResource(), + "", + admission.Operation("DELETE"), + &tt.deletionOptions, + true, + &user.SignedInUser{}, + ), nil) + + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if tt.checkRan { + fakeService.AssertCalled(t, "GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything) + } else { + fakeService.AssertNotCalled(t, "GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index 57bedd9b759..392d1420d93 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -44,6 +44,7 @@ var ( // This is used just so wire has something unique to return type DashboardsAPIBuilder struct { + dashboard.DashboardsAPIBuilder dashboardService dashboards.DashboardService features featuremgmt.FeatureToggles @@ -59,6 +60,7 @@ type DashboardsAPIBuilder struct { func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, + provisioningDashboardService dashboards.DashboardProvisioningService, accessControl accesscontrol.AccessControl, provisioning provisioning.ProvisioningService, dashStore dashboards.Store, @@ -72,7 +74,9 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, namespacer := request.GetNamespaceMapper(cfg) builder := &DashboardsAPIBuilder{ log: log.New("grafana-apiserver.dashboards.v0alpha1"), - + DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{ + ProvisioningDashboardService: provisioningDashboardService, + }, dashboardService: dashboardService, features: features, accessControl: accessControl, diff --git a/pkg/registry/apis/dashboard/v1alpha1/register.go b/pkg/registry/apis/dashboard/v1alpha1/register.go index 1c80817b032..d43ae3961bb 100644 --- a/pkg/registry/apis/dashboard/v1alpha1/register.go +++ b/pkg/registry/apis/dashboard/v1alpha1/register.go @@ -42,6 +42,7 @@ var ( // This is used just so wire has something unique to return type DashboardsAPIBuilder struct { + dashboard.DashboardsAPIBuilder dashboardService dashboards.DashboardService features featuremgmt.FeatureToggles @@ -56,6 +57,7 @@ type DashboardsAPIBuilder struct { func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, + provisioningDashboardService dashboards.DashboardProvisioningService, accessControl accesscontrol.AccessControl, provisioning provisioning.ProvisioningService, dashStore dashboards.Store, @@ -69,7 +71,9 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, namespacer := request.GetNamespaceMapper(cfg) builder := &DashboardsAPIBuilder{ log: log.New("grafana-apiserver.dashboards.v1alpha1"), - + DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{ + ProvisioningDashboardService: provisioningDashboardService, + }, dashboardService: dashboardService, features: features, accessControl: accessControl, diff --git a/pkg/registry/apis/dashboard/v2alpha1/register.go b/pkg/registry/apis/dashboard/v2alpha1/register.go index 90ce1ef1684..8ee4e8d1ca9 100644 --- a/pkg/registry/apis/dashboard/v2alpha1/register.go +++ b/pkg/registry/apis/dashboard/v2alpha1/register.go @@ -42,6 +42,7 @@ var ( // This is used just so wire has something unique to return type DashboardsAPIBuilder struct { + dashboard.DashboardsAPIBuilder dashboardService dashboards.DashboardService features featuremgmt.FeatureToggles @@ -56,6 +57,7 @@ type DashboardsAPIBuilder struct { func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, + provisioningDashboardService dashboards.DashboardProvisioningService, accessControl accesscontrol.AccessControl, provisioning provisioning.ProvisioningService, dashStore dashboards.Store, @@ -70,6 +72,9 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, builder := &DashboardsAPIBuilder{ log: log.New("grafana-apiserver.dashboards.v2alpha1"), + DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{ + ProvisioningDashboardService: provisioningDashboardService, + }, dashboardService: dashboardService, features: features, accessControl: accessControl, diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 7589eadfd2d..f6b8b5c2fe5 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -255,32 +255,42 @@ func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInf return cached.(map[string]bool), nil } - basicRoles, err := s.getUserBasicRole(ctx, ns, userIdentifiers) + res, err, _ := s.sf.Do(userPermKey+"_getUserPermissions", func() (interface{}, error) { + basicRoles, err := s.getUserBasicRole(ctx, ns, userIdentifiers) + if err != nil { + return nil, err + } + + teamIDs, err := s.getUserTeams(ctx, ns, userIdentifiers) + if err != nil { + return nil, err + } + + userPermQuery := store.PermissionsQuery{ + UserID: userIdentifiers.ID, + Action: action, + TeamIDs: teamIDs, + Role: basicRoles.Role, + IsServerAdmin: basicRoles.IsAdmin, + } + + permissions, err := s.store.GetUserPermissions(ctx, ns, userPermQuery) + if err != nil { + return nil, err + } + scopeMap := getScopeMap(permissions) + + s.permCache.Set(userPermKey, scopeMap, 0) + span.SetAttributes(attribute.Int("num_permissions_fetched", len(permissions))) + + return scopeMap, nil + }) + if err != nil { return nil, err } - teamIDs, err := s.getUserTeams(ctx, ns, userIdentifiers) - if err != nil { - return nil, err - } - - userPermQuery := store.PermissionsQuery{ - UserID: userIdentifiers.ID, - Action: action, - TeamIDs: teamIDs, - Role: basicRoles.Role, - IsServerAdmin: basicRoles.IsAdmin, - } - - permissions, err := s.store.GetUserPermissions(ctx, ns, userPermQuery) - if err != nil { - return nil, err - } - scopeMap := getScopeMap(permissions) - s.permCache.Set(userPermKey, scopeMap, 0) - span.SetAttributes(attribute.Int("num_permissions_fetched", len(permissions))) - return scopeMap, nil + return res.(map[string]bool), nil } func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.NamespaceInfo, action string) (map[string]bool, error) { @@ -292,13 +302,21 @@ func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.Namespa return cached.(map[string]bool), nil } - permissions, err := s.store.GetUserPermissions(ctx, ns, store.PermissionsQuery{Action: action, Role: "Viewer"}) + res, err, _ := s.sf.Do(anonPermKey+"_getAnonymousPermissions", func() (interface{}, error) { + permissions, err := s.store.GetUserPermissions(ctx, ns, store.PermissionsQuery{Action: action, Role: "Viewer"}) + if err != nil { + return nil, err + } + scopeMap := getScopeMap(permissions) + s.permCache.Set(anonPermKey, scopeMap, 0) + return scopeMap, nil + }) + if err != nil { return nil, err } - scopeMap := getScopeMap(permissions) - s.permCache.Set(anonPermKey, scopeMap, 0) - return scopeMap, nil + + return res.(map[string]bool), nil } func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) { diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index 6aaa0c80eec..8d10c1693f2 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -368,6 +368,7 @@ func TestService_getUserPermissions(t *testing.T) { tracer: tracing.NewNoopTracerService(), idCache: localcache.New(longCacheTTL, longCleanupInterval), permCache: cacheService, + sf: new(singleflight.Group), basicRoleCache: localcache.New(longCacheTTL, longCleanupInterval), teamCache: localcache.New(shortCacheTTL, shortCleanupInterval), } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 53a81e558dd..328481908de 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -492,16 +492,16 @@ func (dr *DashboardServiceImpl) SoftDeleteDashboard(ctx context.Context, orgID i return fmt.Errorf("feature flag %s is not enabled", featuremgmt.FlagDashboardRestore) } + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + // deletes in unistore are soft deletes, so we can just delete in the same way + return dr.deleteDashboardThroughK8s(ctx, &dashboards.DeleteDashboardCommand{OrgID: orgID, UID: dashboardUID}, true) + } + provisionedData, _ := dr.GetProvisionedDashboardDataByDashboardUID(ctx, orgID, dashboardUID) if provisionedData != nil && provisionedData.ID != 0 { return dashboards.ErrDashboardCannotDeleteProvisionedDashboard } - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { - // deletes in unistore are soft deletes, so we can just delete in the same way - return dr.deleteDashboardThroughK8s(ctx, &dashboards.DeleteDashboardCommand{OrgID: orgID, UID: dashboardUID}) - } - return dr.dashboardStore.SoftDeleteDashboard(ctx, orgID, dashboardUID) } @@ -533,6 +533,12 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId ctx, span := tracer.Start(ctx, "dashboards.service.deleteDashboard") defer span.End() + cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID} + + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + return dr.deleteDashboardThroughK8s(ctx, cmd, validateProvisionedDashboard) + } + if validateProvisionedDashboard { provisionedData, err := dr.GetProvisionedDashboardDataByDashboardID(ctx, dashboardId) if err != nil { @@ -544,12 +550,6 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId } } - cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID} - - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { - return dr.deleteDashboardThroughK8s(ctx, cmd) - } - return dr.dashboardStore.DeleteDashboard(ctx, cmd) } @@ -1218,7 +1218,7 @@ func (dr *DashboardServiceImpl) deleteAllDashboardThroughK8s(ctx context.Context return nil } -func (dr *DashboardServiceImpl) deleteDashboardThroughK8s(ctx context.Context, cmd *dashboards.DeleteDashboardCommand) error { +func (dr *DashboardServiceImpl) deleteDashboardThroughK8s(ctx context.Context, cmd *dashboards.DeleteDashboardCommand, validateProvisionedDashboard bool) error { // create a new context - prevents issues when the request stems from the k8s api itself // otherwise the context goes through the handlers twice and causes issues newCtx, cancel, err := dr.getK8sContext(ctx) @@ -1245,7 +1245,16 @@ func (dr *DashboardServiceImpl) deleteDashboardThroughK8s(ctx context.Context, c cmd.UID = result.UID } - err = client.Delete(newCtx, cmd.UID, v1.DeleteOptions{}) + // use a grace period of 0 to indicate to skip the check of deleting provisioned dashboards + var gracePeriod *int64 + if !validateProvisionedDashboard { + noGracePeriod := int64(0) + gracePeriod = &noGracePeriod + } + + err = client.Delete(newCtx, cmd.UID, v1.DeleteOptions{ + GracePeriodSeconds: gracePeriod, + }) if err != nil { return err } diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 5d8c2a67613..613ecf60fcb 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -656,19 +656,22 @@ func TestDeleteDashboard(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once() - k8sResourceMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + k8sResourceMock.On("Delete", mock.Anything, mock.Anything, metav1.DeleteOptions{ + GracePeriodSeconds: nil, + }, mock.Anything).Return(nil).Once() err := service.DeleteDashboard(ctx, 1, "uid", 1) require.NoError(t, err) k8sClientMock.AssertExpectations(t) }) - t.Run("If UID is not passed in, it should retrieve that first", func(t *testing.T) { + t.Run("When UID is not passed in for provisioned dashboards, should retrieve that first and set grace period to zero", func(t *testing.T) { ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) + zeroInt64 := int64(0) k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() - fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once() - k8sResourceMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + k8sResourceMock.On("Delete", mock.Anything, mock.Anything, metav1.DeleteOptions{ + GracePeriodSeconds: &zeroInt64, + }, mock.Anything).Return(nil).Once() k8sClientMock.searcher.On("Search", mock.Anything).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -694,7 +697,7 @@ func TestDeleteDashboard(t *testing.T) { }, TotalHits: 1, }, nil) - err := service.DeleteDashboard(ctx, 1, "", 1) + err := service.DeleteProvisionedDashboard(ctx, 1, 1) require.NoError(t, err) k8sClientMock.AssertExpectations(t) k8sClientMock.searcher.AssertExpectations(t) diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index e2139c80b57..6b27a239813 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -6,7 +6,11 @@ import ( "strconv" "time" + "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -17,13 +21,96 @@ import ( const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 -func ProvideService(cfg *setting.Cfg, db db.DB) stats.Service { - return &sqlStatsService{cfg: cfg, db: db} +func ProvideService(cfg *setting.Cfg, db db.DB, dashSvc dashboards.DashboardService, folderSvc folder.Service, orgSvc org.Service) stats.Service { + return &sqlStatsService{ + cfg: cfg, + db: db, + folderSvc: folderSvc, + dashSvc: dashSvc, + orgSvc: orgSvc, + } } type sqlStatsService struct { - db db.DB - cfg *setting.Cfg + db db.DB + cfg *setting.Cfg + dashSvc dashboards.DashboardService + folderSvc folder.Service + orgSvc org.Service +} + +type dashboardStats struct { + count int + bytesTotal int + bytesMax int +} + +func (ss *sqlStatsService) collectDashboardStats(ctx context.Context, orgs []*org.OrgDTO, calculateByteSize bool) (dashboardStats, error) { + stats := dashboardStats{ + count: 0, + bytesTotal: 0, + bytesMax: 0, + } + + for _, org := range orgs { + ctx = identity.WithRequester(ctx, getStatsRequester(org.ID)) + dashs, err := ss.dashSvc.GetAllDashboardsByOrgId(ctx, org.ID) + if err != nil { + return stats, err + } + stats.count += len(dashs) + + // only calculate bytes if needed + if calculateByteSize { + for _, dash := range dashs { + b, err := dash.Data.ToDB() + if err != nil { + return stats, err + } + stats.bytesTotal += len(b) + + if len(b) > stats.bytesMax { + stats.bytesMax = len(b) + } + } + } + } + + return stats, nil +} + +func (ss *sqlStatsService) getTagCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) { + total := 0 + for _, org := range orgs { + ctx = identity.WithRequester(ctx, getStatsRequester(org.ID)) + tags, err := ss.dashSvc.GetDashboardTags(ctx, &dashboards.GetDashboardTagsQuery{ + OrgID: org.ID, + }) + if err != nil { + return 0, err + } + total += len(tags) + } + + return int64(total), nil +} + +func (ss *sqlStatsService) getFolderCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) { + total := 0 + for _, org := range orgs { + backgroundUser := getStatsRequester(org.ID) + ctx = identity.WithRequester(ctx, backgroundUser) + folders, err := ss.folderSvc.GetFolders(ctx, folder.GetFoldersQuery{ + OrgID: org.ID, + SignedInUser: backgroundUser, + }) + if err != nil { + return 0, err + } + + total += len(folders) + } + return int64(total), nil } func (ss *sqlStatsService) GetAlertNotifiersUsageStats(ctx context.Context, query *stats.GetAlertNotifierUsageStatsQuery) (result []*stats.NotifierUsageStats, err error) { @@ -67,7 +154,6 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS sb := &db.SQLBuilder{} sb.Write("SELECT ") sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE ` + notServiceAccount(dialect) + `) AS users,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) @@ -87,11 +173,6 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE `+ notServiceAccount(dialect)+` AND last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false)) - sb.Write(`(SELECT SUM(LENGTH(data)) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboard_bytes_total,`, dialect.BooleanStr(false)) - sb.Write(`(SELECT MAX(LENGTH(data)) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboard_bytes_max,`, dialect.BooleanStr(false)) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`) @@ -124,6 +205,30 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS return nil }) + if err != nil { + return result, err + } + + orgs, err := ss.orgSvc.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return result, err + } + result.Orgs = int64(len(orgs)) + + // for services in unified storage, get the stats through the service rather than the db directly + dashStats, err := ss.collectDashboardStats(ctx, orgs, true) + if err != nil { + return result, err + } + result.DashboardBytesMax = int64(dashStats.bytesMax) + result.DashboardBytesTotal = int64(dashStats.bytesTotal) + result.Dashboards = int64(dashStats.count) + + folderCount, err := ss.getFolderCount(ctx, orgs) + if err != nil { + return result, err + } + result.Folders = folderCount return result, err } @@ -161,22 +266,10 @@ func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAd } var rawSQL = `SELECT - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("org") + ` - ) AS orgs, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("dashboard") + `WHERE is_folder=` + dialect.BooleanStr(false) + ` - ) AS dashboards, ( SELECT COUNT(*) FROM ` + dialect.Quote("dashboard_snapshot") + ` ) AS snapshots, - ( - SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` )) - FROM ` + dialect.Quote("dashboard_tag") + ` - ) AS tags, ( SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + ` @@ -225,6 +318,29 @@ func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAd result = &stats return nil }) + if err != nil { + return result, err + } + + orgs, err := ss.orgSvc.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return result, err + } + result.Orgs = int64(len(orgs)) + + // for services in unified storage, get the stats through the service rather than the db directly + dashStats, err := ss.collectDashboardStats(ctx, orgs, false) + if err != nil { + return result, err + } + result.Dashboards = int64(dashStats.count) + + tagCount, err := ss.getTagCount(ctx, orgs) + if err != nil { + return result, err + } + result.Tags = tagCount + return result, err } @@ -343,3 +459,20 @@ func addToStats(base stats.UserStats, role org.RoleType, count int64) stats.User return base } + +func getStatsRequester(orgId int64) *identity.StaticRequester { + return &identity.StaticRequester{ + Type: claims.TypeServiceAccount, + UserID: 1, + OrgID: orgId, + Name: "admin", + Login: "admin", + OrgRole: identity.RoleAdmin, + IsGrafanaAdmin: true, + Permissions: map[int64]map[string][]string{ + orgId: { + "*": {"*"}, + }, + }, + } +} diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index e07c059bf9d..3b3951ed27b 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -6,13 +6,18 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/correlations/correlationstest" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -33,8 +38,30 @@ func TestIntegrationStatsDataAccess(t *testing.T) { t.Skip("skipping integration test") } db, cfg := db.InitTestDBWithCfg(t) - statsService := &sqlStatsService{db: db} - populateDB(t, db, cfg) + orgSvc := populateDB(t, db, cfg) + dashSvc := &dashboards.FakeDashboardService{} + emptyJson := simplejson.New() + emptyJsonBytes, err := emptyJson.ToDB() + require.NoError(t, err) + largerJson := simplejson.NewFromAny(map[string]string{"key": "value"}) + largerJsonBytes, err := largerJson.ToDB() + require.NoError(t, err) + dashSvc.On("GetAllDashboardsByOrgId", mock.Anything, int64(1)).Return([]*dashboards.Dashboard{{Data: largerJson}, {Data: emptyJson}}, nil) + dashSvc.On("GetAllDashboardsByOrgId", mock.Anything, int64(2)).Return([]*dashboards.Dashboard{}, nil) + dashSvc.On("GetAllDashboardsByOrgId", mock.Anything, int64(3)).Return([]*dashboards.Dashboard{}, nil) + dashSvc.On("GetDashboardTags", mock.Anything, &dashboards.GetDashboardTagsQuery{OrgID: 1}).Return([]*dashboards.DashboardTagCloudItem{{Term: "test"}}, nil) + dashSvc.On("GetDashboardTags", mock.Anything, &dashboards.GetDashboardTagsQuery{OrgID: 2}).Return([]*dashboards.DashboardTagCloudItem{}, nil) + dashSvc.On("GetDashboardTags", mock.Anything, &dashboards.GetDashboardTagsQuery{OrgID: 3}).Return([]*dashboards.DashboardTagCloudItem{}, nil) + + folderService := &foldertest.FakeService{} + folderService.ExpectedFolders = []*folder.Folder{{ID: 1}, {ID: 2}, {ID: 3}} + + statsService := &sqlStatsService{ + db: db, + dashSvc: dashSvc, + orgSvc: orgSvc, + folderSvc: folderService, + } t.Run("Get system stats should not results in error", func(t *testing.T) { query := stats.GetSystemStatsQuery{} @@ -48,6 +75,11 @@ func TestIntegrationStatsDataAccess(t *testing.T) { assert.Equal(t, int64(0), result.LibraryVariables) assert.Equal(t, int64(0), result.APIKeys) assert.Equal(t, int64(2), result.Correlations) + assert.Equal(t, int64(3), result.Orgs) + assert.Equal(t, int64(2), result.Dashboards) + assert.Equal(t, int64(9), result.Folders) // will return 3 folders for each org + assert.Equal(t, int64(len(largerJsonBytes)+len(emptyJsonBytes)), result.DashboardBytesTotal) + assert.Equal(t, int64(len(largerJsonBytes)), result.DashboardBytesMax) assert.NotNil(t, result.DatabaseCreatedTime) assert.Equal(t, db.GetDialect().DriverName(), result.DatabaseDriver) }) @@ -78,12 +110,15 @@ func TestIntegrationStatsDataAccess(t *testing.T) { t.Run("Get admin stats should not result in error", func(t *testing.T) { query := stats.GetAdminStatsQuery{} - _, err := statsService.GetAdminStats(context.Background(), &query) + stats, err := statsService.GetAdminStats(context.Background(), &query) assert.NoError(t, err) + assert.Equal(t, int64(1), stats.Tags) + assert.Equal(t, int64(2), stats.Dashboards) + assert.Equal(t, int64(3), stats.Orgs) }) } -func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) { +func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) org.Service { t.Helper() orgService, _ := orgimpl.ProvideService(db, cfg, quotatest.New(false, nil)) @@ -151,16 +186,6 @@ func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) { } err = orgService.AddOrgUser(context.Background(), cmd) require.NoError(t, err) -} -func TestIntegration_GetAdminStats(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - db, cfg := db.InitTestDBWithCfg(t) - statsService := ProvideService(cfg, db) - - query := stats.GetAdminStatsQuery{} - _, err := statsService.GetAdminStats(context.Background(), &query) - require.NoError(t, err) + return orgService } diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index e9ca8082f9e..cedc1142213 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -83,8 +83,11 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) { require.Equal(t, obj.GetUID(), updated.GetUID()) require.Less(t, obj.GetResourceVersion(), updated.GetResourceVersion()) - // Delete the object - err = client.Resource.Delete(ctx, created, metav1.DeleteOptions{}) + // Delete the object, skipping the provisioned dashboard check + zeroInt64 := int64(0) + err = client.Resource.Delete(ctx, created, metav1.DeleteOptions{ + GracePeriodSeconds: &zeroInt64, + }) require.NoError(t, err) // Now it is not in the list diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx new file mode 100644 index 00000000000..655b33b2e7e --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx @@ -0,0 +1,119 @@ +import { UserEvent } from '@testing-library/user-event'; +import * as React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { screen } from 'test/test-utils'; +import { byRole } from 'testing-library-selector'; + +import { FeatureToggles } from '@grafana/data'; +import { contextSrv } from 'app/core/services/context_srv'; +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; +import { AccessControlAction } from 'app/types'; + +import { grantUserPermissions, mockDataSource } from './mocks'; +import { grafanaRulerGroup } from './mocks/grafanaRulerApi'; +import { captureRequests, serializeRequests } from './mocks/server/events'; +import { FOLDER_TITLE_HAPPY_PATH } from './mocks/server/handlers/search'; +import { testWithFeatureToggles } from './test/test-utils'; +import { setupDataSources } from './testSetup/datasources'; + +jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ + AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, +})); + +jest.setTimeout(60 * 1000); + +setupMswServer(); + +const selectFolderAndGroup = async (user: UserEvent) => { + await user.click(await screen.findByRole('button', { name: /select folder/i })); + await user.click(await screen.findByLabelText(FOLDER_TITLE_HAPPY_PATH)); + const groupInput = await ui.inputs.group.find(); + await user.click(await byRole('combobox').find(groupInput)); + await clickSelectOption(groupInput, grafanaRulerGroup.name); +}; + +const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + uid: PROMETHEUS_DATASOURCE_UID, + isDefault: true, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ), +}; +describe('RuleEditor grafana recording rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDataSources(dataSources.default); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.DataSourcesRead, + AccessControlAction.DataSourcesWrite, + AccessControlAction.DataSourcesCreate, + AccessControlAction.FoldersWrite, + AccessControlAction.FoldersRead, + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + ]); + }); + + const testCreateGrafanaRR = (featureToggles: Array, testName: string) => { + testWithFeatureToggles(featureToggles); + + it(testName, async () => { + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + + const { user } = renderRuleEditor(undefined, 'grafana-recording'); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + await user.type(await ui.inputs.metric.find(), 'metricName'); + await selectFolderAndGroup(user); + + await user.click(ui.buttons.saveAndExit.get()); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + }; + + const testCreateGrafanaRRWithInvalidMetricName = (featureToggles: Array, testName: string) => { + testWithFeatureToggles(featureToggles); + + it(testName, async () => { + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + const { user } = renderRuleEditor(undefined, 'grafana-recording'); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + await selectFolderAndGroup(user); + + await user.click(ui.buttons.saveAndExit.get()); + const requests = await capture; + expect(requests).toHaveLength(0); + }); + }; + + testCreateGrafanaRR([], 'can create new grafana recording rule with simplified steps feature toggles disabled'); + testCreateGrafanaRR( + ['alertingQueryAndExpressionsStepMode', 'alertingNotificationsStepMode'], + 'can create new grafana recording rule with simplified steps enabled' + ); + + testCreateGrafanaRRWithInvalidMetricName( + [], + 'cannot create new grafana recording rule with invalid metric name with simplified steps feature toggles disabled' + ); + testCreateGrafanaRRWithInvalidMetricName( + ['alertingQueryAndExpressionsStepMode', 'alertingNotificationsStepMode'], + 'cannot create new grafana recording rule with invalid metric name with simplified steps enabled' + ); +}); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index 41a35d2ff60..9b863517b14 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -11,6 +11,7 @@ import { AccessControlAction } from 'app/types'; import { grantUserPermissions, mockDataSource } from './mocks'; import { grafanaRulerGroup } from './mocks/grafanaRulerApi'; +import { captureRequests, serializeRequests } from './mocks/server/events'; import { setupDataSources } from './testSetup/datasources'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ @@ -42,6 +43,7 @@ describe('RuleEditor grafana managed rules', () => { }); it('can create new grafana managed alert', async () => { + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); const dataSources = { default: mockDataSource( { @@ -50,7 +52,7 @@ describe('RuleEditor grafana managed rules', () => { uid: PROMETHEUS_DATASOURCE_UID, isDefault: true, }, - { alerting: false } + { alerting: true, module: 'core:plugin/prometheus' } ), }; @@ -69,5 +71,8 @@ describe('RuleEditor grafana managed rules', () => { await user.click(ui.buttons.saveAndExit.get()); expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); }); }); diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx index f61352d06b8..766253febf1 100644 --- a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx @@ -67,7 +67,7 @@ describe('RuleEditor recording rules', () => { }); it('can create a new cloud recording rule', async () => { - renderRuleEditor(undefined, true); + renderRuleEditor(undefined, 'recording'); await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap new file mode 100644 index 00000000000..e6a833408b1 --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap @@ -0,0 +1,259 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RuleEditor grafana recording rules can create new grafana recording rule with simplified steps enabled 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "grafana_alert": { + "condition": "B", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + ], + "is_paused": false, + "record": { + "from": "B", + "metric": "metricName", + }, + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; + +exports[`RuleEditor grafana recording rules can create new grafana recording rule with simplified steps feature toggles disabled 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "grafana_alert": { + "condition": "B", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + ], + "is_paused": false, + "record": { + "from": "B", + "metric": "metricName", + }, + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap new file mode 100644 index 00000000000..821c074014f --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RuleEditor grafana managed rules can create new grafana managed alert 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": { + "description": "some description", + }, + "for": "1m", + "grafana_alert": { + "condition": "C", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "no_data_state": "NoData", + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index bdd5fced16b..11b3a0f5dfc 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -1,27 +1,25 @@ +import { UserEvent } from '@testing-library/user-event'; import { ReactNode } from 'react'; -import { Route, Routes } from 'react-router-dom-v5-compat'; -import { ui } from 'test/helpers/alertingRuleEditor'; +import { GrafanaRuleFormStep, renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { render, screen, userEvent } from 'test/test-utils'; +import { screen, waitFor } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; -import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; -import RuleEditor from 'app/features/alerting/unified/RuleEditor'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure'; import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events'; import { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search'; -import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils'; -import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; +import { MANUAL_ROUTING_KEY, SIMPLIFIED_QUERY_EDITOR_KEY } from 'app/features/alerting/unified/utils/rule-form'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { grafanaRulerGroup } from '../../../../mocks/grafanaRulerApi'; -import { setupDataSources } from '../../../../testSetup/datasources'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: ReactNode }) =>
{actions}
, @@ -29,8 +27,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ jest.setTimeout(60 * 1000); -setupMswServer(); - const dataSources = { default: mockDataSource( { @@ -39,7 +35,7 @@ const dataSources = { uid: PROMETHEUS_DATASOURCE_UID, isDefault: true, }, - { alerting: false } + { alerting: true, module: 'core:plugin/prometheus' } ), am: mockDataSource({ name: 'Alertmanager', @@ -47,10 +43,7 @@ const dataSources = { }), }; -setupDataSources(dataSources.default, dataSources.am); - -const selectFolderAndGroup = async () => { - const user = userEvent.setup(); +const selectFolderAndGroup = async (user: UserEvent) => { await user.click(await screen.findByRole('button', { name: /select folder/i })); await user.click(await screen.findByLabelText(FOLDER_TITLE_HAPPY_PATH)); const groupInput = await ui.inputs.group.find(); @@ -58,12 +51,21 @@ const selectFolderAndGroup = async () => { await clickSelectOption(groupInput, grafanaRulerGroup.name); }; +const selectContactPoint = async (user: UserEvent, contactPointName: string) => { + const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); + await user.click(byRole('combobox').get(contactPointInput)); + await clickSelectOption(contactPointInput, contactPointName); +}; + +setupMswServer(); describe('Can create a new grafana managed alert using simplified routing', () => { + testWithFeatureToggles(['alertingSimplifiedRouting']); + beforeEach(() => { - jest.clearAllMocks(); + window.localStorage.clear(); + setupDataSources(dataSources.default, dataSources.am); contextSrv.isEditor = true; contextSrv.hasEditPermissionInFolders = true; - config.featureToggles.alertingSimplifiedRouting = true; grantUserPermissions([ AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate, @@ -84,11 +86,11 @@ describe('Can create a new grafana managed alert using simplified routing', () = it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => { const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); - const { user } = renderSimplifiedRuleEditor(); + const { user } = renderRuleEditor(); await user.type(await ui.inputs.name.find(), 'my great new rule'); - await selectFolderAndGroup(); + await selectFolderAndGroup(user); //select contact point routing await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); @@ -104,26 +106,25 @@ describe('Can create a new grafana managed alert using simplified routing', () = it('simplified routing is not available when Grafana AM is not enabled', async () => { setAlertmanagerChoices(AlertmanagerChoice.External, 1); - renderSimplifiedRuleEditor(); + renderRuleEditor(); - expect(ui.inputs.simplifiedRouting.contactPointRouting.query()).not.toBeInTheDocument(); + await waitFor(() => expect(ui.inputs.simplifiedRouting.contactPointRouting.query()).not.toBeInTheDocument()); }); it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => { const contactPointName = 'lotsa-emails'; const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); - const { user } = renderSimplifiedRuleEditor(); + const { user } = renderRuleEditor(); await user.type(await ui.inputs.name.find(), 'my great new rule'); - await selectFolderAndGroup(); + await selectFolderAndGroup(user); //select contact point routing await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); - const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); - await user.click(byRole('combobox').get(contactPointInput)); - await clickSelectOption(contactPointInput, contactPointName); + + await selectContactPoint(user, contactPointName); // save and check what was sent to backend await user.click(ui.buttons.saveAndExit.get()); @@ -137,27 +138,108 @@ describe('Can create a new grafana managed alert using simplified routing', () = testWithFeatureToggles(['alertingApiServer']); it('allows selecting a contact point when using alerting API server', async () => { - const { user } = renderSimplifiedRuleEditor(); + const { user } = renderRuleEditor(); await user.click(await ui.inputs.simplifiedRouting.contactPointRouting.find()); - const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); - await user.click(byRole('combobox').get(contactPointInput)); - await clickSelectOption(contactPointInput, 'lotsa-emails'); + await selectContactPoint(user, 'Email'); expect(await screen.findByText('Email')).toBeInTheDocument(); }); }); -}); + describe('switch modes enabled', () => { + testWithFeatureToggles(['alertingQueryAndExpressionsStepMode', 'alertingNotificationsStepMode']); -function renderSimplifiedRuleEditor() { - return render( - - - } /> - } /> - - , - { historyOptions: { initialEntries: ['/alerting/new/alerting'] } } - ); -} + it('can create the new grafana-managed rule with default modes', async () => { + const contactPointName = 'lotsa-emails'; + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + + const { user } = renderRuleEditor(); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + + await selectFolderAndGroup(user); + + await selectContactPoint(user, contactPointName); + + // save and check what was sent to backend + await user.click(ui.buttons.saveAndExit.get()); + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + it('can create the new grafana-managed rule with advanced modes', async () => { + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + + const { user } = renderRuleEditor(); + + await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch to query step advanced mode + await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()); // switch to notifications step advanced mode + await user.type(await ui.inputs.name.find(), 'my great new rule'); + + await selectFolderAndGroup(user); + + // save and check what was sent to backend + await user.click(ui.buttons.saveAndExit.get()); + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + it('can create the new grafana-managed rule with only notifications step advanced mode', async () => { + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + + const { user } = renderRuleEditor(); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + + await selectFolderAndGroup(user); + + await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()); // switch notifications step to advanced mode + + // save and check what was sent to backend + await user.click(ui.buttons.saveAndExit.get()); + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + it('can create the new grafana-managed rule with only query step advanced mode', async () => { + const contactPointName = 'lotsa-emails'; + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + + const { user } = renderRuleEditor(); + + await user.type(await ui.inputs.name.find(), 'my great new rule'); + + await selectFolderAndGroup(user); + await selectContactPoint(user, contactPointName); + + await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch query step to advanced mode + + // save and check what was sent to backend + await user.click(ui.buttons.saveAndExit.get()); + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + it('switch modes are intiallized depending on the local storage - 1', async () => { + localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false'); + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + + const { user } = renderRuleEditor(); + await selectFolderAndGroup(user); + + expect(ui.inputs.switchModeAdvanced(GrafanaRuleFormStep.Query).get()).toBeInTheDocument(); + expect(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()).toBeInTheDocument(); + }); + it('switch modes are intiallized depending on the local storage - 2', async () => { + localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true'); + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + + const { user } = renderRuleEditor(); + await selectFolderAndGroup(user); + + expect(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()).toBeInTheDocument(); + expect(ui.inputs.switchModeAdvanced(GrafanaRuleFormStep.Notification).get()).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap index 5e8fc88cdb3..3cee4d53070 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap @@ -51,43 +51,20 @@ exports[`Can create a new grafana managed alert using simplified routing can cre "annotations": {}, "for": "1m", "grafana_alert": { - "condition": "B", + "condition": "C", "data": [ { - "datasourceUid": "__expr__", + "datasourceUid": "prometheus", "model": { - "conditions": [ - { - "evaluator": { - "params": [], - "type": "gt", - }, - "operator": { - "type": "and", - }, - "query": { - "params": [ - "A", - ], - }, - "reducer": { - "params": [], - "type": "last", - }, - "type": "query", - }, - ], - "datasource": { - "type": "__expr__", - "uid": "__expr__", - }, - "expression": "A", - "reducer": "last", + "instant": true, "refId": "A", - "type": "reduce", }, "queryType": "", "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, }, { "datasourceUid": "__expr__", @@ -95,9 +72,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre "conditions": [ { "evaluator": { - "params": [ - 0, - ], + "params": [], "type": "gt", }, "operator": { @@ -120,12 +95,50 @@ exports[`Can create a new grafana managed alert using simplified routing can cre "uid": "__expr__", }, "expression": "A", + "reducer": "last", "refId": "B", - "type": "threshold", + "type": "reduce", }, "queryType": "", "refId": "B", }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, ], "exec_err_state": "Error", "is_paused": false, @@ -154,3 +167,693 @@ exports[`Can create a new grafana managed alert using simplified routing can cre }, ] `; + +exports[`Can create a new grafana managed alert using simplified routing switch modes enabled can create the new grafana-managed rule with advanced modes 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "for": "1m", + "grafana_alert": { + "condition": "C", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "metadata": { + "editor_settings": { + "simplified_notifications_section": false, + "simplified_query_and_expressions_section": false, + }, + }, + "no_data_state": "NoData", + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; + +exports[`Can create a new grafana managed alert using simplified routing switch modes enabled can create the new grafana-managed rule with default modes 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "for": "1m", + "grafana_alert": { + "condition": "C", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "metadata": { + "editor_settings": { + "simplified_notifications_section": true, + "simplified_query_and_expressions_section": true, + }, + }, + "no_data_state": "NoData", + "notification_settings": { + "receiver": "lotsa-emails", + }, + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; + +exports[`Can create a new grafana managed alert using simplified routing switch modes enabled can create the new grafana-managed rule with only notifications step advanced mode 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "for": "1m", + "grafana_alert": { + "condition": "C", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "metadata": { + "editor_settings": { + "simplified_notifications_section": false, + "simplified_query_and_expressions_section": true, + }, + }, + "no_data_state": "NoData", + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; + +exports[`Can create a new grafana managed alert using simplified routing switch modes enabled can create the new grafana-managed rule with only query step advanced mode 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "for": "1m", + "grafana_alert": { + "condition": "C", + "data": [ + { + "datasourceUid": "prometheus", + "model": { + "instant": true, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "metadata": { + "editor_settings": { + "simplified_notifications_section": true, + "simplified_query_and_expressions_section": false, + }, + }, + "no_data_state": "NoData", + "notification_settings": { + "receiver": "lotsa-emails", + }, + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx index c701e2a41ec..124bd7e78a2 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx @@ -93,7 +93,14 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc label: t('share-dashboard.menu.invite-user-title', 'Invite new member'), renderCondition: !!config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd), onClick: () => { - window.open(config.externalUserMngLinkUrl, '_blank'); + const url = new URL(config.externalUserMngLinkUrl); + + // Add query parameters to track conversion + url.searchParams.append('src', 'grafananet'); + url.searchParams.append('cnt', 'share-invite'); + + // Open the modified URL + window.open(url.toString(), '_blank'); }, renderDividerAbove: true, component: () => , diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index 465b2ef0dc3..0a2a8634ce8 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -1,15 +1,20 @@ -import { Routes, Route } from 'react-router-dom-v5-compat'; +import { Route, Routes } from 'react-router-dom-v5-compat'; import { render } from 'test/test-utils'; import { byRole, byTestId, byText } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors'; import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; import RuleEditor from 'app/features/alerting/unified/RuleEditor'; +export enum GrafanaRuleFormStep { + Query = 2, + Notification = 5, +} export const ui = { loadingIndicator: byText('Loading rule...'), inputs: { name: byRole('textbox', { name: 'name' }), + metric: byRole('textbox', { name: 'metric' }), alertType: byTestId('alert-type-picker'), dataSource: byTestId(selectors.components.DataSourcePicker.inputV2), folder: byTestId('folder-picker'), @@ -26,6 +31,8 @@ export const ui = { contactPoint: byTestId('contact-point-picker'), routingOptions: byText(/muting, grouping and timings \(optional\)/i), }, + switchModeBasic: (stepNo: GrafanaRuleFormStep) => byTestId(`advanced-switch-${stepNo}-basic`), + switchModeAdvanced: (stepNo: GrafanaRuleFormStep) => byTestId(`advanced-switch-${stepNo}-advanced`), }, buttons: { saveAndExit: byRole('button', { name: 'Save rule and exit' }), @@ -34,8 +41,7 @@ export const ui = { addLabel: byRole('button', { name: /Add label/ }), }, }; - -export function renderRuleEditor(identifier?: string, recording = false) { +export function renderRuleEditor(identifier?: string, recording?: 'recording' | 'grafana-recording') { return render( <> @@ -46,9 +52,7 @@ export function renderRuleEditor(identifier?: string, recording = false) { , { historyOptions: { - initialEntries: [ - identifier ? `/alerting/${identifier}/edit` : `/alerting/new/${recording ? 'recording' : 'alerting'}`, - ], + initialEntries: [identifier ? `/alerting/${identifier}/edit` : `/alerting/new/${recording ?? 'alerting'}`], }, } ); diff --git a/yarn.lock b/yarn.lock index f9eaf5f8f3e..96e3935d1cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2691,17 +2691,17 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react@npm:0.27.2": - version: 0.27.2 - resolution: "@floating-ui/react@npm:0.27.2" +"@floating-ui/react@npm:0.27.3": + version: 0.27.3 + resolution: "@floating-ui/react@npm:0.27.3" dependencies: "@floating-ui/react-dom": "npm:^2.1.2" - "@floating-ui/utils": "npm:^0.2.8" + "@floating-ui/utils": "npm:^0.2.9" tabbable: "npm:^6.0.0" peerDependencies: react: ">=17.0.0" react-dom: ">=17.0.0" - checksum: 10/c9ff0d7216cc7b51f066e8927852ba014bf51c79e48a1837f99760750df37fc3ed010dcdc89e57e673e2740e3bd2b91ccdcc6ccd13ec927950110a18b6b9cefa + checksum: 10/91eef58241b2225420fa73dfc501934463310489e08f2a0681b320e0c639c9ee59044ab66645f2901b74a88328208ff4cc4afabe93917a7a28f9d18037965cb6 languageName: node linkType: hard @@ -2719,10 +2719,10 @@ __metadata: languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.8": - version: 0.2.8 - resolution: "@floating-ui/utils@npm:0.2.8" - checksum: 10/3e3ea3b2de06badc4baebdf358b3dbd77ccd9474a257a6ef237277895943db2acbae756477ec64de65a2a1436d94aea3107129a1feeef6370675bf2b161c1abc +"@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.8, @floating-ui/utils@npm:^0.2.9": + version: 0.2.9 + resolution: "@floating-ui/utils@npm:0.2.9" + checksum: 10/0ca786347db3dd8d9034b86d1449fabb96642788e5900cc5f2aee433cd7b243efbcd7a165bead50b004ee3f20a90ddebb6a35296fc41d43cfd361b6f01b69ffb languageName: node linkType: hard @@ -3668,7 +3668,7 @@ __metadata: dependencies: "@emotion/css": "npm:11.13.5" "@emotion/eslint-plugin": "npm:11.12.0" - "@floating-ui/react": "npm:0.27.2" + "@floating-ui/react": "npm:0.27.3" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" "@grafana/experimental": "npm:2.1.6" @@ -3969,7 +3969,7 @@ __metadata: "@emotion/react": "npm:11.14.0" "@emotion/serialize": "npm:1.3.3" "@faker-js/faker": "npm:^9.0.0" - "@floating-ui/react": "npm:0.27.2" + "@floating-ui/react": "npm:0.27.3" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" "@grafana/faro-web-sdk": "npm:^1.3.6" @@ -17612,7 +17612,7 @@ __metadata: "@emotion/eslint-plugin": "npm:11.12.0" "@emotion/react": "npm:11.14.0" "@fingerprintjs/fingerprintjs": "npm:^3.4.2" - "@floating-ui/react": "npm:0.27.2" + "@floating-ui/react": "npm:0.27.3" "@formatjs/intl-durationformat": "npm:^0.7.0" "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana/aws-sdk": "npm:0.6.0"