diff --git a/devenv/docker/blocks/elastic5/docker-compose.yaml b/devenv/docker/blocks/elastic5/docker-compose.yaml index 33a7d9855b0..3a2ef39faba 100644 --- a/devenv/docker/blocks/elastic5/docker-compose.yaml +++ b/devenv/docker/blocks/elastic5/docker-compose.yaml @@ -1,6 +1,3 @@ -# You need to run 'sysctl -w vm.max_map_count=262144' on the host machine -version: '2' -services: elasticsearch5: image: elasticsearch:5 command: elasticsearch diff --git a/package.json b/package.json index f004ee07732..36fdd794d0c 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "html-loader": "^0.5.1", "html-webpack-harddisk-plugin": "^0.2.0", "html-webpack-plugin": "^3.2.0", - "husky": "^0.14.3", + "husky": "^1.3.1", "jest": "^23.6.0", "jest-date-mock": "^1.0.6", "lint-staged": "^8.1.3", @@ -120,7 +120,6 @@ "typecheck": "tsc --noEmit", "jest": "jest --notify --watch", "api-tests": "jest --notify --watch --config=tests/api/jest.js", - "precommit": "grunt precommit", "storybook": "cd packages/grafana-ui && yarn storybook" }, "husky": { @@ -151,6 +150,7 @@ "dependencies": { "@babel/polyfill": "^7.0.0", "@torkelo/react-select": "2.1.1", + "@types/reselect": "^2.2.0", "angular": "1.6.6", "angular-bindonce": "0.3.1", "angular-native-dragdrop": "1.2.2", @@ -187,6 +187,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", "remarkable": "^1.7.1", + "reselect": "^4.0.0", "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "^6.3.3", "slate": "^0.33.4", diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx index 845ff5f6bf4..2b6af67df22 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ChangeEvent } from 'react'; import { shallow } from 'enzyme'; import { ThresholdsEditor, Props } from './ThresholdsEditor'; @@ -118,7 +118,7 @@ describe('change threshold value', () => { ]; const instance = setup({ thresholds }); - const mockEvent = { target: { value: 12 } }; + const mockEvent = ({ target: { value: '12' } } as any) as ChangeEvent; instance.onChangeThresholdValue(mockEvent, thresholds[0]); @@ -137,7 +137,7 @@ describe('change threshold value', () => { thresholds, }; - const mockEvent = { target: { value: 78 } }; + const mockEvent = ({ target: { value: '78' } } as any) as ChangeEvent; instance.onChangeThresholdValue(mockEvent, thresholds[1]); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx index b2a2e07c58d..f4db23d6656 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, ChangeEvent } from 'react'; import { Threshold } from '../../types'; import { ColorPicker } from '../ColorPicker/ColorPicker'; import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; @@ -94,14 +94,15 @@ export class ThresholdsEditor extends PureComponent { ); }; - onChangeThresholdValue = (event: any, threshold: Threshold) => { + onChangeThresholdValue = (event: ChangeEvent, threshold: Threshold) => { if (threshold.index === 0) { return; } const { thresholds } = this.state; - const parsedValue = parseInt(event.target.value, 10); - const value = isNaN(parsedValue) ? null : parsedValue; + const cleanValue = event.target.value.replace(/,/g, '.'); + const parsedValue = parseFloat(cleanValue); + const value = isNaN(parsedValue) ? '' : parsedValue; const newThresholds = thresholds.map(t => { if (t === threshold && t.index !== 0) { @@ -164,16 +165,14 @@ export class ThresholdsEditor extends PureComponent {
{threshold.color && (
- this.onChangeThresholdColor(threshold, color)} - /> + this.onChangeThresholdColor(threshold, color)} />
)}
this.onChangeThresholdValue(event, threshold)} value={value} onBlur={this.onBlur} diff --git a/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss index 503b977bbf0..a33724b3460 100644 --- a/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss +++ b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss @@ -31,7 +31,7 @@ $popper-margin-from-ref: 5px; // Themes &.popper__background--error { - @include popper-theme($tooltipBackgroundError, $tooltipBackgroundError); + @include popper-theme($tooltipBackgroundError, $white); } &.popper__background--info { diff --git a/pkg/api/api.go b/pkg/api/api.go index 0685ef3814d..6da127fb550 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -16,7 +16,7 @@ func (hs *HTTPServer) registerRoutes() { reqOrgAdmin := middleware.ReqOrgAdmin redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() - quota := middleware.Quota + quota := middleware.Quota(hs.QuotaService) bind := binding.Bind r := hs.RouteRegister @@ -286,7 +286,7 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff)) - dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(PostDashboard)) + dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(hs.PostDashboard)) dashboardRoute.Get("/home", Wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), Wrap(ImportDashboard)) @@ -294,7 +294,7 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) { dashIdRoute.Get("/versions", Wrap(GetDashboardVersions)) dashIdRoute.Get("/versions/:id", Wrap(GetDashboardVersion)) - dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), Wrap(RestoreDashboardVersion)) + dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), Wrap(hs.RestoreDashboardVersion)) dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) { dashboardPermissionRoute.Get("/", Wrap(GetDashboardPermissionList)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 2789b0bf51e..20d717ef8fa 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -18,7 +18,6 @@ import ( m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/guardian" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -208,14 +207,14 @@ func DeleteDashboardByUID(c *m.ReqContext) Response { }) } -func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response { +func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response { cmd.OrgId = c.OrgId cmd.UserId = c.UserId dash := cmd.GetDashboardModel() if dash.Id == 0 && dash.Uid == "" { - limitReached, err := quota.QuotaReached(c, "dashboard") + limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") if err != nil { return Error(500, "failed to get quota", err) } @@ -463,7 +462,7 @@ func CalculateDashboardDiff(c *m.ReqContext, apiOptions dtos.CalculateDiffOption } // RestoreDashboardVersion restores a dashboard to the given version. -func RestoreDashboardVersion(c *m.ReqContext, apiCmd dtos.RestoreDashboardVersionCommand) Response { +func (hs *HTTPServer) RestoreDashboardVersion(c *m.ReqContext, apiCmd dtos.RestoreDashboardVersionCommand) Response { dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"), "") if rsp != nil { return rsp @@ -490,7 +489,7 @@ func RestoreDashboardVersion(c *m.ReqContext, apiCmd dtos.RestoreDashboardVersio saveCmd.Dashboard.Set("uid", dash.Uid) saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version) - return PostDashboard(c, saveCmd) + return hs.PostDashboard(c, saveCmd) } func GetDashboardTags(c *m.ReqContext) { diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 8ee40920cbc..44d5cd32430 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -881,12 +881,16 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() + hs := HTTPServer{ + Bus: bus.GetBus(), + } + sc := setupScenarioContext(url) sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{OrgId: cmd.OrgId, UserId: cmd.UserId} - return PostDashboard(c, cmd) + return hs.PostDashboard(c, cmd) }) origNewDashboardService := dashboards.NewService diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index cadf6896bf4..2a430147b55 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" @@ -55,6 +56,7 @@ type HTTPServer struct { CacheService *cache.CacheService `inject:""` DatasourceCache datasources.CacheService `inject:""` AuthTokenService models.UserTokenService `inject:""` + QuotaService *quota.QuotaService `inject:""` } func (hs *HTTPServer) Init() error { diff --git a/pkg/login/ext_user.go b/pkg/login/ext_user.go index 42fb37ff9d0..f217f9fe33c 100644 --- a/pkg/login/ext_user.go +++ b/pkg/login/ext_user.go @@ -4,18 +4,30 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/quota" ) func init() { - bus.AddHandler("auth", UpsertUser) + registry.RegisterService(&LoginService{}) } var ( logger = log.New("login.ext_user") ) -func UpsertUser(cmd *m.UpsertUserCommand) error { +type LoginService struct { + Bus bus.Bus `inject:""` + QuotaService *quota.QuotaService `inject:""` +} + +func (ls *LoginService) Init() error { + ls.Bus.AddHandler(ls.UpsertUser) + + return nil +} + +func (ls *LoginService) UpsertUser(cmd *m.UpsertUserCommand) error { extUser := cmd.ExternalUser userQuery := &m.GetUserByAuthInfoQuery{ @@ -37,7 +49,7 @@ func UpsertUser(cmd *m.UpsertUserCommand) error { return ErrInvalidCredentials } - limitReached, err := quota.QuotaReached(cmd.ReqContext, "user") + limitReached, err := ls.QuotaService.QuotaReached(cmd.ReqContext, "user") if err != nil { log.Warn("Error getting user quota. error: %v", err) return ErrGettingUserQuota @@ -57,7 +69,7 @@ func UpsertUser(cmd *m.UpsertUserCommand) error { AuthModule: extUser.AuthModule, AuthId: extUser.AuthId, } - if err := bus.Dispatch(cmd2); err != nil { + if err := ls.Bus.Dispatch(cmd2); err != nil { return err } } @@ -78,12 +90,12 @@ func UpsertUser(cmd *m.UpsertUserCommand) error { // Sync isGrafanaAdmin permission if extUser.IsGrafanaAdmin != nil && *extUser.IsGrafanaAdmin != cmd.Result.IsAdmin { - if err := bus.Dispatch(&m.UpdateUserPermissionsCommand{UserId: cmd.Result.Id, IsGrafanaAdmin: *extUser.IsGrafanaAdmin}); err != nil { + if err := ls.Bus.Dispatch(&m.UpdateUserPermissionsCommand{UserId: cmd.Result.Id, IsGrafanaAdmin: *extUser.IsGrafanaAdmin}); err != nil { return err } } - err = bus.Dispatch(&m.SyncTeamsCommand{ + err = ls.Bus.Dispatch(&m.SyncTeamsCommand{ User: cmd.Result, ExternalUser: extUser, }) diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go index c02fa02e030..ef20feb1373 100644 --- a/pkg/login/ldap_test.go +++ b/pkg/login/ldap_test.go @@ -395,8 +395,11 @@ func ldapAutherScenario(desc string, fn scenarioFunc) { defer bus.ClearBusHandlers() sc := &scenarioContext{} + loginService := &LoginService{ + Bus: bus.GetBus(), + } - bus.AddHandler("test", UpsertUser) + bus.AddHandler("test", loginService.UpsertUser) bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.SyncTeamsCommand) error { return nil diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 8545c3856c9..1fbd303bebd 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -682,6 +682,7 @@ type fakeUserAuthTokenService struct { tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error) lookupTokenProvider func(unhashedToken string) (*m.UserToken, error) revokeTokenProvider func(token *m.UserToken) error + activeAuthTokenCount func() (int64, error) } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { @@ -704,6 +705,9 @@ func newFakeUserAuthTokenService() *fakeUserAuthTokenService { revokeTokenProvider: func(token *m.UserToken) error { return nil }, + activeAuthTokenCount: func() (int64, error) { + return 10, nil + }, } } @@ -722,3 +726,7 @@ func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error { return s.revokeTokenProvider(token) } + +func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) { + return s.activeAuthTokenCount() +} diff --git a/pkg/middleware/quota.go b/pkg/middleware/quota.go index 43efca43485..51f906e2c92 100644 --- a/pkg/middleware/quota.go +++ b/pkg/middleware/quota.go @@ -9,16 +9,20 @@ import ( "github.com/grafana/grafana/pkg/services/quota" ) -func Quota(target string) macaron.Handler { - return func(c *m.ReqContext) { - limitReached, err := quota.QuotaReached(c, target) - if err != nil { - c.JsonApiErr(500, "failed to get quota", err) - return - } - if limitReached { - c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", target), nil) - return +// Quota returns a function that returns a function used to call quotaservice based on target name +func Quota(quotaService *quota.QuotaService) func(target string) macaron.Handler { + //https://open.spotify.com/track/7bZSoBEAEEUsGEuLOf94Jm?si=T1Tdju5qRSmmR0zph_6RBw fuuuuunky + return func(target string) macaron.Handler { + return func(c *m.ReqContext) { + limitReached, err := quotaService.QuotaReached(c, target) + if err != nil { + c.JsonApiErr(500, "failed to get quota", err) + return + } + if limitReached { + c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", target), nil) + return + } } } } diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index e2a6ef63377..52b696cf037 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -3,9 +3,10 @@ package middleware import ( "testing" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) @@ -13,10 +14,6 @@ import ( func TestMiddlewareQuota(t *testing.T) { Convey("Given the grafana quota middleware", t, func() { - session.GetSessionCount = func() int { - return 4 - } - setting.AnonymousEnabled = false setting.Quota = setting.QuotaSettings{ Enabled: true, @@ -39,6 +36,12 @@ func TestMiddlewareQuota(t *testing.T) { }, } + fakeAuthTokenService := newFakeUserAuthTokenService() + qs := "a.QuotaService{ + AuthTokenService: fakeAuthTokenService, + } + QuotaFn := Quota(qs) + middlewareScenario("with user not logged in", func(sc *scenarioContext) { bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { query.Result = &m.GlobalQuotaDTO{ @@ -48,26 +51,30 @@ func TestMiddlewareQuota(t *testing.T) { } return nil }) + Convey("global quota not reached", func() { - sc.m.Get("/user", Quota("user"), sc.defaultHandler) + sc.m.Get("/user", QuotaFn("user"), sc.defaultHandler) sc.fakeReq("GET", "/user").exec() So(sc.resp.Code, ShouldEqual, 200) }) + Convey("global quota reached", func() { setting.Quota.Global.User = 4 - sc.m.Get("/user", Quota("user"), sc.defaultHandler) + sc.m.Get("/user", QuotaFn("user"), sc.defaultHandler) sc.fakeReq("GET", "/user").exec() So(sc.resp.Code, ShouldEqual, 403) }) + Convey("global session quota not reached", func() { setting.Quota.Global.Session = 10 - sc.m.Get("/user", Quota("session"), sc.defaultHandler) + sc.m.Get("/user", QuotaFn("session"), sc.defaultHandler) sc.fakeReq("GET", "/user").exec() So(sc.resp.Code, ShouldEqual, 200) }) + Convey("global session quota reached", func() { setting.Quota.Global.Session = 1 - sc.m.Get("/user", Quota("session"), sc.defaultHandler) + sc.m.Get("/user", QuotaFn("session"), sc.defaultHandler) sc.fakeReq("GET", "/user").exec() So(sc.resp.Code, ShouldEqual, 403) }) @@ -95,6 +102,7 @@ func TestMiddlewareQuota(t *testing.T) { } return nil }) + bus.AddHandler("userQuota", func(query *m.GetUserQuotaByTargetQuery) error { query.Result = &m.UserQuotaDTO{ Target: query.Target, @@ -103,6 +111,7 @@ func TestMiddlewareQuota(t *testing.T) { } return nil }) + bus.AddHandler("orgQuota", func(query *m.GetOrgQuotaByTargetQuery) error { query.Result = &m.OrgQuotaDTO{ Target: query.Target, @@ -111,45 +120,49 @@ func TestMiddlewareQuota(t *testing.T) { } return nil }) + Convey("global datasource quota reached", func() { setting.Quota.Global.DataSource = 4 - sc.m.Get("/ds", Quota("data_source"), sc.defaultHandler) + sc.m.Get("/ds", QuotaFn("data_source"), sc.defaultHandler) sc.fakeReq("GET", "/ds").exec() So(sc.resp.Code, ShouldEqual, 403) }) + Convey("user Org quota not reached", func() { setting.Quota.User.Org = 5 - sc.m.Get("/org", Quota("org"), sc.defaultHandler) + sc.m.Get("/org", QuotaFn("org"), sc.defaultHandler) sc.fakeReq("GET", "/org").exec() So(sc.resp.Code, ShouldEqual, 200) }) + Convey("user Org quota reached", func() { setting.Quota.User.Org = 4 - sc.m.Get("/org", Quota("org"), sc.defaultHandler) + sc.m.Get("/org", QuotaFn("org"), sc.defaultHandler) sc.fakeReq("GET", "/org").exec() So(sc.resp.Code, ShouldEqual, 403) }) + Convey("org dashboard quota not reached", func() { setting.Quota.Org.Dashboard = 10 - sc.m.Get("/dashboard", Quota("dashboard"), sc.defaultHandler) + sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler) sc.fakeReq("GET", "/dashboard").exec() So(sc.resp.Code, ShouldEqual, 200) }) + Convey("org dashboard quota reached", func() { setting.Quota.Org.Dashboard = 4 - sc.m.Get("/dashboard", Quota("dashboard"), sc.defaultHandler) + sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler) sc.fakeReq("GET", "/dashboard").exec() So(sc.resp.Code, ShouldEqual, 403) }) + Convey("org dashboard quota reached but quotas disabled", func() { setting.Quota.Org.Dashboard = 4 setting.Quota.Enabled = false - sc.m.Get("/dashboard", Quota("dashboard"), sc.defaultHandler) + sc.m.Get("/dashboard", QuotaFn("dashboard"), sc.defaultHandler) sc.fakeReq("GET", "/dashboard").exec() So(sc.resp.Code, ShouldEqual, 200) }) - }) - }) } diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index e041d42e56b..6736d699a39 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" macaron "gopkg.in/macaron.v1" @@ -66,7 +65,6 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { sc.userAuthTokenService = newFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine - session.StartSessionGC = func() {} sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index c8084cf1eba..388bc2dd4a2 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -29,4 +29,5 @@ type UserTokenService interface { LookupToken(unhashedToken string) (*UserToken, error) TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error) RevokeToken(token *UserToken) error + ActiveTokenCount() (int64, error) } diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index ef5dccd779f..648575d54cd 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -35,6 +35,13 @@ func (s *UserAuthTokenService) Init() error { return nil } +func (s *UserAuthTokenService) ActiveTokenCount() (int64, error) { + var model userAuthToken + count, err := s.SQLStore.NewSession().Where(`created_at > ? AND rotated_at > ?`, s.createdAfterParam(), s.rotatedAfterParam()).Count(&model) + + return count, err +} + func (s *UserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { clientIP = util.ParseIPAddress(clientIP) token, err := util.RandomHex(16) @@ -79,13 +86,8 @@ func (s *UserAuthTokenService) LookupToken(unhashedToken string) (*models.UserTo s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) } - tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour - tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour - createdAfter := getTime().Add(-tokenMaxLifetime).Unix() - rotatedAfter := getTime().Add(-tokenMaxInactiveLifetime).Unix() - var model userAuthToken - exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ? AND rotated_at > ?", hashedToken, hashedToken, createdAfter, rotatedAfter).Get(&model) + exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ? AND rotated_at > ?", hashedToken, hashedToken, s.createdAfterParam(), s.rotatedAfterParam()).Get(&model) if err != nil { return nil, err } @@ -219,6 +221,16 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error { return nil } +func (s *UserAuthTokenService) createdAfterParam() int64 { + tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour + return getTime().Add(-tokenMaxLifetime).Unix() +} + +func (s *UserAuthTokenService) rotatedAfterParam() int64 { + tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour + return getTime().Add(-tokenMaxInactiveLifetime).Unix() +} + func hashToken(token string) string { hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) return hex.EncodeToString(hashBytes[:]) diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 26dcbc5c868..49e7acc3a5b 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -31,6 +31,12 @@ func TestUserAuthToken(t *testing.T) { So(userToken, ShouldNotBeNil) So(userToken.AuthTokenSeen, ShouldBeFalse) + Convey("Can count active tokens", func() { + count, err := userAuthTokenService.ActiveTokenCount() + So(err, ShouldBeNil) + So(count, ShouldEqual, 1) + }) + Convey("When lookup unhashed token should return user auth token", func() { userToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) @@ -114,6 +120,12 @@ func TestUserAuthToken(t *testing.T) { notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldEqual, models.ErrUserTokenNotFound) So(notGood, ShouldBeNil) + + Convey("should not find active token when expired", func() { + count, err := userAuthTokenService.ActiveTokenCount() + So(err, ShouldBeNil) + So(count, ShouldEqual, 0) + }) }) Convey("when rotated_at is 5 days ago and created_at is 29 days and 23:59:59 ago should not find token", func() { diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index 2ec399437e6..ff2528e31e8 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -3,11 +3,23 @@ package quota import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" + "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/setting" ) -func QuotaReached(c *m.ReqContext, target string) (bool, error) { +func init() { + registry.RegisterService(&QuotaService{}) +} + +type QuotaService struct { + AuthTokenService m.UserTokenService `inject:""` +} + +func (qs *QuotaService) Init() error { + return nil +} + +func (qs *QuotaService) QuotaReached(c *m.ReqContext, target string) (bool, error) { if !setting.Quota.Enabled { return false, nil } @@ -30,7 +42,12 @@ func QuotaReached(c *m.ReqContext, target string) (bool, error) { return true, nil } if target == "session" { - usedSessions := session.GetSessionCount() + + usedSessions, err := qs.AuthTokenService.ActiveTokenCount() + if err != nil { + return false, err + } + if int64(usedSessions) > scope.DefaultLimit { c.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit) return true, nil diff --git a/pkg/services/session/session.go b/pkg/services/session/session.go index 2e60b8a25d7..3481c99ce58 100644 --- a/pkg/services/session/session.go +++ b/pkg/services/session/session.go @@ -19,7 +19,7 @@ const ( var sessionManager *ms.Manager var sessionOptions *ms.Options -var StartSessionGC func() +var StartSessionGC func() = func() {} var GetSessionCount func() int var sessionLogger = log.New("session") var sessionConnMaxLifetime int64 diff --git a/public/app/core/utils/reselect.ts b/public/app/core/utils/reselect.ts new file mode 100644 index 00000000000..7c8fc7727b0 --- /dev/null +++ b/public/app/core/utils/reselect.ts @@ -0,0 +1,5 @@ +import { memoize } from 'lodash'; +import { createSelectorCreator } from 'reselect'; + +const hashFn = (...args) => args.reduce((acc, val) => acc + '-' + JSON.stringify(val), ''); +export const createLodashMemoizedSelector = createSelectorCreator(memoize, hashFn); diff --git a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts index e5cfac97d5f..fc3b98b4848 100755 --- a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts +++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts @@ -38,7 +38,7 @@ export class SettingsCtrl { }); }); - this.canSaveAs = this.dashboard.meta.canEdit && contextSrv.hasEditPermissionInFolders; + this.canSaveAs = contextSrv.hasEditPermissionInFolders; this.canSave = this.dashboard.meta.canSave; this.canDelete = this.dashboard.meta.canSave; diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index 04722214e87..6cec3254106 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -36,31 +36,29 @@ class NewDataSourcePage extends PureComponent { return ( -
-

