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
  ...
This commit is contained in:
bergquist 2018-06-04 17:44:18 +02:00
commit 0e647db485
41 changed files with 605 additions and 165 deletions

View File

@ -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)

View File

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

View File

@ -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 "$@"

View File

@ -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
```

View File

@ -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.

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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 == "" {

View File

@ -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",

View File

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

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
})

View File

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

View File

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

View File

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

View File

@ -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,
})
}

View File

@ -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")

View File

@ -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() {

View File

@ -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,
}))
}

View File

@ -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
pkg/util/md5.go Normal file
View File

@ -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
pkg/util/md5_test.go Normal file
View File

@ -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)
}
}

View File

@ -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();
}
}

View File

@ -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', () => {

View File

@ -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);
}
}

View File

@ -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);
});
});
});

View File

@ -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();',

View File

@ -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();
});

View File

@ -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];
};

View File

@ -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">

View File

@ -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>
</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>
</form>
<div class="text-center login-divider" ng-show="oauthEnabled">
<div>
<div class="login-divider-line">
<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>
<a href="signup" class="btn btn-medium btn-signup btn-p-x-2">
Sign Up
</a>
</div>
<div>
<span class="login-divider-text">
<span ng-hide="disableLoginForm">or</span>
</span>
</div>
<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>
<div>
<div class="login-divider-line">
<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>
<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 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>
<a href="signup" class="btn btn-medium btn-signup btn-p-x-2">
Sign Up
</a>
</div>
</div>
<div class="clearfix"></div>
</div>
</div>

View File

@ -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,

View File

@ -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'),
},
})

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -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);