Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm committed 2025-01-08 15:11:13 +01:00
commit 2763efba12
28 files changed
+1966 -191

No files matched your search

+1 -1
View File
@@ -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",
@@ -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;
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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 <div id="grafana-portal-container" className={styles.grafanaPortalContainer} />;
return (
<div
id="grafana-portal-container"
data-testid={selectors.components.Portal.container}
className={styles.grafanaPortalContainer}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
@@ -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)
+49 -2
View File
@@ -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
}
@@ -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)
}
})
}
}
@@ -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,
@@ -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,
@@ -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,
+44 -26
View File
@@ -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) {
+1
View File
@@ -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),
}
@@ -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
}
@@ -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)
+155 -22
View File
@@ -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: {
"*": {"*"},
},
},
}
}
+40 -15
View File
@@ -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
}
+5 -2
View File
@@ -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
@@ -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 }) => <div>{actions}</div>,
}));
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<keyof FeatureToggles>, 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<keyof FeatureToggles>, 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'
);
});
@@ -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();
});
});
@@ -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');
@@ -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",
},
]
`;
@@ -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",
},
]
`;
@@ -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 }) => <div>{actions}</div>,
@@ -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(
<AlertmanagerProvider alertmanagerSourceName={GRAFANA_DATASOURCE_NAME} accessType="notification">
<Routes>
<Route path={'/alerting/new/:type'} element={<RuleEditor />} />
<Route path={'/alerting/:id/edit'} element={<RuleEditor />} />
</Routes>
</AlertmanagerProvider>,
{ 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();
});
});
});
@@ -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",
},
]
`;
@@ -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: () => <Icon name="external-link-alt" className={styles.inviteUserItemIcon} />,
+10 -6
View File
@@ -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(
<>
<AppNotificationList />
@@ -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'}`],
},
}
);
+12 -12
View File
@@ -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"