Hugo Häggmark 6 роки тому
батько
коміт
90d162e608
38 змінених файлів з 815 додано та 634 видалено
  1. 0 3
      devenv/docker/blocks/elastic5/docker-compose.yaml
  2. 3 2
      package.json
  3. 3 3
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  4. 8 9
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  5. 1 1
      packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
  6. 3 3
      pkg/api/api.go
  7. 4 5
      pkg/api/dashboard.go
  8. 5 1
      pkg/api/dashboard_test.go
  9. 2 0
      pkg/api/http_server.go
  10. 18 6
      pkg/login/ext_user.go
  11. 4 1
      pkg/login/ldap_test.go
  12. 8 0
      pkg/middleware/middleware_test.go
  13. 14 10
      pkg/middleware/quota.go
  14. 30 17
      pkg/middleware/quota_test.go
  15. 0 2
      pkg/middleware/recovery_test.go
  16. 1 0
      pkg/models/user_token.go
  17. 18 6
      pkg/services/auth/auth_token.go
  18. 12 0
      pkg/services/auth/auth_token_test.go
  19. 20 3
      pkg/services/quota/quota.go
  20. 1 1
      pkg/services/session/session.go
  21. 5 0
      public/app/core/utils/reselect.ts
  22. 1 1
      public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
  23. 23 25
      public/app/features/datasources/NewDataSourcePage.tsx
  24. 1 0
      public/app/features/datasources/settings/ButtonRow.test.tsx
  25. 12 4
      public/app/features/datasources/settings/ButtonRow.tsx
  26. 10 3
      public/app/features/datasources/settings/DataSourceSettingsPage.tsx
  27. 3 4
      public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap
  28. 336 348
      public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap
  29. 7 17
      public/app/features/explore/Logs.tsx
  30. 25 12
      public/app/features/explore/LogsContainer.tsx
  31. 11 2
      public/app/features/explore/state/actionTypes.ts
  32. 11 0
      public/app/features/explore/state/reducers.ts
  33. 30 0
      public/app/features/explore/state/selectors.ts
  34. 24 26
      public/app/features/folders/FolderSettingsPage.tsx
  35. 90 98
      public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap
  36. 2 2
      public/app/features/teams/TeamPages.tsx
  37. 6 1
      public/app/types/explore.ts
  38. 63 18
      yarn.lock

+ 0 - 3
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

+ 3 - 2
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",

+ 3 - 3
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<HTMLInputElement>;
 
     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<HTMLInputElement>;
 
     instance.onChangeThresholdValue(mockEvent, thresholds[1]);
 

+ 8 - 9
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<Props, State> {
     );
   };
 
-  onChangeThresholdValue = (event: any, threshold: Threshold) => {
+  onChangeThresholdValue = (event: ChangeEvent<HTMLInputElement>, 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<Props, State> {
         <div className="thresholds-row-input-inner-color">
           {threshold.color && (
             <div className="thresholds-row-input-inner-color-colorpicker">
-              <ColorPicker
-                color={threshold.color}
-                onChange={color => this.onChangeThresholdColor(threshold, color)}
-              />
+              <ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
             </div>
           )}
         </div>
         <div className="thresholds-row-input-inner-value">
           <input
-            type="text"
+            type="number"
+            step="0.0001"
             onChange={event => this.onChangeThresholdValue(event, threshold)}
             value={value}
             onBlur={this.onBlur}

+ 1 - 1
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 {

+ 3 - 3
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))

+ 4 - 5
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) {

+ 5 - 1
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

+ 2 - 0
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 {

+ 18 - 6
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,
 	})

+ 4 - 1
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

+ 8 - 0
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()
+}

+ 14 - 10
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
+			}
 		}
 	}
 }

+ 30 - 17
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 := &quota.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)
 			})
-
 		})
-
 	})
 }

+ 0 - 2
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())
 

+ 1 - 0
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)
 }

+ 18 - 6
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[:])

+ 12 - 0
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() {

+ 20 - 3
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

+ 1 - 1
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

+ 5 - 0
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);

+ 1 - 1
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;
 

+ 23 - 25
public/app/features/datasources/NewDataSourcePage.tsx