Choose data source type

-
- -
-
- {dataSourceTypes.map((plugin, index) => { - return ( -
this.onDataSourceTypeClicked(plugin)} - className="add-data-source-grid-item" - key={`${plugin.id}-${index}`} - > - - {plugin.name} -
- ); - })} -
+

Choose data source type

+
+ +
+
+ {dataSourceTypes.map((plugin, index) => { + return ( +
this.onDataSourceTypeClicked(plugin)} + className="add-data-source-grid-item" + key={`${plugin.id}-${index}`} + > + + {plugin.name} +
+ ); + })}
diff --git a/public/app/features/datasources/settings/ButtonRow.test.tsx b/public/app/features/datasources/settings/ButtonRow.test.tsx index 0acab8941ff..84b16d829d5 100644 --- a/public/app/features/datasources/settings/ButtonRow.test.tsx +++ b/public/app/features/datasources/settings/ButtonRow.test.tsx @@ -7,6 +7,7 @@ const setup = (propOverrides?: object) => { isReadOnly: true, onSubmit: jest.fn(), onDelete: jest.fn(), + onTest: jest.fn(), }; Object.assign(props, propOverrides); diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/settings/ButtonRow.tsx index 911bbe2c8b4..9f633ee6bcf 100644 --- a/public/app/features/datasources/settings/ButtonRow.tsx +++ b/public/app/features/datasources/settings/ButtonRow.tsx @@ -4,14 +4,22 @@ export interface Props { isReadOnly: boolean; onDelete: () => void; onSubmit: (event) => void; + onTest: (event) => void; } -const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit }) => { +const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit, onTest }) => { return (
- + {!isReadOnly && ( + + )} + {isReadOnly && ( + + )} diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index ff840390cf5..ba50c9430ba 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -72,6 +72,12 @@ export class DataSourceSettingsPage extends PureComponent { this.testDataSource(); }; + onTest = async (evt: React.FormEvent) => { + evt.preventDefault(); + + this.testDataSource(); + }; + onDelete = () => { appEvents.emit('confirm-modal', { title: 'Delete', @@ -180,7 +186,7 @@ export class DataSourceSettingsPage extends PureComponent { return ( - {this.hasDataSource &&
+ {this.hasDataSource && (
{this.isReadOnly() && this.renderIsReadOnlyMessage()} @@ -201,7 +207,7 @@ export class DataSourceSettingsPage extends PureComponent { /> )} -
+
{testingMessage && (
@@ -222,10 +228,11 @@ export class DataSourceSettingsPage extends PureComponent { onSubmit={event => this.onSubmit(event)} isReadOnly={this.isReadOnly()} onDelete={this.onDelete} + onTest={event => this.onTest(event)} />
-
} + )} ); diff --git a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap index 506a03ccd83..00251237f83 100644 --- a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap +++ b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap @@ -5,12 +5,11 @@ exports[`Render should render component 1`] = ` className="gf-form-button-row" > - -
- -
+
+
+
+ + +
+
+ + +
+
diff --git a/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap b/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap index e51e5c0e180..cd6cdf4a032 100644 --- a/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap +++ b/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap @@ -7,62 +7,58 @@ exports[`Render should enable save button 1`] = ` -
-

+
+
- Folder Settings -

-
- -
- - + +
+
+
-
+ - -
- -
+ + Delete + +
+
@@ -75,62 +71,58 @@ exports[`Render should render component 1`] = ` -
-

+
+
- Folder Settings -

-
- -
- - + +
+
+
-
+ - -
- -
+ + Delete + +
+
diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx index 7a38197ff71..235936aa8f5 100644 --- a/public/app/features/teams/TeamPages.tsx +++ b/public/app/features/teams/TeamPages.tsx @@ -49,9 +49,9 @@ export class TeamPages extends PureComponent { async fetchTeam() { const { loadTeam, teamId } = this.props; - this.setState({isLoading: true}); + this.setState({ isLoading: true }); const team = await loadTeam(teamId); - this.setState({isLoading: false}); + this.setState({ isLoading: false }); return team; } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 066ca226157..7a6af04b2ee 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -11,7 +11,7 @@ import { } from '@grafana/ui'; import { Emitter } from 'app/core/core'; -import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model'; +import { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model'; import TableModel from 'app/core/table_model'; export interface CompletionItem { @@ -242,6 +242,11 @@ export interface ExploreItemState { * Current logs deduplication strategy */ dedupStrategy?: LogsDedupStrategy; + + /** + * Currently hidden log series + */ + hiddenLogLevels?: LogLevel[]; } export interface ExploreUIState { diff --git a/yarn.lock b/yarn.lock index 2fb4a5d3ee2..e77b8af58cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1833,6 +1833,13 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/reselect@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/reselect/-/reselect-2.2.0.tgz#c667206cfdc38190e1d379babe08865b2288575f" + integrity sha1-xmcgbP3DgZDh03m6vgiGWyKIV18= + dependencies: + reselect "*" + "@types/storybook__addon-actions@^3.4.1": version "3.4.1" resolved "https://registry.yarnpkg.com/@types/storybook__addon-actions/-/storybook__addon-actions-3.4.1.tgz#8f90d76b023b58ee794170f2fe774a3fddda2c1d" @@ -4712,6 +4719,11 @@ ci-info@^1.5.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + cidr-regex@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-1.0.6.tgz#74abfd619df370b9d54ab14475568e97dd64c0c1" @@ -7922,6 +7934,11 @@ get-stdin@^4.0.1: resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4= +get-stdin@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" + integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== + get-stream@3.0.0, get-stream@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" @@ -8886,14 +8903,21 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -husky@^0.14.3: - version "0.14.3" - resolved "https://registry.yarnpkg.com/husky/-/husky-0.14.3.tgz#c69ed74e2d2779769a17ba8399b54ce0b63c12c3" - integrity sha512-e21wivqHpstpoiWA/Yi8eFti8E+sQDSS53cpJsPptPs295QTOQR0ZwnHo2TXy1XOpZFD9rPOd3NpmqTK6uMLJA== +husky@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/husky/-/husky-1.3.1.tgz#26823e399300388ca2afff11cfa8a86b0033fae0" + integrity sha512-86U6sVVVf4b5NYSZ0yvv88dRgBSSXXmHaiq5pP4KDj5JVzdwKgBjEtUPOm8hcoytezFwbU+7gotXNhpHdystlg== dependencies: - is-ci "^1.0.10" - normalize-path "^1.0.0" - strip-indent "^2.0.0" + cosmiconfig "^5.0.7" + execa "^1.0.0" + find-up "^3.0.0" + get-stdin "^6.0.0" + is-ci "^2.0.0" + pkg-dir "^3.0.0" + please-upgrade-node "^3.1.1" + read-pkg "^4.0.1" + run-node "^1.0.0" + slash "^2.0.0" iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: version "0.4.24" @@ -9279,6 +9303,13 @@ is-ci@^1.0.10: dependencies: ci-info "^1.5.0" +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + is-cidr@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-1.0.0.tgz#fb5aacf659255310359da32cae03e40c6a1c2afc" @@ -11925,11 +11956,6 @@ normalize-path@2.0.1: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.0.1.tgz#47886ac1662760d4261b7d979d241709d3ce3f7a" integrity sha1-R4hqwWYnYNQmG32XnSQXCdPOP3o= -normalize-path@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-1.0.0.tgz#32d0e472f91ff345701c15a8311018d3b0a90379" - integrity sha1-MtDkcvkf80VwHBWoMRAY07CpA3k= - normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" @@ -12948,7 +12974,7 @@ pkg-up@^1.0.0: dependencies: find-up "^1.0.0" -please-upgrade-node@^3.0.2: +please-upgrade-node@^3.0.2, please-upgrade-node@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.1.1.tgz#ed320051dfcc5024fae696712c8288993595e8ac" integrity sha512-KY1uHnQ2NlQHqIJQpnh/i54rKkuxCEBx+voJIS/Mvb+L2iYd2NMotwduhKTMjfC1uKoX3VXOxLjIYG66dfJTVQ== @@ -14336,6 +14362,15 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" +read-pkg@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237" + integrity sha1-ljYlN48+HE1IyFhytabsfV0JMjc= + dependencies: + normalize-package-data "^2.3.2" + parse-json "^4.0.0" + pify "^3.0.0" + read@1, read@~1.0.1, read@~1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" @@ -14823,6 +14858,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +reselect@*, reselect@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" + integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -14990,6 +15030,11 @@ run-async@^2.0.0, run-async@^2.2.0: dependencies: is-promise "^2.1.0" +run-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/run-node/-/run-node-1.0.0.tgz#46b50b946a2aa2d4947ae1d886e9856fd9cabe5e" + integrity sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A== + run-queue@^1.0.0, run-queue@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" @@ -15448,6 +15493,11 @@ slash@^1.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + slate-base64-serializer@^0.2.36: version "0.2.94" resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.94.tgz#b908c3af481b9a0ead78f313653414c4b2b4b2d5" @@ -16147,11 +16197,6 @@ strip-indent@^1.0.1: dependencies: get-stdin "^4.0.1" -strip-indent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" - integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= - strip-json-comments@~1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91"