Browse Source

Merge branch 'master' into alerting_reminder

* master: (30 commits)
  changelog: add notes about closing #11882
  renamed variable in tests
  added comment, variableChange -> variableValueChange
  added a test
  added if to check if new variable has been added
  Gravatar fallback does not respect 'AppSubUrl'-setting (#12149)
  change admin password after first login
  changelog: adds note about closing #11958
  revert: reverted singlestat panel position change PR #12004
  Revert "provisioning: turn relative symlinked path into absolut paths"
  provisioning: turn relative symlinked path into absolut paths
  changelog: adds note about closing #11670
  elasticsearch: sort bucket keys to fix issue wth response parser tests
  docs: what's new in v5.2
  changelog: add notes about closing #11167
  docs: docker secrets support. (#12141)
  alerting: show alerts for user with Viewer role
  datasource: added option no-direct-access to ds-http-settings diretive, closes #12138
  provisioning: adds fallback if evalsymlink/abs fails
  tests: uses different paths depending on os
  ...
bergquist 7 years ago
parent
commit
0e647db485
41 changed files with 604 additions and 164 deletions
  1. 5 0
      CHANGELOG.md
  2. 1 1
      devenv/dashboards/bulk-testing/bulkdash.jsonnet
  3. 2 2
      devenv/setup.sh
  4. 1 0
      docs/sources/administration/provisioning.md
  5. 70 0
      docs/sources/guides/whats-new-in-v5-2.md
  6. 12 0
      docs/sources/installation/docker.md
  7. 1 1
      pkg/api/alerting.go
  8. 3 0
      pkg/api/api.go
  9. 1 1
      pkg/api/dtos/models.go
  10. 1 1
      pkg/api/index.go
  11. 1 0
      pkg/models/dashboards.go
  12. 4 0
      pkg/services/provisioning/dashboards/config_reader.go
  13. 7 3
      pkg/services/provisioning/dashboards/config_reader_test.go
  14. 53 14
      pkg/services/provisioning/dashboards/file_reader.go
  15. 39 0
      pkg/services/provisioning/dashboards/file_reader_linux_test.go
  16. 7 4
      pkg/services/provisioning/dashboards/file_reader_test.go
  17. 1 0
      pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml
  18. 1 0
      pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml
  19. 1 0
      pkg/services/provisioning/dashboards/testdata/test-dashboards/symlink
  20. 40 35
      pkg/services/provisioning/dashboards/types.go
  21. 1 1
      pkg/services/sqlstore/alert.go
  22. 3 3
      pkg/services/sqlstore/alert_test.go
  23. 4 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  24. 10 3
      pkg/tsdb/elasticsearch/response_parser.go
  25. 26 0
      pkg/util/md5.go
  26. 17 0
      pkg/util/md5_test.go
  27. 59 7
      public/app/core/controllers/login_ctrl.ts
  28. 18 15
      public/app/core/services/keybindingSrv.ts
  29. 12 6
      public/app/features/dashboard/save_modal.ts
  30. 41 3
      public/app/features/dashboard/specs/save_modal.jest.ts
  31. 5 3
      public/app/features/panel/metrics_panel_ctrl.ts
  32. 2 1
      public/app/features/panel/specs/metrics_panel_ctrl.jest.ts
  33. 4 0
      public/app/features/plugins/ds_edit_ctrl.ts
  34. 1 1
      public/app/features/plugins/partials/ds_http_settings.html
  35. 82 51
      public/app/partials/login.html
  36. 18 2
      public/app/routes/ReactContainer.tsx
  37. 1 0
      public/app/routes/routes.ts
  38. 4 0
      public/sass/components/_gf-form.scss
  39. 5 6
      public/sass/components/_panel_singlestat.scss
  40. 38 0
      public/sass/pages/_login.scss
  41. 2 0
      public/test/specs/helpers.ts

+ 5 - 0
CHANGELOG.md

@@ -3,6 +3,7 @@
 ### New Features
 
 * **Elasticsearch**: Alerting support [#5893](https://github.com/grafana/grafana/issues/5893), thx [@WPH95](https://github.com/WPH95)
+* **Login**: Change admin password after first login [#11882](https://github.com/grafana/grafana/issues/11882)
 * **Alert list panel**: Updated to support filtering alerts by name, dashboard title, folder, tags [#11500](https://github.com/grafana/grafana/issues/11500), [#8168](https://github.com/grafana/grafana/issues/8168), [#6541](https://github.com/grafana/grafana/issues/6541)
 
 ### Minor
@@ -30,6 +31,10 @@
 * **Singlestat**: Fix singlestat threshold tooltip [#11971](https://github.com/grafana/grafana/issues/11971)
 * **Dashboard**: Hide grid controls in fullscreen/low-activity views [#11771](https://github.com/grafana/grafana/issues/11771)
 * **Dashboard**: Validate uid when importing dashboards [#11515](https://github.com/grafana/grafana/issues/11515)
+* **Docker**: Support for env variables ending with _FILE [grafana-docker #166](https://github.com/grafana/grafana-docker/pull/166), thx [@efrecon](https://github.com/efrecon)
+* **Alert list panel**: Show alerts for user with viewer role [#11167](https://github.com/grafana/grafana/issues/11167)
+* **Provisioning**: Verify checksum of dashboards before updating to reduce load on database [#11670](https://github.com/grafana/grafana/issues/11670)
+* **Provisioning**: Support symlinked files in dashboard provisioning config files [#11958](https://github.com/grafana/grafana/issues/11958)
 
 # 5.1.3 (2018-05-16)
 

+ 1 - 1
devenv/dashboards/bulk-testing/bulkdash.jsonnet

@@ -1137,4 +1137,4 @@
   "title": "Big Dashboard",
   "uid": "000000003",
   "version": 16
-}
+}

+ 2 - 2
devenv/setup.sh

@@ -8,7 +8,7 @@ bulkDashboard() {
     MAX=400
     while [  $COUNTER -lt $MAX ]; do
         jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + {  uid: 'uid-${COUNTER}',  title: 'title-${COUNTER}' }"
-        let COUNTER=COUNTER+1 
+        let COUNTER=COUNTER+1
     done
 
     ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
@@ -58,4 +58,4 @@ main() {
 	fi
 }
 
-main "$@"
+main "$@"

+ 1 - 0
docs/sources/administration/provisioning.md

@@ -197,6 +197,7 @@ providers:
   folder: ''
   type: file
   disableDeletion: false
+  updateIntervalSeconds: 3 #how often Grafana will scan for changed dashboards
   options:
     path: /var/lib/grafana/dashboards
 ```

+ 70 - 0
docs/sources/guides/whats-new-in-v5-2.md

@@ -0,0 +1,70 @@
++++
+title = "What's New in Grafana v5.2"
+description = "Feature & improvement highlights for Grafana v5.2"
+keywords = ["grafana", "new", "documentation", "5.2"]
+type = "docs"
+[menu.docs]
+name = "Version 5.2"
+identifier = "v5.2"
+parent = "whatsnew"
+weight = -8
++++
+
+# What's New in Grafana v5.2
+
+Grafana v5.2 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements.
+
+* [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here!
+* [Cross platform build support]({{< relref "#cross-platform-build-support" >}}) enables native builds of Grafana for many more platforms!
+* [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets
+* [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements
+* [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord
+* [Dashboards & Panels]({{< relref "#dashboards-panels" >}})
+
+## Elasticsearch alerting
+
+{{< docs-imagebox img="/img/docs/v52/elasticsearch_alerting.png" max-width="800px" class="docs-image--right" >}}
+
+Grafana v5.2 ships with an updated Elasticsearch datasource with support for alerting. Alerting support for Elasticsearch has been one of
+the most requested features by our community and now it's finally here. Please try it out and let us know what you think.
+
+<div class="clearfix"></div>
+
+## Cross platform build support
+
+Grafana v5.2 brings an improved build pipeline with cross platform support. This enables native builds of Grafana for ARMv7 (x32), ARM64 (x64),
+MacOS/Darwin (x64) and Windows (x64) in both stable and nightly builds.
+
+We've been longing for native ARM build support for a long time. With the help from our amazing community this is now finally available.
+
+## Improved Docker image
+
+The Grafana docker image now includes support for Docker secrets which enables you to supply Grafana with configuration through files. More
+information in the [Installing using Docker documentation](/installation/docker/#reading-secrets-from-files-support-for-docker-secrets).
+
+## Prometheus
+
+The Prometheus datasource now aligns the start/end of the query sent to Prometheus with the step, which ensures PromQL expressions with *rate*
+functions get consistent results, and thus avoid graphs jumping around on reload.
+
+## Alerting
+
+By popular demand Grafana now includes support for an alert notification channel type for [Discord](https://discordapp.com/).
+
+## Dashboards & Panels
+
+### Modified time range and variables are no longer saved by default
+
+{{< docs-imagebox img="/img/docs/v52/dashboard_save_modal.png" max-width="800px" class="docs-image--right" >}}
+
+Starting from Grafana v5.2 a modified time range or variable are no longer saved by default. To save a modified
+time range or variable you'll need to actively select that when saving a dashboard, see screenshot.
+This should hopefully make it easier to have sane defaults of time and variables in dashboards and make it more explicit
+when you actually want to overwrite those settings.
+
+<div class="clearfix"></div>
+
+## Changelog
+
+Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
+of new features, changes, and bug fixes.

+ 12 - 0
docs/sources/installation/docker.md

@@ -130,6 +130,18 @@ ID=$(id -u) # saves your user id in the ID variable
 docker run -d --user $ID --volume "$PWD/data:/var/lib/grafana" -p 3000:3000 grafana/grafana:5.1.0
 ```
 
+## Reading secrets from files (support for Docker Secrets)
+
+It's possible to supply Grafana with configuration through files. This works well with [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/) as the secrets by default gets mapped into `/run/secrets/<name of secret>` of the container.
+
+You can do this with any of the configuration options in conf/grafana.ini by setting `GF_<SectionName>_<KeyName>_FILE` to the path of the file holding the secret.
+
+Let's say you want to set the admin password this way.
+
+- Admin password secret: `/run/secrets/admin_password`
+- Environment variable: `GF_SECURITY_ADMIN_PASSWORD_FILE=/run/secrets/admin_password`
+
+
 ## Migration from a previous version of the docker container to 5.1 or later
 
 The docker container for Grafana has seen a major rewrite for 5.1.

+ 1 - 1
pkg/api/alerting.go

@@ -79,7 +79,7 @@ func GetAlerts(c *m.ReqContext) Response {
 			DashboardIds: dashboardIDs,
 			Type:         string(search.DashHitDB),
 			FolderIds:    folderIDs,
-			Permission:   m.PERMISSION_EDIT,
+			Permission:   m.PERMISSION_VIEW,
 		}
 
 		err := bus.Dispatch(&searchQuery)

+ 3 - 0
pkg/api/api.go

@@ -77,6 +77,9 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
 
+	r.Get("/explore/", reqEditorRole, Index)
+	r.Get("/explore/*", reqEditorRole, Index)
+
 	r.Get("/playlists/", reqSignedIn, Index)
 	r.Get("/playlists/*", reqSignedIn, Index)
 	r.Get("/alerting/", reqSignedIn, Index)

+ 1 - 1
pkg/api/dtos/models.go

@@ -52,7 +52,7 @@ type UserStars struct {
 
 func GetGravatarUrl(text string) string {
 	if setting.DisableGravatar {
-		return "/public/img/user_profile.png"
+		return setting.AppSubUrl + "/public/img/user_profile.png"
 	}
 
 	if text == "" {

+ 1 - 1
pkg/api/index.go

@@ -128,7 +128,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		Children: dashboardChildNavs,
 	})
 
-	if setting.ExploreEnabled {
+	if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
 		data.NavTree = append(data.NavTree, &dtos.NavLink{
 			Text:     "Explore",
 			Id:       "explore",

+ 1 - 0
pkg/models/dashboards.go

@@ -254,6 +254,7 @@ type DashboardProvisioning struct {
 	DashboardId int64
 	Name        string
 	ExternalId  string
+	CheckSum    string
 	Updated     int64
 }
 

+ 4 - 0
pkg/services/provisioning/dashboards/config_reader.go

@@ -81,6 +81,10 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
 		if dashboards[i].OrgId == 0 {
 			dashboards[i].OrgId = 1
 		}
+
+		if dashboards[i].UpdateIntervalSeconds == 0 {
+			dashboards[i].UpdateIntervalSeconds = 3
+		}
 	}
 
 	return dashboards, nil

+ 7 - 3
pkg/services/provisioning/dashboards/config_reader_test.go

@@ -22,7 +22,7 @@ func TestDashboardsAsConfig(t *testing.T) {
 			cfg, err := cfgProvider.readConfig()
 			So(err, ShouldBeNil)
 
-			validateDashboardAsConfig(cfg)
+			validateDashboardAsConfig(t, cfg)
 		})
 
 		Convey("Can read config file in version 0 format", func() {
@@ -30,7 +30,7 @@ func TestDashboardsAsConfig(t *testing.T) {
 			cfg, err := cfgProvider.readConfig()
 			So(err, ShouldBeNil)
 
-			validateDashboardAsConfig(cfg)
+			validateDashboardAsConfig(t, cfg)
 		})
 
 		Convey("Should skip invalid path", func() {
@@ -56,7 +56,9 @@ func TestDashboardsAsConfig(t *testing.T) {
 		})
 	})
 }
-func validateDashboardAsConfig(cfg []*DashboardsAsConfig) {
+func validateDashboardAsConfig(t *testing.T, cfg []*DashboardsAsConfig) {
+	t.Helper()
+
 	So(len(cfg), ShouldEqual, 2)
 
 	ds := cfg[0]
@@ -68,6 +70,7 @@ func validateDashboardAsConfig(cfg []*DashboardsAsConfig) {
 	So(len(ds.Options), ShouldEqual, 1)
 	So(ds.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards")
 	So(ds.DisableDeletion, ShouldBeTrue)
+	So(ds.UpdateIntervalSeconds, ShouldEqual, 10)
 
 	ds2 := cfg[1]
 	So(ds2.Name, ShouldEqual, "default")
@@ -78,4 +81,5 @@ func validateDashboardAsConfig(cfg []*DashboardsAsConfig) {
 	So(len(ds2.Options), ShouldEqual, 1)
 	So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards")
 	So(ds2.DisableDeletion, ShouldBeFalse)
+	So(ds2.UpdateIntervalSeconds, ShouldEqual, 3)
 }

+ 53 - 14
pkg/services/provisioning/dashboards/file_reader.go

@@ -4,12 +4,14 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io/ioutil"
 	"os"
 	"path/filepath"
 	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/util"
 
 	"github.com/grafana/grafana/pkg/bus"
 
@@ -19,8 +21,6 @@ import (
 )
 
 var (
-	checkDiskForChangesInterval = time.Second * 3
-
 	ErrFolderNameMissing = errors.New("Folder name missing")
 )
 
@@ -47,15 +47,25 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade
 		log.Error("Cannot read directory", "error", err)
 	}
 
-	absPath, err := filepath.Abs(path)
+	copy := path
+	path, err := filepath.Abs(path)
 	if err != nil {
 		log.Error("Could not create absolute path ", "path", path)
-		absPath = path //if .Abs return an error we fallback to path
+	}
+
+	path, err = filepath.EvalSymlinks(path)
+	if err != nil {
+		log.Error("Failed to read content of symlinked path: %s", path)
+	}
+
+	if path == "" {
+		path = copy
+		log.Info("falling back to original path due to EvalSymlink/Abs failure")
 	}
 
 	return &fileReader{
 		Cfg:              cfg,
-		Path:             absPath,
+		Path:             path,
 		log:              log,
 		dashboardService: dashboards.NewProvisioningService(),
 	}, nil
@@ -66,7 +76,7 @@ func (fr *fileReader) ReadAndListen(ctx context.Context) error {
 		fr.log.Error("failed to search for dashboards", "error", err)
 	}
 
-	ticker := time.NewTicker(checkDiskForChangesInterval)
+	ticker := time.NewTicker(time.Duration(int64(time.Second) * fr.Cfg.UpdateIntervalSeconds))
 
 	running := false
 
@@ -159,15 +169,20 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil
 	}
 
 	provisionedData, alreadyProvisioned := provisionedDashboardRefs[path]
-	upToDate := alreadyProvisioned && provisionedData.Updated == resolvedFileInfo.ModTime().Unix()
+	upToDate := alreadyProvisioned && provisionedData.Updated >= resolvedFileInfo.ModTime().Unix()
 
-	dash, err := fr.readDashboardFromFile(path, resolvedFileInfo.ModTime(), folderId)
+	jsonFile, err := fr.readDashboardFromFile(path, resolvedFileInfo.ModTime(), folderId)
 	if err != nil {
 		fr.log.Error("failed to load dashboard from ", "file", path, "error", err)
 		return provisioningMetadata, nil
 	}
 
+	if provisionedData != nil && jsonFile.checkSum == provisionedData.CheckSum {
+		upToDate = true
+	}
+
 	// keeps track of what uid's and title's we have already provisioned
+	dash := jsonFile.dashboard
 	provisioningMetadata.uid = dash.Dashboard.Uid
 	provisioningMetadata.title = dash.Dashboard.Title
 
@@ -185,7 +200,13 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil
 	}
 
 	fr.log.Debug("saving new dashboard", "file", path)
-	dp := &models.DashboardProvisioning{ExternalId: path, Name: fr.Cfg.Name, Updated: resolvedFileInfo.ModTime().Unix()}
+	dp := &models.DashboardProvisioning{
+		ExternalId: path,
+		Name:       fr.Cfg.Name,
+		Updated:    resolvedFileInfo.ModTime().Unix(),
+		CheckSum:   jsonFile.checkSum,
+	}
+
 	_, err = fr.dashboardService.SaveProvisionedDashboard(dash, dp)
 	return provisioningMetadata, err
 }
@@ -283,14 +304,30 @@ func validateWalkablePath(fileInfo os.FileInfo) (bool, error) {
 	return true, nil
 }
 
-func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time, folderId int64) (*dashboards.SaveDashboardDTO, error) {
+type dashboardJsonFile struct {
+	dashboard    *dashboards.SaveDashboardDTO
+	checkSum     string
+	lastModified time.Time
+}
+
+func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time, folderId int64) (*dashboardJsonFile, error) {
 	reader, err := os.Open(path)
 	if err != nil {
 		return nil, err
 	}
 	defer reader.Close()
 
-	data, err := simplejson.NewFromReader(reader)
+	all, err := ioutil.ReadAll(reader)
+	if err != nil {
+		return nil, err
+	}
+
+	checkSum, err := util.Md5SumString(string(all))
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := simplejson.NewJson(all)
 	if err != nil {
 		return nil, err
 	}
@@ -300,7 +337,11 @@ func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time,
 		return nil, err
 	}
 
-	return dash, nil
+	return &dashboardJsonFile{
+		dashboard:    dash,
+		checkSum:     checkSum,
+		lastModified: lastModified,
+	}, nil
 }
 
 type provisioningMetadata struct {
@@ -328,7 +369,6 @@ func (checker provisioningSanityChecker) track(pm provisioningMetadata) {
 	if len(pm.title) > 0 {
 		checker.titleUsage[pm.title] += 1
 	}
-
 }
 
 func (checker provisioningSanityChecker) logWarnings(log log.Logger) {
@@ -343,5 +383,4 @@ func (checker provisioningSanityChecker) logWarnings(log log.Logger) {
 			log.Error("the same 'title' is used more than once", "title", title, "provider", checker.provisioningProvider)
 		}
 	}
-
 }

+ 39 - 0
pkg/services/provisioning/dashboards/file_reader_linux_test.go

@@ -0,0 +1,39 @@
+// +build linux
+
+package dashboards
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/grafana/grafana/pkg/log"
+)
+
+var (
+	symlinkedFolder = "testdata/test-dashboards/symlink"
+)
+
+func TestProvsionedSymlinkedFolder(t *testing.T) {
+	cfg := &DashboardsAsConfig{
+		Name:    "Default",
+		Type:    "file",
+		OrgId:   1,
+		Folder:  "",
+		Options: map[string]interface{}{"path": symlinkedFolder},
+	}
+
+	reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
+	if err != nil {
+		t.Error("expected err to be nil")
+	}
+
+	want, err := filepath.Abs(containingId)
+
+	if err != nil {
+		t.Errorf("expected err to be nill")
+	}
+
+	if reader.Path != want {
+		t.Errorf("got %s want %s", reader.Path, want)
+	}
+}

+ 7 - 4
pkg/services/provisioning/dashboards/file_reader_test.go

@@ -49,13 +49,16 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
 		})
 
 		Convey("using full path", func() {
-			cfg.Options["folder"] = "/var/lib/grafana/dashboards"
+			fullPath := "/var/lib/grafana/dashboards"
+			if runtime.GOOS == "windows" {
+				fullPath = `c:\var\lib\grafana`
+			}
+
+			cfg.Options["folder"] = fullPath
 			reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
 			So(err, ShouldBeNil)
 
-			if runtime.GOOS != "windows" {
-				So(reader.Path, ShouldEqual, "/var/lib/grafana/dashboards")
-			}
+			So(reader.Path, ShouldEqual, fullPath)
 			So(filepath.IsAbs(reader.Path), ShouldBeTrue)
 		})
 

+ 1 - 0
pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml

@@ -6,6 +6,7 @@ providers:
   folder: 'developers'
   editable: true
   disableDeletion: true
+  updateIntervalSeconds: 10
   type: file
   options:
     path: /var/lib/grafana/dashboards

+ 1 - 0
pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml

@@ -3,6 +3,7 @@
   folder: 'developers'
   editable: true
   disableDeletion: true
+  updateIntervalSeconds: 10
   type: file
   options:
     path: /var/lib/grafana/dashboards

+ 1 - 0
pkg/services/provisioning/dashboards/testdata/test-dashboards/symlink

@@ -0,0 +1 @@
+containing-id/

+ 40 - 35
pkg/services/provisioning/dashboards/types.go

@@ -10,23 +10,25 @@ import (
 )
 
 type DashboardsAsConfig struct {
-	Name            string
-	Type            string
-	OrgId           int64
-	Folder          string
-	Editable        bool
-	Options         map[string]interface{}
-	DisableDeletion bool
+	Name                  string
+	Type                  string
+	OrgId                 int64
+	Folder                string
+	Editable              bool
+	Options               map[string]interface{}
+	DisableDeletion       bool
+	UpdateIntervalSeconds int64
 }
 
 type DashboardsAsConfigV0 struct {
-	Name            string                 `json:"name" yaml:"name"`
-	Type            string                 `json:"type" yaml:"type"`
-	OrgId           int64                  `json:"org_id" yaml:"org_id"`
-	Folder          string                 `json:"folder" yaml:"folder"`
-	Editable        bool                   `json:"editable" yaml:"editable"`
-	Options         map[string]interface{} `json:"options" yaml:"options"`
-	DisableDeletion bool                   `json:"disableDeletion" yaml:"disableDeletion"`
+	Name                  string                 `json:"name" yaml:"name"`
+	Type                  string                 `json:"type" yaml:"type"`
+	OrgId                 int64                  `json:"org_id" yaml:"org_id"`
+	Folder                string                 `json:"folder" yaml:"folder"`
+	Editable              bool                   `json:"editable" yaml:"editable"`
+	Options               map[string]interface{} `json:"options" yaml:"options"`
+	DisableDeletion       bool                   `json:"disableDeletion" yaml:"disableDeletion"`
+	UpdateIntervalSeconds int64                  `json:"updateIntervalSeconds" yaml:"updateIntervalSeconds"`
 }
 
 type ConfigVersion struct {
@@ -38,13 +40,14 @@ type DashboardAsConfigV1 struct {
 }
 
 type DashboardProviderConfigs struct {
-	Name            string                 `json:"name" yaml:"name"`
-	Type            string                 `json:"type" yaml:"type"`
-	OrgId           int64                  `json:"orgId" yaml:"orgId"`
-	Folder          string                 `json:"folder" yaml:"folder"`
-	Editable        bool                   `json:"editable" yaml:"editable"`
-	Options         map[string]interface{} `json:"options" yaml:"options"`
-	DisableDeletion bool                   `json:"disableDeletion" yaml:"disableDeletion"`
+	Name                  string                 `json:"name" yaml:"name"`
+	Type                  string                 `json:"type" yaml:"type"`
+	OrgId                 int64                  `json:"orgId" yaml:"orgId"`
+	Folder                string                 `json:"folder" yaml:"folder"`
+	Editable              bool                   `json:"editable" yaml:"editable"`
+	Options               map[string]interface{} `json:"options" yaml:"options"`
+	DisableDeletion       bool                   `json:"disableDeletion" yaml:"disableDeletion"`
+	UpdateIntervalSeconds int64                  `json:"updateIntervalSeconds" yaml:"updateIntervalSeconds"`
 }
 
 func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardDTO, error) {
@@ -68,13 +71,14 @@ func mapV0ToDashboardAsConfig(v0 []*DashboardsAsConfigV0) []*DashboardsAsConfig
 
 	for _, v := range v0 {
 		r = append(r, &DashboardsAsConfig{
-			Name:            v.Name,
-			Type:            v.Type,
-			OrgId:           v.OrgId,
-			Folder:          v.Folder,
-			Editable:        v.Editable,
-			Options:         v.Options,
-			DisableDeletion: v.DisableDeletion,
+			Name:                  v.Name,
+			Type:                  v.Type,
+			OrgId:                 v.OrgId,
+			Folder:                v.Folder,
+			Editable:              v.Editable,
+			Options:               v.Options,
+			DisableDeletion:       v.DisableDeletion,
+			UpdateIntervalSeconds: v.UpdateIntervalSeconds,
 		})
 	}
 
@@ -86,13 +90,14 @@ func (dc *DashboardAsConfigV1) mapToDashboardAsConfig() []*DashboardsAsConfig {
 
 	for _, v := range dc.Providers {
 		r = append(r, &DashboardsAsConfig{
-			Name:            v.Name,
-			Type:            v.Type,
-			OrgId:           v.OrgId,
-			Folder:          v.Folder,
-			Editable:        v.Editable,
-			Options:         v.Options,
-			DisableDeletion: v.DisableDeletion,
+			Name:                  v.Name,
+			Type:                  v.Type,
+			OrgId:                 v.OrgId,
+			Folder:                v.Folder,
+			Editable:              v.Editable,
+			Options:               v.Options,
+			DisableDeletion:       v.DisableDeletion,
+			UpdateIntervalSeconds: v.UpdateIntervalSeconds,
 		})
 	}
 

+ 1 - 1
pkg/services/sqlstore/alert.go

@@ -116,7 +116,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 	}
 
 	if query.User.OrgRole != m.ROLE_ADMIN {
-		builder.writeDashboardPermissionFilter(query.User, m.PERMISSION_EDIT)
+		builder.writeDashboardPermissionFilter(query.User, m.PERMISSION_VIEW)
 	}
 
 	builder.Write(" ORDER BY name ASC")

+ 3 - 3
pkg/services/sqlstore/alert_test.go

@@ -2,7 +2,6 @@ package sqlstore
 
 import (
 	"testing"
-
 	"time"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -110,11 +109,12 @@ func TestAlertingDataAccess(t *testing.T) {
 		})
 
 		Convey("Viewer cannot read alerts", func() {
-			alertQuery := m.GetAlertsQuery{DashboardIDs: []int64{testDash.Id}, PanelId: 1, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_VIEWER}}
+			viewerUser := &m.SignedInUser{OrgRole: m.ROLE_VIEWER, OrgId: 1}
+			alertQuery := m.GetAlertsQuery{DashboardIDs: []int64{testDash.Id}, PanelId: 1, OrgId: 1, User: viewerUser}
 			err2 := HandleAlertsQuery(&alertQuery)
 
 			So(err2, ShouldBeNil)
-			So(alertQuery.Result, ShouldHaveLength, 0)
+			So(alertQuery.Result, ShouldHaveLength, 1)
 		})
 
 		Convey("Alerts with same dashboard id and panel id should update", func() {

+ 4 - 0
pkg/services/sqlstore/migrations/dashboard_mig.go

@@ -211,4 +211,8 @@ func addDashboardMigration(mg *Migrator) {
 		"name":         "name",
 		"external_id":  "external_id",
 	})
+
+	mg.AddMigration("Add check_sum column", NewAddColumnMigration(dashboardExtrasTableV2, &Column{
+		Name: "check_sum", Type: DB_NVarchar, Length: 32, Nullable: true,
+	}))
 }

+ 10 - 3
pkg/tsdb/elasticsearch/response_parser.go

@@ -113,15 +113,22 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu
 				}
 			}
 
-			for k, v := range esAgg.Get("buckets").MustMap() {
-				bucket := simplejson.NewFromAny(v)
+			buckets := esAgg.Get("buckets").MustMap()
+			bucketKeys := make([]string, 0)
+			for k := range buckets {
+				bucketKeys = append(bucketKeys, k)
+			}
+			sort.Strings(bucketKeys)
+
+			for _, bucketKey := range bucketKeys {
+				bucket := simplejson.NewFromAny(buckets[bucketKey])
 				newProps := make(map[string]string, 0)
 
 				for k, v := range props {
 					newProps[k] = v
 				}
 
-				newProps["filter"] = k
+				newProps["filter"] = bucketKey
 
 				err = rp.processBuckets(bucket.MustMap(), target, series, table, newProps, depth+1)
 				if err != nil {

+ 26 - 0
pkg/util/md5.go

@@ -0,0 +1,26 @@
+package util
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"io"
+	"strings"
+)
+
+// Md5Sum calculates the md5sum of a stream
+func Md5Sum(reader io.Reader) (string, error) {
+	var returnMD5String string
+	hash := md5.New()
+	if _, err := io.Copy(hash, reader); err != nil {
+		return returnMD5String, err
+	}
+	hashInBytes := hash.Sum(nil)[:16]
+	returnMD5String = hex.EncodeToString(hashInBytes)
+	return returnMD5String, nil
+}
+
+// Md5Sum calculates the md5sum of a string
+func Md5SumString(input string) (string, error) {
+	buffer := strings.NewReader(input)
+	return Md5Sum(buffer)
+}

+ 17 - 0
pkg/util/md5_test.go

@@ -0,0 +1,17 @@
+package util
+
+import "testing"
+
+func TestMd5Sum(t *testing.T) {
+	input := "dont hash passwords with md5"
+
+	have, err := Md5SumString(input)
+	if err != nil {
+		t.Fatal("expected err to be nil")
+	}
+
+	want := "2d6a56c82d09d374643b926d3417afba"
+	if have != want {
+		t.Fatalf("expected: %s got: %s", want, have)
+	}
+}

+ 59 - 7
public/app/core/controllers/login_ctrl.ts

@@ -11,10 +11,15 @@ export class LoginCtrl {
       password: '',
     };
 
+    $scope.command = {};
+    $scope.result = '';
+
     contextSrv.sidemenu = false;
 
     $scope.oauth = config.oauth;
     $scope.oauthEnabled = _.keys(config.oauth).length > 0;
+    $scope.ldapEnabled = config.ldapEnabled;
+    $scope.authProxyEnabled = config.authProxyEnabled;
 
     $scope.disableLoginForm = config.disableLoginForm;
     $scope.disableUserSignUp = config.disableUserSignUp;
@@ -39,6 +44,43 @@ export class LoginCtrl {
       }
     };
 
+    $scope.changeView = function() {
+      let loginView = document.querySelector('#login-view');
+      let changePasswordView = document.querySelector('#change-password-view');
+
+      loginView.className += ' add';
+      setTimeout(() => {
+        loginView.className += ' hidden';
+      }, 250);
+      setTimeout(() => {
+        changePasswordView.classList.remove('hidden');
+      }, 251);
+      setTimeout(() => {
+        changePasswordView.classList.remove('remove');
+      }, 301);
+
+      setTimeout(() => {
+        document.getElementById('newPassword').focus();
+      }, 400);
+    };
+
+    $scope.changePassword = function() {
+      $scope.command.oldPassword = 'admin';
+
+      if ($scope.command.newPassword !== $scope.command.confirmNew) {
+        $scope.appEvent('alert-warning', ['New passwords do not match', '']);
+        return;
+      }
+
+      backendSrv.put('/api/user/password', $scope.command).then(function() {
+        $scope.toGrafana();
+      });
+    };
+
+    $scope.skip = function() {
+      $scope.toGrafana();
+    };
+
     $scope.loginModeChanged = function(newValue) {
       $scope.submitBtnText = newValue ? 'Log in' : 'Sign up';
     };
@@ -65,18 +107,28 @@ export class LoginCtrl {
       }
 
       backendSrv.post('/login', $scope.formModel).then(function(result) {
-        var params = $location.search();
+        $scope.result = result;
 
-        if (params.redirect && params.redirect[0] === '/') {
-          window.location.href = config.appSubUrl + params.redirect;
-        } else if (result.redirectUrl) {
-          window.location.href = result.redirectUrl;
-        } else {
-          window.location.href = config.appSubUrl + '/';
+        if ($scope.formModel.password !== 'admin' || $scope.ldapEnabled || $scope.authProxyEnabled) {
+          $scope.toGrafana();
+          return;
         }
+        $scope.changeView();
       });
     };
 
+    $scope.toGrafana = function() {
+      var params = $location.search();
+
+      if (params.redirect && params.redirect[0] === '/') {
+        window.location.href = config.appSubUrl + params.redirect;
+      } else if ($scope.result.redirectUrl) {
+        window.location.href = $scope.result.redirectUrl;
+      } else {
+        window.location.href = config.appSubUrl + '/';
+      }
+    };
+
     $scope.init();
   }
 }

+ 18 - 15
public/app/core/services/keybindingSrv.ts

@@ -14,7 +14,7 @@ export class KeybindingSrv {
   timepickerOpen = false;
 
   /** @ngInject */
-  constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv) {
+  constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv, private contextSrv) {
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
@@ -177,21 +177,24 @@ export class KeybindingSrv {
       }
     });
 
-    this.bind('x', async () => {
-      if (dashboard.meta.focusPanelId) {
-        const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
-        const datasource = await this.datasourceSrv.get(panel.datasource);
-        if (datasource && datasource.supportsExplore) {
-          const range = this.timeSrv.timeRangeForUrl();
-          const state = {
-            ...datasource.getExploreState(panel),
-            range,
-          };
-          const exploreState = encodePathComponent(JSON.stringify(state));
-          this.$location.url(`/explore/${exploreState}`);
+    // jump to explore if permissions allow
+    if (this.contextSrv.isEditor) {
+      this.bind('x', async () => {
+        if (dashboard.meta.focusPanelId) {
+          const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
+          const datasource = await this.datasourceSrv.get(panel.datasource);
+          if (datasource && datasource.supportsExplore) {
+            const range = this.timeSrv.timeRangeForUrl();
+            const state = {
+              ...datasource.getExploreState(panel),
+              range,
+            };
+            const exploreState = encodePathComponent(JSON.stringify(state));
+            this.$location.url(`/explore/${exploreState}`);
+          }
         }
-      }
-    });
+      });
+    }
 
     // delete panel
     this.bind('p r', () => {

+ 12 - 6
public/app/features/dashboard/save_modal.ts

@@ -16,13 +16,13 @@ const template = `
 
   <form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
     <div class="p-t-1">
-      <div class="gf-form-group" ng-if="ctrl.timeChange || ctrl.variableChange">
+      <div class="gf-form-group" ng-if="ctrl.timeChange || ctrl.variableValueChange">
 		    <gf-form-switch class="gf-form"
 			    label="Save current time range" ng-if="ctrl.timeChange" label-class="width-12" switch-class="max-width-6"
 			    checked="ctrl.saveTimerange" on-change="buildUrl()">
 		    </gf-form-switch>
 		    <gf-form-switch class="gf-form"
-			    label="Save current variables" ng-if="ctrl.variableChange" label-class="width-12" switch-class="max-width-6"
+			    label="Save current variables" ng-if="ctrl.variableValueChange" label-class="width-12" switch-class="max-width-6"
 			    checked="ctrl.saveVariables" on-change="buildUrl()">
 		    </gf-form-switch>
 	    </div>
@@ -70,7 +70,7 @@ export class SaveDashboardModalCtrl {
   saveForm: any;
   dismiss: () => void;
   timeChange = false;
-  variableChange = false;
+  variableValueChange = false;
 
   /** @ngInject */
   constructor(private dashboardSrv) {
@@ -91,18 +91,24 @@ export class SaveDashboardModalCtrl {
   }
 
   compareTemplating() {
+    //checks if variables has been added or removed, if so variables will be saved automatically
+    if (this.dashboardSrv.dash.originalTemplating.length !== this.dashboardSrv.dash.templating.list.length) {
+      return (this.variableValueChange = false);
+    }
+
+    //checks if variable value has changed
     if (this.dashboardSrv.dash.templating.list.length > 0) {
       for (let i = 0; i < this.dashboardSrv.dash.templating.list.length; i++) {
         if (
           this.dashboardSrv.dash.templating.list[i].current.text !==
           this.dashboardSrv.dash.originalTemplating[i].current.text
         ) {
-          return (this.variableChange = true);
+          return (this.variableValueChange = true);
         }
       }
-      return (this.variableChange = false);
+      return (this.variableValueChange = false);
     } else {
-      return (this.variableChange = false);
+      return (this.variableValueChange = false);
     }
   }
 

+ 41 - 3
public/app/features/dashboard/specs/save_modal.jest.ts

@@ -43,7 +43,7 @@ describe('SaveDashboardModal', () => {
       let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
 
       expect(modal.timeChange).toBe(true);
-      expect(modal.variableChange).toBe(true);
+      expect(modal.variableValueChange).toBe(true);
     });
 
     it('should hide checkboxes', () => {
@@ -54,7 +54,6 @@ describe('SaveDashboardModal', () => {
               {
                 current: {
                   selected: true,
-                  //tags: Array(0),
                   text: 'server_002',
                   value: 'server_002',
                 },
@@ -84,7 +83,46 @@ describe('SaveDashboardModal', () => {
       };
       let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
       expect(modal.timeChange).toBe(false);
-      expect(modal.variableChange).toBe(false);
+      expect(modal.variableValueChange).toBe(false);
+    });
+
+    it('should hide variable checkboxes', () => {
+      let fakeDashboardSrv = {
+        dash: {
+          templating: {
+            list: [
+              {
+                current: {
+                  selected: true,
+                  text: 'server_002',
+                  value: 'server_002',
+                },
+                name: 'Server',
+              },
+              {
+                current: {
+                  selected: true,
+                  text: 'web_002',
+                  value: 'web_002',
+                },
+                name: 'Web',
+              },
+            ],
+          },
+          originalTemplating: [
+            {
+              current: {
+                selected: true,
+                text: 'server_002',
+                value: 'server_002',
+              },
+              name: 'Server',
+            },
+          ],
+        },
+      };
+      let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
+      expect(modal.variableValueChange).toBe(false);
     });
   });
 });

+ 5 - 3
public/app/features/panel/metrics_panel_ctrl.ts

@@ -1,9 +1,9 @@
-import config from 'app/core/config';
 import $ from 'jquery';
 import _ from 'lodash';
+
+import config from 'app/core/config';
 import kbn from 'app/core/utils/kbn';
 import { PanelCtrl } from 'app/features/panel/panel_ctrl';
-
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import * as dateMath from 'app/core/utils/datemath';
 import { encodePathComponent } from 'app/core/utils/location_util';
@@ -16,6 +16,7 @@ class MetricsPanelCtrl extends PanelCtrl {
   datasourceName: any;
   $q: any;
   $timeout: any;
+  contextSrv: any;
   datasourceSrv: any;
   timeSrv: any;
   templateSrv: any;
@@ -37,6 +38,7 @@ class MetricsPanelCtrl extends PanelCtrl {
     // make metrics tab the default
     this.editorTabIndex = 1;
     this.$q = $injector.get('$q');
+    this.contextSrv = $injector.get('contextSrv');
     this.datasourceSrv = $injector.get('datasourceSrv');
     this.timeSrv = $injector.get('timeSrv');
     this.templateSrv = $injector.get('templateSrv');
@@ -312,7 +314,7 @@ class MetricsPanelCtrl extends PanelCtrl {
 
   getAdditionalMenuItems() {
     const items = [];
-    if (this.datasource && this.datasource.supportsExplore) {
+    if (this.contextSrv.isEditor && this.datasource && this.datasource.supportsExplore) {
       items.push({
         text: 'Explore',
         click: 'ctrl.explore();',

+ 2 - 1
public/app/features/panel/specs/metrics_panel_ctrl.jest.ts

@@ -24,8 +24,9 @@ describe('MetricsPanelCtrl', () => {
       });
     });
 
-    describe('and has datasource set that supports explore', () => {
+    describe('and has datasource set that supports explore and user has powers', () => {
       beforeEach(() => {
+        ctrl.contextSrv = { isEditor: true };
         ctrl.datasource = { supportsExplore: true };
         additionalItems = ctrl.getAdditionalMenuItems();
       });

+ 4 - 0
public/app/features/plugins/ds_edit_ctrl.ts

@@ -204,10 +204,14 @@ coreModule.directive('datasourceHttpSettings', function() {
     scope: {
       current: '=',
       suggestUrl: '@',
+      noDirectAccess: '@',
     },
     templateUrl: 'public/app/features/plugins/partials/ds_http_settings.html',
     link: {
       pre: function($scope, elem, attrs) {
+        // do not show access option if direct access is disabled
+        $scope.showAccessOption = $scope.noDirectAccess !== 'true';
+
         $scope.getSuggestUrls = function() {
           return [$scope.suggestUrl];
         };

+ 1 - 1
public/app/features/plugins/partials/ds_http_settings.html

@@ -22,7 +22,7 @@
       </div>
     </div>
 
-    <div class="gf-form-inline">
+    <div class="gf-form-inline" ng-if="showAccessOption">
       <div class="gf-form max-width-30">
         <span class="gf-form-label width-7">Access</span>
         <div class="gf-form-select-wrapper max-width-24">

+ 82 - 51
public/app/partials/login.html

@@ -4,70 +4,101 @@
       <img class="logo-icon" src="public/img/grafana_icon.svg" alt="Grafana" />
       <i class="icon-gf icon-gf-grafana_wordmark"></i>
     </div>
-    <div class="login-inner-box">
-      <form name="loginForm" class="login-form-group gf-form-group" ng-hide="disableLoginForm">
-        <div class="login-form">
-          <input type="text" name="username" class="gf-form-input login-form-input" required ng-model='formModel.user' placeholder={{loginHint}}
-            autofocus>
-        </div>
-        <div class="login-form">
-          <input type="password" name="password" class="gf-form-input login-form-input" required ng-model="formModel.password" id="inputPassword"
-            placeholder="password">
-        </div>
-        <div class="login-button-group">
-          <button type="submit" class="btn btn-large p-x-2" ng-click="submit();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
-            Log In
-          </button>
+    <div class="login-outer-box">
+      <div class="login-inner-box" id="login-view">
+        <form name="loginForm" class="login-form-group gf-form-group" ng-hide="disableLoginForm">
+          <div class="login-form">
+            <input type="text" name="username" class="gf-form-input login-form-input" required ng-model='formModel.user' placeholder={{loginHint}}
+              autofocus>
+          </div>
+          <div class="login-form">
+            <input type="password" name="password" class="gf-form-input login-form-input" required ng-model="formModel.password" id="inputPassword"
+              placeholder="password">
+          </div>
+          <div class="login-button-group">
+            <button type="submit" class="btn btn-large p-x-2" ng-click="submit();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
+              Log In
+            </button>
             <div class="small login-button-forgot-password">
               <a href="user/password/send-reset-email">
                 Forgot your password?
               </a>
+            </div>
           </div>
-        </div>
-      </form>
-      <div class="text-center login-divider" ng-show="oauthEnabled">
-        <div>
-          <div class="login-divider-line">
+        </form>
+        <div class="text-center login-divider" ng-show="oauthEnabled">
+          <div>
+            <div class="login-divider-line">
+            </div>
+          </div>
+          <div>
+            <span class="login-divider-text">
+              <span ng-hide="disableLoginForm">or</span>
+            </span>
+          </div>
+          <div>
+            <div class="login-divider-line">
+            </div>
           </div>
         </div>
-        <div>
-          <span class="login-divider-text">
-            <span ng-hide="disableLoginForm">or</span>
-          </span>
+        <div class="clearfix"></div>
+        <div class="login-oauth text-center" ng-show="oauthEnabled">
+          <a class="btn btn-medium btn-service btn-service--google login-btn" href="login/google" target="_self" ng-if="oauth.google">
+            <i class="btn-service-icon fa fa-google"></i>
+            Sign in with Google
+          </a>
+          <a class="btn btn-medium btn-service btn-service--github login-btn" href="login/github" target="_self" ng-if="oauth.github">
+            <i class="btn-service-icon fa fa-github"></i>
+            Sign in with GitHub
+          </a>
+          <a class="btn btn-medium btn-inverse btn-service btn-service--grafanacom login-btn" href="login/grafana_com" target="_self"
+            ng-if="oauth.grafana_com">
+            <i class="btn-service-icon"></i>
+            Sign in with Grafana.com
+          </a>
+          <a class="btn btn-medium btn-inverse btn-service btn-service--oauth login-btn" href="login/generic_oauth" target="_self"
+            ng-if="oauth.generic_oauth">
+            <i class="btn-service-icon fa fa-sign-in"></i>
+            Sign in with {{oauth.generic_oauth.name}}
+          </a>
         </div>
-        <div>
-          <div class="login-divider-line">
+        <div class="login-signup-box" ng-show="!disableUserSignUp">
+          <div class="login-signup-title p-r-1">
+            New to Grafana?
           </div>
+          <a href="signup" class="btn btn-medium btn-signup btn-p-x-2">
+            Sign Up
+          </a>
         </div>
       </div>
-      <div class="clearfix"></div>
-      <div class="login-oauth text-center" ng-show="oauthEnabled">
-        <a class="btn btn-medium btn-service btn-service--google login-btn" href="login/google" target="_self" ng-if="oauth.google">
-          <i class="btn-service-icon fa fa-google"></i>
-          Sign in with Google
-        </a>
-        <a class="btn btn-medium btn-service btn-service--github login-btn" href="login/github" target="_self" ng-if="oauth.github">
-          <i class="btn-service-icon fa fa-github"></i>
-          Sign in with GitHub
-        </a>
-        <a class="btn btn-medium btn-inverse btn-service btn-service--grafanacom login-btn" href="login/grafana_com" target="_self" ng-if="oauth.grafana_com">
-          <i class="btn-service-icon"></i>
-          Sign in with Grafana.com
-        </a>
-        <a class="btn btn-medium btn-inverse btn-service btn-service--oauth login-btn" href="login/generic_oauth" target="_self" ng-if="oauth.generic_oauth">
-          <i class="btn-service-icon fa fa-sign-in"></i>
-          Sign in with {{oauth.generic_oauth.name}}
-        </a>
-      </div>
-      <div class="login-signup-box" ng-show="!disableUserSignUp">
-        <div class="login-signup-title p-r-1">
-          New to Grafana?
+      <div class="login-inner-box remove hidden" id="change-password-view">
+        <div class="text-left login-change-password-info">
+          <h5>Change Password</h5>
+          Before you can get started with awesome dashboards we need you to make your account more secure by changing your password.
+          <br />You can change your password again later.
         </div>
-        <a href="signup" class="btn btn-medium btn-signup btn-p-x-2">
-          Sign Up
-        </a>
+        <form class="login-form-group gf-form-group">
+          <div class="login-form">
+            <input type="password" id="newPassword" name="newPassword" class="gf-form-input login-form-input" required ng-model='command.newPassword'
+              placeholder="New password">
+          </div>
+          <div class="login-form">
+            <input type="password" name="confirmNew" class="gf-form-input login-form-input" required ng-model="command.confirmNew" placeholder="Confirm new password">
+          </div>
+          <div class="login-button-group login-button-group--right text-right">
+            <a class="btn btn-link" ng-click="skip();">
+              Skip
+              <info-popover mode="no-padding">
+                If you skip you will be promted to change password next time you login.
+              </info-popover>
+            </a>
+            <button type="submit" class="btn btn-large p-x-2" ng-click="changePassword();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-success': loginForm.$valid}">
+              Save
+            </button>
+          </div>
+        </form>
       </div>
+      <div class="clearfix"></div>
     </div>
-    <div class="clearfix"></div>
   </div>
 </div>

+ 18 - 2
public/app/routes/ReactContainer.tsx

@@ -6,6 +6,7 @@ import coreModule from 'app/core/core_module';
 import { store } from 'app/stores/store';
 import { BackendSrv } from 'app/core/services/backend_srv';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { ContextSrv } from 'app/core/services/context_srv';
 
 function WrapInProvider(store, Component, props) {
   return (
@@ -16,16 +17,31 @@ function WrapInProvider(store, Component, props) {
 }
 
 /** @ngInject */
-export function reactContainer($route, $location, backendSrv: BackendSrv, datasourceSrv: DatasourceSrv) {
+export function reactContainer(
+  $route,
+  $location,
+  backendSrv: BackendSrv,
+  datasourceSrv: DatasourceSrv,
+  contextSrv: ContextSrv
+) {
   return {
     restrict: 'E',
     template: '',
     link(scope, elem) {
-      let component = $route.current.locals.component;
+      // Check permissions for this component
+      const { roles } = $route.current.locals;
+      if (roles && roles.length) {
+        if (!roles.some(r => contextSrv.hasRole(r))) {
+          $location.url('/');
+        }
+      }
+
+      let { component } = $route.current.locals;
       // Dynamic imports return whole module, need to extract default export
       if (component.default) {
         component = component.default;
       }
+
       const props = {
         backendSrv: backendSrv,
         datasourceSrv: datasourceSrv,

+ 1 - 0
public/app/routes/routes.ts

@@ -113,6 +113,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     .when('/explore/:initial?', {
       template: '<react-container />',
       resolve: {
+        roles: () => ['Editor', 'Admin'],
         component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Wrapper'),
       },
     })

+ 4 - 0
public/sass/components/_gf-form.scss

@@ -384,6 +384,10 @@ $input-border: 1px solid $input-border-color;
   &--header {
     margin-bottom: $gf-form-margin;
   }
+
+  &--no-padding {
+    padding-left: 0;
+  }
 }
 
 select.gf-form-input ~ .gf-form-help-icon {

+ 5 - 6
public/sass/components/_panel_singlestat.scss

@@ -7,14 +7,13 @@
 
 .singlestat-panel-value-container {
   line-height: 1;
-  position: absolute;
+  display: table-cell;
+  vertical-align: middle;
+  text-align: center;
+  position: relative;
   z-index: 1;
   font-size: 3em;
-  font-weight: bold;
-  margin: 0;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
+  font-weight: $font-weight-semi-bold;
 }
 
 .singlestat-panel-prefix {

+ 38 - 0
public/sass/pages/_login.scss

@@ -59,6 +59,14 @@ select:-webkit-autofill:focus {
   justify-content: space-between;
   width: 100%;
   margin-top: 0.5rem;
+
+  &--right {
+    justify-content: flex-end;
+
+    & .btn {
+      margin-left: 1rem;
+    }
+  }
 }
 
 .login-button-forgot-password {
@@ -75,7 +83,9 @@ select:-webkit-autofill:focus {
   align-items: stretch;
   flex-direction: column;
   position: relative;
+  justify-content: center;
   z-index: 1;
+  height: 320px;
 }
 
 .login-branding {
@@ -100,6 +110,11 @@ select:-webkit-autofill:focus {
   }
 }
 
+.login-outer-box {
+  display: flex;
+  overflow-y: hidden;
+}
+
 .login-inner-box {
   text-align: center;
   padding: 2rem 4rem;
@@ -109,6 +124,22 @@ select:-webkit-autofill:focus {
   justify-content: center;
   flex-grow: 1;
   max-width: 415px;
+  transform: tranlate(0px, 0px);
+  transition: 0.25s ease;
+
+  &.add {
+    transform: translate(0px, -320px);
+    &.hidden {
+      display: none;
+    }
+  }
+
+  &.remove {
+    transform: translate(0px, 320px);
+    &.hidden {
+      display: none;
+    }
+  }
 }
 
 .login-tab-header {
@@ -117,6 +148,13 @@ select:-webkit-autofill:focus {
   margin-bottom: 3rem;
 }
 
+.login-change-password-info {
+  padding-bottom: 1.5rem;
+
+  & h5 {
+    text-align: left;
+  }
+}
 .btn-signup {
   color: $white;
   border: 1px solid $login-border;

+ 2 - 0
public/test/specs/helpers.ts

@@ -11,6 +11,7 @@ export function ControllerTestContext() {
   this.$element = {};
   this.$sanitize = {};
   this.annotationsSrv = {};
+  this.contextSrv = {};
   this.timeSrv = new TimeSrvStub();
   this.templateSrv = new TemplateSrvStub();
   this.datasourceSrv = {
@@ -27,6 +28,7 @@ export function ControllerTestContext() {
 
   this.providePhase = function(mocks) {
     return angularMocks.module(function($provide) {
+      $provide.value('contextSrv', self.contextSrv);
       $provide.value('datasourceSrv', self.datasourceSrv);
       $provide.value('annotationsSrv', self.annotationsSrv);
       $provide.value('timeSrv', self.timeSrv);