@@ -36,31 +36,29 @@ class NewDataSourcePage extends PureComponent<Props> {
     return (
       <Page navModel={navModel}>
         <Page.Contents isLoading={isLoading}>
-          <div className="page-container page-body">
-            <h2 className="add-data-source-header">Choose data source type</h2>
-            <div className="add-data-source-search">
-              <FilterInput
-                labelClassName="gf-form--has-input-icon"
-                inputClassName="gf-form-input width-20"
-                value={dataSourceTypeSearchQuery}
-                onChange={this.onSearchQueryChange}
-                placeholder="Filter by name or type"
-              />
-            </div>
-            <div className="add-data-source-grid">
-              {dataSourceTypes.map((plugin, index) => {
-                return (
-                  <div
-                    onClick={() => this.onDataSourceTypeClicked(plugin)}
-                    className="add-data-source-grid-item"
-                    key={`${plugin.id}-${index}`}
-                  >
-                    <img className="add-data-source-grid-item-logo" src={plugin.info.logos.small} />
-                    <span className="add-data-source-grid-item-text">{plugin.name}</span>
-                  </div>
-                );
-              })}
-            </div>
+          <h2 className="add-data-source-header">Choose data source type</h2>
+          <div className="add-data-source-search">
+            <FilterInput
+              labelClassName="gf-form--has-input-icon"
+              inputClassName="gf-form-input width-20"
+              value={dataSourceTypeSearchQuery}
+              onChange={this.onSearchQueryChange}
+              placeholder="Filter by name or type"
+            />
+          </div>
+          <div className="add-data-source-grid">
+            {dataSourceTypes.map((plugin, index) => {
+              return (
+                <div
+                  onClick={() => this.onDataSourceTypeClicked(plugin)}
+                  className="add-data-source-grid-item"
+                  key={`${plugin.id}-${index}`}
+                >
+                  <img className="add-data-source-grid-item-logo" src={plugin.info.logos.small} />
+                  <span className="add-data-source-grid-item-text">{plugin.name}</span>
+                </div>
+              );
+            })}
           </div>
         </Page.Contents>
       </Page>

+ 1 - 0
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);

+ 12 - 4
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<Props> = ({ isReadOnly, onDelete, onSubmit }) => {
+const ButtonRow: FC<Props> = ({ isReadOnly, onDelete, onSubmit, onTest }) => {
   return (
     <div className="gf-form-button-row">
-      <button type="submit" className="btn btn-primary" disabled={isReadOnly} onClick={event => onSubmit(event)}>
-        Save &amp; Test
-      </button>
+      {!isReadOnly && (
+        <button type="submit" className="btn btn-primary" disabled={isReadOnly} onClick={event => onSubmit(event)}>
+          Save &amp; Test
+        </button>
+      )}
+      {isReadOnly && (
+        <button type="submit" className="btn btn-success" onClick={onTest}>
+          Test
+        </button>
+      )}
       <button type="submit" className="btn btn-danger" disabled={isReadOnly} onClick={onDelete}>
         Delete
       </button>

+ 10 - 3
public/app/features/datasources/settings/DataSourceSettingsPage.tsx

@@ -72,6 +72,12 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
     this.testDataSource();
   };
 
+  onTest = async (evt: React.FormEvent<HTMLFormElement>) => {
+    evt.preventDefault();
+
+    this.testDataSource();
+  };
+
   onDelete = () => {
     appEvents.emit('confirm-modal', {
       title: 'Delete',
@@ -180,7 +186,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
     return (
       <Page navModel={navModel}>
         <Page.Contents isLoading={!this.hasDataSource}>
-          {this.hasDataSource && <div className="page-container page-body">
+          {this.hasDataSource && (
             <div>
               <form onSubmit={this.onSubmit}>
                 {this.isReadOnly() && this.renderIsReadOnlyMessage()}
@@ -201,7 +207,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
                   />
                 )}
 
-                <div className="gf-form-group section">
+                <div className="gf-form-group">
                   {testingMessage && (
                     <div className={`alert-${testingStatus} alert`}>
                       <div className="alert-icon">
@@ -222,10 +228,11 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
                   onSubmit={event => this.onSubmit(event)}
                   isReadOnly={this.isReadOnly()}
                   onDelete={this.onDelete}
+                  onTest={event => this.onTest(event)}
                 />
               </form>
             </div>
-          </div>}
+          )}
         </Page.Contents>
       </Page>
     );

+ 3 - 4
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"
 >
   <button
-    className="btn btn-primary"
-    disabled={true}
-    onClick={[Function]}
+    className="btn btn-success"
+    onClick={[MockFunction]}
     type="submit"
   >
-    Save & Test
+    Test
   </button>
   <button
     className="btn btn-danger"

+ 336 - 348
public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap

@@ -7,99 +7,96 @@ exports[`Render should render alpha info text 1`] = `
   <PageContents
     isLoading={false}
   >
-    <div
-      className="page-container page-body"
-    >
-      <div>
-        <form
-          onSubmit={[Function]}
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <div
+          className="grafana-info-box"
         >
-          <div
-            className="grafana-info-box"
-          >
-            This plugin is marked as being in alpha state, which means it is in early development phase and updates will include breaking changes.
-          </div>
-          <BasicSettings
-            dataSourceName="gdev-cloudwatch"
-            isDefault={false}
-            onDefaultChange={[Function]}
-            onNameChange={[Function]}
-          />
-          <PluginSettings
-            dataSource={
-              Object {
-                "access": "",
-                "basicAuth": false,
-                "basicAuthPassword": "",
-                "basicAuthUser": "",
-                "database": "",
-                "id": 13,
-                "isDefault": false,
-                "jsonData": Object {
-                  "authType": "credentials",
-                  "defaultRegion": "eu-west-2",
-                },
-                "name": "gdev-cloudwatch",
-                "orgId": 1,
-                "password": "",
-                "readOnly": false,
-                "type": "cloudwatch",
-                "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
-                "url": "",
-                "user": "",
-                "withCredentials": false,
-              }
+          This plugin is marked as being in alpha state, which means it is in early development phase and updates will include breaking changes.
+        </div>
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          isDefault={false}
+          onDefaultChange={[Function]}
+          onNameChange={[Function]}
+        />
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": false,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
             }
-            dataSourceMeta={
-              Object {
-                "defaultNavUrl": "some/url",
-                "enabled": false,
-                "hasUpdate": false,
-                "id": "1",
-                "info": Object {
-                  "author": Object {
-                    "name": "Grafana Labs",
-                    "url": "url/to/GrafanaLabs",
-                  },
-                  "description": "pretty decent plugin",
-                  "links": Array [
-                    Object {
-                      "name": "project",
-                      "url": "one link",
-                    },
-                  ],
-                  "logos": Object {
-                    "large": "large/logo",
-                    "small": "small/logo",
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  Object {
+                    "name": "project",
+                    "url": "one link",
                   },
-                  "screenshots": Array [
-                    Object {
-                      "path": "screenshot",
-                    },
-                  ],
-                  "updated": "2018-09-26",
-                  "version": "1",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
                 },
-                "latestVersion": "1",
-                "module": Object {},
-                "name": "pretty cool plugin 1",
-                "pinned": false,
-                "state": "alpha",
-                "type": "",
-              }
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "alpha",
+              "type": "",
             }
-            onModelChange={[Function]}
-          />
-          <div
-            className="gf-form-group section"
-          />
-          <ButtonRow
-            isReadOnly={false}
-            onDelete={[Function]}
-            onSubmit={[Function]}
-          />
-        </form>
-      </div>
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group"
+        />
+        <ButtonRow
+          isReadOnly={false}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+          onTest={[Function]}
+        />
+      </form>
     </div>
   </PageContents>
 </Page>
@@ -112,99 +109,96 @@ exports[`Render should render beta info text 1`] = `
   <PageContents
     isLoading={false}
   >
-    <div
-      className="page-container page-body"
-    >
-      <div>
-        <form
-          onSubmit={[Function]}
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <div
+          className="grafana-info-box"
         >
-          <div
-            className="grafana-info-box"
-          >
-            This plugin is marked as being in a beta development state. This means it is in currently in active development and could be missing important features.
-          </div>
-          <BasicSettings
-            dataSourceName="gdev-cloudwatch"
-            isDefault={false}
-            onDefaultChange={[Function]}
-            onNameChange={[Function]}
-          />
-          <PluginSettings
-            dataSource={
-              Object {
-                "access": "",
-                "basicAuth": false,
-                "basicAuthPassword": "",
-                "basicAuthUser": "",
-                "database": "",
-                "id": 13,
-                "isDefault": false,
-                "jsonData": Object {
-                  "authType": "credentials",
-                  "defaultRegion": "eu-west-2",
-                },
-                "name": "gdev-cloudwatch",
-                "orgId": 1,
-                "password": "",
-                "readOnly": false,
-                "type": "cloudwatch",
-                "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
-                "url": "",
-                "user": "",
-                "withCredentials": false,
-              }
+          This plugin is marked as being in a beta development state. This means it is in currently in active development and could be missing important features.
+        </div>
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          isDefault={false}
+          onDefaultChange={[Function]}
+          onNameChange={[Function]}
+        />
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": false,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
             }
-            dataSourceMeta={
-              Object {
-                "defaultNavUrl": "some/url",
-                "enabled": false,
-                "hasUpdate": false,
-                "id": "1",
-                "info": Object {
-                  "author": Object {
-                    "name": "Grafana Labs",
-                    "url": "url/to/GrafanaLabs",
-                  },
-                  "description": "pretty decent plugin",
-                  "links": Array [
-                    Object {
-                      "name": "project",
-                      "url": "one link",
-                    },
-                  ],
-                  "logos": Object {
-                    "large": "large/logo",
-                    "small": "small/logo",
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  Object {
+                    "name": "project",
+                    "url": "one link",
                   },
-                  "screenshots": Array [
-                    Object {
-                      "path": "screenshot",
-                    },
-                  ],
-                  "updated": "2018-09-26",
-                  "version": "1",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
                 },
-                "latestVersion": "1",
-                "module": Object {},
-                "name": "pretty cool plugin 1",
-                "pinned": false,
-                "state": "beta",
-                "type": "",
-              }
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "beta",
+              "type": "",
             }
-            onModelChange={[Function]}
-          />
-          <div
-            className="gf-form-group section"
-          />
-          <ButtonRow
-            isReadOnly={false}
-            onDelete={[Function]}
-            onSubmit={[Function]}
-          />
-        </form>
-      </div>
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group"
+        />
+        <ButtonRow
+          isReadOnly={false}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+          onTest={[Function]}
+        />
+      </form>
     </div>
   </PageContents>
 </Page>
@@ -217,94 +211,91 @@ exports[`Render should render component 1`] = `
   <PageContents
     isLoading={false}
   >
-    <div
-      className="page-container page-body"
-    >
-      <div>
-        <form
-          onSubmit={[Function]}
-        >
-          <BasicSettings
-            dataSourceName="gdev-cloudwatch"
-            isDefault={false}
-            onDefaultChange={[Function]}
-            onNameChange={[Function]}
-          />
-          <PluginSettings
-            dataSource={
-              Object {
-                "access": "",
-                "basicAuth": false,
-                "basicAuthPassword": "",
-                "basicAuthUser": "",
-                "database": "",
-                "id": 13,
-                "isDefault": false,
-                "jsonData": Object {
-                  "authType": "credentials",
-                  "defaultRegion": "eu-west-2",
-                },
-                "name": "gdev-cloudwatch",
-                "orgId": 1,
-                "password": "",
-                "readOnly": false,
-                "type": "cloudwatch",
-                "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
-                "url": "",
-                "user": "",
-                "withCredentials": false,
-              }
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          isDefault={false}
+          onDefaultChange={[Function]}
+          onNameChange={[Function]}
+        />
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": false,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
             }
-            dataSourceMeta={
-              Object {
-                "defaultNavUrl": "some/url",
-                "enabled": false,
-                "hasUpdate": false,
-                "id": "1",
-                "info": Object {
-                  "author": Object {
-                    "name": "Grafana Labs",
-                    "url": "url/to/GrafanaLabs",
-                  },
-                  "description": "pretty decent plugin",
-                  "links": Array [
-                    Object {
-                      "name": "project",
-                      "url": "one link",
-                    },
-                  ],
-                  "logos": Object {
-                    "large": "large/logo",
-                    "small": "small/logo",
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  Object {
+                    "name": "project",
+                    "url": "one link",
                   },
-                  "screenshots": Array [
-                    Object {
-                      "path": "screenshot",
-                    },
-                  ],
-                  "updated": "2018-09-26",
-                  "version": "1",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
                 },
-                "latestVersion": "1",
-                "module": Object {},
-                "name": "pretty cool plugin 1",
-                "pinned": false,
-                "state": "",
-                "type": "",
-              }
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "",
+              "type": "",
             }
-            onModelChange={[Function]}
-          />
-          <div
-            className="gf-form-group section"
-          />
-          <ButtonRow
-            isReadOnly={false}
-            onDelete={[Function]}
-            onSubmit={[Function]}
-          />
-        </form>
-      </div>
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group"
+        />
+        <ButtonRow
+          isReadOnly={false}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+          onTest={[Function]}
+        />
+      </form>
     </div>
   </PageContents>
 </Page>
@@ -317,99 +308,96 @@ exports[`Render should render is ready only message 1`] = `
   <PageContents
     isLoading={false}
   >
-    <div
-      className="page-container page-body"
-    >
-      <div>
-        <form
-          onSubmit={[Function]}
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <div
+          className="grafana-info-box span8"
         >
-          <div
-            className="grafana-info-box span8"
-          >
-            This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
-          </div>
-          <BasicSettings
-            dataSourceName="gdev-cloudwatch"
-            isDefault={false}
-            onDefaultChange={[Function]}
-            onNameChange={[Function]}
-          />
-          <PluginSettings
-            dataSource={
-              Object {
-                "access": "",
-                "basicAuth": false,
-                "basicAuthPassword": "",
-                "basicAuthUser": "",
-                "database": "",
-                "id": 13,
-                "isDefault": false,
-                "jsonData": Object {
-                  "authType": "credentials",
-                  "defaultRegion": "eu-west-2",
-                },
-                "name": "gdev-cloudwatch",
-                "orgId": 1,
-                "password": "",
-                "readOnly": true,
-                "type": "cloudwatch",
-                "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
-                "url": "",
-                "user": "",
-                "withCredentials": false,
-              }
+          This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
+        </div>
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          isDefault={false}
+          onDefaultChange={[Function]}
+          onNameChange={[Function]}
+        />
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": true,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
             }
-            dataSourceMeta={
-              Object {
-                "defaultNavUrl": "some/url",
-                "enabled": false,
-                "hasUpdate": false,
-                "id": "1",
-                "info": Object {
-                  "author": Object {
-                    "name": "Grafana Labs",
-                    "url": "url/to/GrafanaLabs",
-                  },
-                  "description": "pretty decent plugin",
-                  "links": Array [
-                    Object {
-                      "name": "project",
-                      "url": "one link",
-                    },
-                  ],
-                  "logos": Object {
-                    "large": "large/logo",
-                    "small": "small/logo",
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  Object {
+                    "name": "project",
+                    "url": "one link",
                   },
-                  "screenshots": Array [
-                    Object {
-                      "path": "screenshot",
-                    },
-                  ],
-                  "updated": "2018-09-26",
-                  "version": "1",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
                 },
-                "latestVersion": "1",
-                "module": Object {},
-                "name": "pretty cool plugin 1",
-                "pinned": false,
-                "state": "",
-                "type": "",
-              }
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "",
+              "type": "",
             }
-            onModelChange={[Function]}
-          />
-          <div
-            className="gf-form-group section"
-          />
-          <ButtonRow
-            isReadOnly={true}
-            onDelete={[Function]}
-            onSubmit={[Function]}
-          />
-        </form>
-      </div>
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group"
+        />
+        <ButtonRow
+          isReadOnly={true}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+          onTest={[Function]}
+        />
+      </form>
     </div>
   </PageContents>
 </Page>

+ 7 - 17
public/app/features/explore/Logs.tsx

@@ -5,15 +5,7 @@ import * as rangeUtil from 'app/core/utils/rangeutil';
 import { RawTimeRange, Switch } from '@grafana/ui';
 import TimeSeries from 'app/core/time_series2';
 
-import {
-  LogsDedupDescription,
-  LogsDedupStrategy,
-  LogsModel,
-  dedupLogRows,
-  filterLogLevels,
-  LogLevel,
-  LogsMetaKind,
-} from 'app/core/logs_model';
+import { LogsDedupDescription, LogsDedupStrategy, LogsModel, LogLevel, LogsMetaKind } from 'app/core/logs_model';
 
 import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
 
@@ -51,6 +43,7 @@ function renderMetaItem(value: any, kind: LogsMetaKind) {
 
 interface Props {
   data?: LogsModel;
+  dedupedData?: LogsModel;
   width: number;
   exploreId: string;
   highlighterExpressions: string[];
@@ -59,16 +52,17 @@ interface Props {
   scanning?: boolean;
   scanRange?: RawTimeRange;
   dedupStrategy: LogsDedupStrategy;
+  hiddenLogLevels: Set<LogLevel>;
   onChangeTime?: (range: RawTimeRange) => void;
   onClickLabel?: (label: string, value: string) => void;
   onStartScanning?: () => void;
   onStopScanning?: () => void;
   onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void;
+  onToggleLogLevel: (hiddenLogLevels: Set<LogLevel>) => void;
 }
 
 interface State {
   deferLogs: boolean;
-  hiddenLogLevels: Set<LogLevel>;
   renderAll: boolean;
   showLabels: boolean | null; // Tristate: null means auto
   showLocalTime: boolean;
@@ -81,7 +75,6 @@ export default class Logs extends PureComponent<Props, State> {
 
   state = {
     deferLogs: true,
-    hiddenLogLevels: new Set(),
     renderAll: false,
     showLabels: null,
     showLocalTime: true,
@@ -142,7 +135,7 @@ export default class Logs extends PureComponent<Props, State> {
 
   onToggleLogLevel = (rawLevel: string, hiddenRawLevels: Set<string>) => {
     const hiddenLogLevels: Set<LogLevel> = new Set(Array.from(hiddenRawLevels).map(level => LogLevel[level]));
-    this.setState({ hiddenLogLevels });
+    this.props.onToggleLogLevel(hiddenLogLevels);
   };
 
   onClickScan = (event: React.SyntheticEvent) => {
@@ -166,21 +159,18 @@ export default class Logs extends PureComponent<Props, State> {
       scanning,
       scanRange,
       width,
+      dedupedData,
     } = this.props;
 
     if (!data) {
       return null;
     }
 
-    const { deferLogs, hiddenLogLevels, renderAll, showLocalTime, showUtc,  } = this.state;
+    const { deferLogs, renderAll, showLocalTime, showUtc } = this.state;
     let { showLabels } = this.state;
     const { dedupStrategy } = this.props;
     const hasData = data && data.rows && data.rows.length > 0;
     const showDuplicates = dedupStrategy !== LogsDedupStrategy.none;
-
-    // Filtering
-    const filteredData = filterLogLevels(data, hiddenLogLevels);
-    const dedupedData = dedupLogRows(filteredData, dedupStrategy);
     const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0);
     const meta = [...data.meta];
 

+ 25 - 12
public/app/features/explore/LogsContainer.tsx

@@ -4,18 +4,21 @@ import { connect } from 'react-redux';
 import { RawTimeRange, TimeRange } from '@grafana/ui';
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
-import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model';
+import { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model';
 import { StoreState } from 'app/types';
 
 import { toggleLogs, changeDedupStrategy } from './state/actions';
 import Logs from './Logs';
 import Panel from './Panel';
+import { toggleLogLevelAction } from 'app/features/explore/state/actionTypes';
+import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
 
 interface LogsContainerProps {
   exploreId: ExploreId;
   loading: boolean;
   logsHighlighterExpressions?: string[];
   logsResult?: LogsModel;
+  dedupedResult?: LogsModel;
   onChangeTime: (range: TimeRange) => void;
   onClickLabel: (key: string, value: string) => void;
   onStartScanning: () => void;
@@ -25,8 +28,10 @@ interface LogsContainerProps {
   scanRange?: RawTimeRange;
   showingLogs: boolean;
   toggleLogs: typeof toggleLogs;
+  toggleLogLevelAction: typeof toggleLogLevelAction;
   changeDedupStrategy: typeof changeDedupStrategy;
   dedupStrategy: LogsDedupStrategy;
+  hiddenLogLevels: Set<LogLevel>;
   width: number;
 }
 
@@ -39,12 +44,21 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
     this.props.changeDedupStrategy(this.props.exploreId, dedupStrategy);
   };
 
+  hangleToggleLogLevel = (hiddenLogLevels: Set<LogLevel>) => {
+    const { exploreId } = this.props;
+    this.props.toggleLogLevelAction({
+      exploreId,
+      hiddenLogLevels,
+    });
+  };
+
   render() {
     const {
       exploreId,
       loading,
       logsHighlighterExpressions,
       logsResult,
+      dedupedResult,
       onChangeTime,
       onClickLabel,
       onStartScanning,
@@ -54,6 +68,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
       scanning,
       scanRange,
       width,
+      hiddenLogLevels,
     } = this.props;
 
     return (
@@ -61,6 +76,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
         <Logs
           dedupStrategy={this.props.dedupStrategy || LogsDedupStrategy.none}
           data={logsResult}
+          dedupedData={dedupedResult}
           exploreId={exploreId}
           key={logsResult && logsResult.id}
           highlighterExpressions={logsHighlighterExpressions}
@@ -70,32 +86,26 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
           onStartScanning={onStartScanning}
           onStopScanning={onStopScanning}
           onDedupStrategyChange={this.handleDedupStrategyChange}
+          onToggleLogLevel={this.hangleToggleLogLevel}
           range={range}
           scanning={scanning}
           scanRange={scanRange}
           width={width}
+          hiddenLogLevels={hiddenLogLevels}
         />
       </Panel>
     );
   }
 }
 
-const selectItemUIState = (itemState: ExploreItemState) => {
-  const { showingGraph, showingLogs, showingTable, showingStartPage, dedupStrategy } = itemState;
-  return {
-    showingGraph,
-    showingLogs,
-    showingTable,
-    showingStartPage,
-    dedupStrategy,
-  };
-};
 function mapStateToProps(state: StoreState, { exploreId }) {
   const explore = state.explore;
   const item: ExploreItemState = explore[exploreId];
   const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, range } = item;
   const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
-  const {showingLogs, dedupStrategy} = selectItemUIState(item);
+  const { showingLogs, dedupStrategy } = exploreItemUIStateSelector(item);
+  const hiddenLogLevels = new Set(item.hiddenLogLevels);
+  const dedupedResult = deduplicatedLogsSelector(item);
 
   return {
     loading,
@@ -106,12 +116,15 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     showingLogs,
     range,
     dedupStrategy,
+    hiddenLogLevels,
+    dedupedResult,
   };
 }
 
 const mapDispatchToProps = {
   toggleLogs,
   changeDedupStrategy,
+  toggleLogLevelAction,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer));

+ 11 - 2
public/app/features/explore/state/actionTypes.ts

@@ -18,6 +18,7 @@ import {
   ExploreUIState,
 } from 'app/types/explore';
 import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
+import { LogLevel } from 'app/core/logs_model';
 
 /**  Higher order actions
  *
@@ -192,7 +193,7 @@ export interface ToggleLogsPayload {
   exploreId: ExploreId;
 }
 
-export interface UpdateUIStatePayload extends Partial<ExploreUIState>{
+export interface UpdateUIStatePayload extends Partial<ExploreUIState> {
   exploreId: ExploreId;
 }
 
@@ -201,6 +202,11 @@ export interface UpdateDatasourceInstancePayload {
   datasourceInstance: DataSourceApi;
 }
 
+export interface ToggleLogLevelPayload {
+  exploreId: ExploreId;
+  hiddenLogLevels: Set<LogLevel>;
+}
+
 export interface QueriesImportedPayload {
   exploreId: ExploreId;
   queries: DataQuery[];
@@ -397,6 +403,8 @@ export const updateDatasourceInstanceAction = actionCreatorFactory<UpdateDatasou
   'explore/UPDATE_DATASOURCE_INSTANCE'
 ).create();
 
+export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>('explore/TOGGLE_LOG_LEVEL').create();
+
 /**
  * Resets state for explore.
  */
@@ -436,4 +444,5 @@ export type Action =
   | ActionOf<ToggleGraphPayload>
   | ActionOf<ToggleLogsPayload>
   | ActionOf<UpdateDatasourceInstancePayload>
-  | ActionOf<QueriesImportedPayload>;
+  | ActionOf<QueriesImportedPayload>
+  | ActionOf<ToggleLogLevelPayload>;

+ 11 - 0
public/app/features/explore/state/reducers.ts

@@ -38,6 +38,7 @@ import {
   toggleTableAction,
   queriesImportedAction,
   updateUIStateAction,
+  toggleLogLevelAction,
 } from './actionTypes';
 
 export const DEFAULT_RANGE = {
@@ -467,6 +468,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
+  .addMapper({
+    filter: toggleLogLevelAction,
+    mapper: (state, action): ExploreItemState => {
+      const { hiddenLogLevels } = action.payload;
+      return {
+        ...state,
+        hiddenLogLevels: Array.from(hiddenLogLevels),
+      };
+    },
+  })
   .create();
 
 /**

+ 30 - 0
public/app/features/explore/state/selectors.ts

@@ -0,0 +1,30 @@
+import { createLodashMemoizedSelector } from 'app/core/utils/reselect';
+import { ExploreItemState } from 'app/types';
+import { filterLogLevels, dedupLogRows } from 'app/core/logs_model';
+
+export const exploreItemUIStateSelector = (itemState: ExploreItemState) => {
+  const { showingGraph, showingLogs, showingTable, showingStartPage, dedupStrategy } = itemState;
+  return {
+    showingGraph,
+    showingLogs,
+    showingTable,
+    showingStartPage,
+    dedupStrategy,
+  };
+};
+
+const logsSelector = (state: ExploreItemState) => state.logsResult;
+const hiddenLogLevelsSelector = (state: ExploreItemState) => state.hiddenLogLevels;
+const dedupStrategySelector = (state: ExploreItemState) => state.dedupStrategy;
+export const deduplicatedLogsSelector = createLodashMemoizedSelector(
+  logsSelector,
+  hiddenLogLevelsSelector,
+  dedupStrategySelector,
+  (logs, hiddenLogLevels, dedupStrategy) => {
+    if (!logs) {
+      return null;
+    }
+    const filteredData = filterLogLevels(logs, new Set(hiddenLogLevels));
+    return dedupLogRows(filteredData, dedupStrategy);
+  }
+);

+ 24 - 26
public/app/features/folders/FolderSettingsPage.tsx

@@ -26,7 +26,7 @@ export class FolderSettingsPage extends PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
     this.state = {
-      isLoading: false
+      isLoading: false,
     };
   }
 
@@ -41,9 +41,9 @@ export class FolderSettingsPage extends PureComponent<Props, State> {
   onSave = async (evt: React.FormEvent<HTMLFormElement>) => {
     evt.preventDefault();
     evt.stopPropagation();
-    this.setState({isLoading: true});
+    this.setState({ isLoading: true });
     await this.props.saveFolder(this.props.folder);
-    this.setState({isLoading: false});
+    this.setState({ isLoading: false });
   };
 
   onDelete = (evt: React.MouseEvent<HTMLButtonElement>) => {
@@ -67,30 +67,28 @@ export class FolderSettingsPage extends PureComponent<Props, State> {
     return (
       <Page navModel={navModel}>
         <Page.Contents isLoading={this.state.isLoading}>
-          <div className="page-container page-body">
-            <h2 className="page-sub-heading">Folder Settings</h2>
+          <h2 className="page-sub-heading">Folder Settings</h2>
 
-            <div className="section gf-form-group">
-              <form name="folderSettingsForm" onSubmit={this.onSave}>
-                <div className="gf-form">
-                  <label className="gf-form-label width-7">Name</label>
-                  <input
-                    type="text"
-                    className="gf-form-input width-30"
-                    value={folder.title}
-                    onChange={this.onTitleChange}
-                  />
-                </div>
-                <div className="gf-form-button-row">
-                  <button type="submit" className="btn btn-primary" disabled={!folder.canSave || !folder.hasChanged}>
-                    <i className="fa fa-save" /> Save
-                  </button>
-                  <button className="btn btn-danger" onClick={this.onDelete} disabled={!folder.canSave}>
-                    <i className="fa fa-trash" /> Delete
-                  </button>
-                </div>
-              </form>
-            </div>
+          <div className="section gf-form-group">
+            <form name="folderSettingsForm" onSubmit={this.onSave}>
+              <div className="gf-form">
+                <label className="gf-form-label width-7">Name</label>
+                <input
+                  type="text"
+                  className="gf-form-input width-30"
+                  value={folder.title}
+                  onChange={this.onTitleChange}
+                />
+              </div>
+              <div className="gf-form-button-row">
+                <button type="submit" className="btn btn-primary" disabled={!folder.canSave || !folder.hasChanged}>
+                  <i className="fa fa-save" /> Save
+                </button>
+                <button className="btn btn-danger" onClick={this.onDelete} disabled={!folder.canSave}>
+                  <i className="fa fa-trash" /> Delete
+                </button>
+              </div>
+            </form>
           </div>
         </Page.Contents>
       </Page>

+ 90 - 98
public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap

@@ -7,62 +7,58 @@ exports[`Render should enable save button 1`] = `
   <PageContents
     isLoading={false}
   >
+    <h2
+      className="page-sub-heading"
+    >
+      Folder Settings
+    </h2>
     <div
-      className="page-container page-body"
+      className="section gf-form-group"
     >
-      <h2
-        className="page-sub-heading"
-      >
-        Folder Settings
-      </h2>
-      <div
-        className="section gf-form-group"
+      <form
+        name="folderSettingsForm"
+        onSubmit={[Function]}
       >
-        <form
-          name="folderSettingsForm"
-          onSubmit={[Function]}
+        <div
+          className="gf-form"
         >
-          <div
-            className="gf-form"
+          <label
+            className="gf-form-label width-7"
           >
-            <label
-              className="gf-form-label width-7"
-            >
-              Name
-            </label>
-            <input
-              className="gf-form-input width-30"
-              onChange={[Function]}
-              type="text"
-              value="loading"
+            Name
+          </label>
+          <input
+            className="gf-form-input width-30"
+            onChange={[Function]}
+            type="text"
+            value="loading"
+          />
+        </div>
+        <div
+          className="gf-form-button-row"
+        >
+          <button
+            className="btn btn-primary"
+            disabled={false}
+            type="submit"
+          >
+            <i
+              className="fa fa-save"
             />
-          </div>
-          <div
-            className="gf-form-button-row"
+             Save
+          </button>
+          <button
+            className="btn btn-danger"
+            disabled={false}
+            onClick={[Function]}
           >
-            <button
-              className="btn btn-primary"
-              disabled={false}
-              type="submit"
-            >
-              <i
-                className="fa fa-save"
-              />
-               Save
-            </button>
-            <button
-              className="btn btn-danger"
-              disabled={false}
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-trash"
-              />
-               Delete
-            </button>
-          </div>
-        </form>
-      </div>
+            <i
+              className="fa fa-trash"
+            />
+             Delete
+          </button>
+        </div>
+      </form>
     </div>
   </PageContents>
 </Page>
@@ -75,62 +71,58 @@ exports[`Render should render component 1`] = `
   <PageContents
     isLoading={false}
   >
+    <h2
+      className="page-sub-heading"
+    >
+      Folder Settings
+    </h2>
     <div
-      className="page-container page-body"
+      className="section gf-form-group"
     >
-      <h2
-        className="page-sub-heading"
-      >
-        Folder Settings
-      </h2>
-      <div
-        className="section gf-form-group"
+      <form
+        name="folderSettingsForm"
+        onSubmit={[Function]}
       >
-        <form
-          name="folderSettingsForm"
-          onSubmit={[Function]}
+        <div
+          className="gf-form"
         >
-          <div
-            className="gf-form"
+          <label
+            className="gf-form-label width-7"
           >
-            <label
-              className="gf-form-label width-7"
-            >
-              Name
-            </label>
-            <input
-              className="gf-form-input width-30"
-              onChange={[Function]}
-              type="text"
-              value="loading"
+            Name
+          </label>
+          <input
+            className="gf-form-input width-30"
+            onChange={[Function]}
+            type="text"
+            value="loading"
+          />
+        </div>
+        <div
+          className="gf-form-button-row"
+        >
+          <button
+            className="btn btn-primary"
+            disabled={true}
+            type="submit"
+          >
+            <i
+              className="fa fa-save"
             />
-          </div>
-          <div
-            className="gf-form-button-row"
+             Save
+          </button>
+          <button
+            className="btn btn-danger"
+            disabled={false}
+            onClick={[Function]}
           >
-            <button
-              className="btn btn-primary"
-              disabled={true}
-              type="submit"
-            >
-              <i
-                className="fa fa-save"
-              />
-               Save
-            </button>
-            <button
-              className="btn btn-danger"
-              disabled={false}
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-trash"
-              />
-               Delete
-            </button>
-          </div>
-        </form>
-      </div>
+            <i
+              className="fa fa-trash"
+            />
+             Delete
+          </button>
+        </div>
+      </form>
     </div>
   </PageContents>
 </Page>

+ 2 - 2
public/app/features/teams/TeamPages.tsx

@@ -49,9 +49,9 @@ export class TeamPages extends PureComponent<Props, State> {
 
   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;
   }
 

+ 6 - 1
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 {

+ 63 - 18
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"