Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into alerting_reminder

Marcus Efraimsson 7 лет назад
Родитель
Сommit
34e448c6d4

+ 1 - 1
.gitignore

@@ -71,4 +71,4 @@ debug.test
 /vendor/**/appengine*
 /vendor/**/appengine*
 *.orig
 *.orig
 
 
-/devenv/dashboards/bulk-testing/*.json
+/devenv/bulk-dashboards/*.json

+ 1 - 0
CHANGELOG.md

@@ -20,6 +20,7 @@
 * **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484)
 * **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484)
 * **Prometheus**: Add $__interval, $__interval_ms, $__range, $__range_s & $__range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597) [#12882](https://github.com/grafana/grafana/issues/12882), thx [@roidelapluie](https://github.com/roidelapluie)
 * **Prometheus**: Add $__interval, $__interval_ms, $__range, $__range_s & $__range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597) [#12882](https://github.com/grafana/grafana/issues/12882), thx [@roidelapluie](https://github.com/roidelapluie)
 * **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda)
 * **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda)
+* **Variables**: Limit amount of queries executed when updating variable that other variable(s) are dependent on [#11890](https://github.com/grafana/grafana/issues/11890)
 * **Postgres/MySQL/MSSQL**: New $__unixEpochGroup and $__unixEpochGroupAlias macros [#12892](https://github.com/grafana/grafana/issues/12892), thx [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL/MSSQL**: New $__unixEpochGroup and $__unixEpochGroupAlias macros [#12892](https://github.com/grafana/grafana/issues/12892), thx [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL/MSSQL**: Add previous fill mode to $__timeGroup macro which will fill in previously seen value when point is missing [#12756](https://github.com/grafana/grafana/issues/12756), thx [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL/MSSQL**: Add previous fill mode to $__timeGroup macro which will fill in previously seen value when point is missing [#12756](https://github.com/grafana/grafana/issues/12756), thx [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL/MSSQL**: Use floor rounding in $__timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL/MSSQL**: Use floor rounding in $__timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm)

+ 1 - 1
devenv/bulk-dashboards/bulk-dashboards.yaml

@@ -5,5 +5,5 @@ providers:
    folder: 'Bulk dashboards'
    folder: 'Bulk dashboards'
    type: file
    type: file
    options:
    options:
-     path: devenv/dashboards/bulk-testing
+     path: devenv/bulk-dashboards
 
 

+ 2 - 2
devenv/setup.sh

@@ -7,11 +7,11 @@ bulkDashboard() {
 		COUNTER=0
 		COUNTER=0
 		MAX=400
 		MAX=400
 		while [  $COUNTER -lt $MAX ]; do
 		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}' }"
+				jsonnet -o "bulk-dashboards/dashboard${COUNTER}.json" -e "local bulkDash = import 'bulk-dashboards/bulkdash.jsonnet'; bulkDash + {  uid: 'uid-${COUNTER}',  title: 'title-${COUNTER}' }"
 				let COUNTER=COUNTER+1
 				let COUNTER=COUNTER+1
 		done
 		done
 
 
-		ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
+		ln -s -f -r ./bulk-dashboards/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
 }
 }
 
 
 requiresJsonnet() {
 requiresJsonnet() {

+ 2 - 1
docs/sources/alerting/notifications.md

@@ -153,7 +153,7 @@ There are a couple of configuration options which need to be set up in Grafana U
 
 
 Once these two properties are set, you can send the alerts to Kafka for further processing or throttling.
 Once these two properties are set, you can send the alerts to Kafka for further processing or throttling.
 
 
-### All supported notifier
+### All supported notifiers
 
 
 Name | Type |Support images | Support reminders
 Name | Type |Support images | Support reminders
 -----|------------ | ------ | ------ |
 -----|------------ | ------ | ------ |
@@ -170,6 +170,7 @@ Threema | `threema` | yes | yes
 Pushover | `pushover` | no | yes
 Pushover | `pushover` | no | yes
 Telegram | `telegram` | no | yes
 Telegram | `telegram` | no | yes
 Line | `line` | no | yes
 Line | `line` | no | yes
+Microsoft Teams | `teams` | yes | yes
 Prometheus Alertmanager | `prometheus-alertmanager` | no | no
 Prometheus Alertmanager | `prometheus-alertmanager` | no | no
 
 
 
 

+ 7 - 1
pkg/api/login.go

@@ -78,7 +78,13 @@ func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
 	user := userQuery.Result
 	user := userQuery.Result
 
 
 	// validate remember me cookie
 	// validate remember me cookie
-	if val, _ := c.GetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName); val != user.Login {
+	signingKey := user.Rands + user.Password
+	if len(signingKey) < 10 {
+		c.Logger.Error("Invalid user signingKey")
+		return false
+	}
+
+	if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
 		return false
 		return false
 	}
 	}
 
 

+ 40 - 1
pkg/services/sqlstore/migrations/user_mig.go

@@ -1,6 +1,12 @@
 package migrations
 package migrations
 
 
-import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+import (
+	"fmt"
+
+	"github.com/go-xorm/xorm"
+	. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+	"github.com/grafana/grafana/pkg/util"
+)
 
 
 func addUserMigrations(mg *Migrator) {
 func addUserMigrations(mg *Migrator) {
 	userV1 := Table{
 	userV1 := Table{
@@ -107,4 +113,37 @@ func addUserMigrations(mg *Migrator) {
 	mg.AddMigration("Add last_seen_at column to user", NewAddColumnMigration(userV2, &Column{
 	mg.AddMigration("Add last_seen_at column to user", NewAddColumnMigration(userV2, &Column{
 		Name: "last_seen_at", Type: DB_DateTime, Nullable: true,
 		Name: "last_seen_at", Type: DB_DateTime, Nullable: true,
 	}))
 	}))
+
+	// Adds salt & rands for old users who used ldap or oauth
+	mg.AddMigration("Add missing user data", &AddMissingUserSaltAndRandsMigration{})
+}
+
+type AddMissingUserSaltAndRandsMigration struct {
+	MigrationBase
+}
+
+func (m *AddMissingUserSaltAndRandsMigration) Sql(dialect Dialect) string {
+	return "code migration"
+}
+
+type TempUserDTO struct {
+	Id    int64
+	Login string
+}
+
+func (m *AddMissingUserSaltAndRandsMigration) Exec(sess *xorm.Session, mg *Migrator) error {
+	users := make([]*TempUserDTO, 0)
+
+	err := sess.Sql(fmt.Sprintf("SELECT id, login from %s WHERE rands = ''", mg.Dialect.Quote("user"))).Find(&users)
+	if err != nil {
+		return err
+	}
+
+	for _, user := range users {
+		_, err := sess.Exec("UPDATE "+mg.Dialect.Quote("user")+" SET salt = ?, rands = ? WHERE id = ?", util.GetRandomString(10), util.GetRandomString(10), user.Id)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
 }
 }

+ 11 - 5
pkg/services/sqlstore/migrator/migrator.go

@@ -12,7 +12,7 @@ import (
 
 
 type Migrator struct {
 type Migrator struct {
 	x          *xorm.Engine
 	x          *xorm.Engine
-	dialect    Dialect
+	Dialect    Dialect
 	migrations []Migration
 	migrations []Migration
 	Logger     log.Logger
 	Logger     log.Logger
 }
 }
@@ -31,7 +31,7 @@ func NewMigrator(engine *xorm.Engine) *Migrator {
 	mg.x = engine
 	mg.x = engine
 	mg.Logger = log.New("migrator")
 	mg.Logger = log.New("migrator")
 	mg.migrations = make([]Migration, 0)
 	mg.migrations = make([]Migration, 0)
-	mg.dialect = NewDialect(mg.x)
+	mg.Dialect = NewDialect(mg.x)
 	return mg
 	return mg
 }
 }
 
 
@@ -86,7 +86,7 @@ func (mg *Migrator) Start() error {
 			continue
 			continue
 		}
 		}
 
 
-		sql := m.Sql(mg.dialect)
+		sql := m.Sql(mg.Dialect)
 
 
 		record := MigrationLog{
 		record := MigrationLog{
 			MigrationId: m.Id(),
 			MigrationId: m.Id(),
@@ -122,7 +122,7 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
 
 
 	condition := m.GetCondition()
 	condition := m.GetCondition()
 	if condition != nil {
 	if condition != nil {
-		sql, args := condition.Sql(mg.dialect)
+		sql, args := condition.Sql(mg.Dialect)
 		results, err := sess.SQL(sql).Query(args...)
 		results, err := sess.SQL(sql).Query(args...)
 		if err != nil || len(results) == 0 {
 		if err != nil || len(results) == 0 {
 			mg.Logger.Debug("Skipping migration condition not fulfilled", "id", m.Id())
 			mg.Logger.Debug("Skipping migration condition not fulfilled", "id", m.Id())
@@ -130,7 +130,13 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
 		}
 		}
 	}
 	}
 
 
-	_, err := sess.Exec(m.Sql(mg.dialect))
+	var err error
+	if codeMigration, ok := m.(CodeMigration); ok {
+		err = codeMigration.Exec(sess, mg)
+	} else {
+		_, err = sess.Exec(m.Sql(mg.Dialect))
+	}
+
 	if err != nil {
 	if err != nil {
 		mg.Logger.Error("Executing migration failed", "id", m.Id(), "error", err)
 		mg.Logger.Error("Executing migration failed", "id", m.Id(), "error", err)
 		return err
 		return err

+ 7 - 0
pkg/services/sqlstore/migrator/types.go

@@ -3,6 +3,8 @@ package migrator
 import (
 import (
 	"fmt"
 	"fmt"
 	"strings"
 	"strings"
+
+	"github.com/go-xorm/xorm"
 )
 )
 
 
 const (
 const (
@@ -19,6 +21,11 @@ type Migration interface {
 	GetCondition() MigrationCondition
 	GetCondition() MigrationCondition
 }
 }
 
 
+type CodeMigration interface {
+	Migration
+	Exec(sess *xorm.Session, migrator *Migrator) error
+}
+
 type SQLType string
 type SQLType string
 
 
 type ColumnType string
 type ColumnType string

+ 3 - 2
pkg/services/sqlstore/user.go

@@ -113,9 +113,10 @@ func CreateUser(ctx context.Context, cmd *m.CreateUserCommand) error {
 			LastSeenAt:    time.Now().AddDate(-10, 0, 0),
 			LastSeenAt:    time.Now().AddDate(-10, 0, 0),
 		}
 		}
 
 
+		user.Salt = util.GetRandomString(10)
+		user.Rands = util.GetRandomString(10)
+
 		if len(cmd.Password) > 0 {
 		if len(cmd.Password) > 0 {
-			user.Salt = util.GetRandomString(10)
-			user.Rands = util.GetRandomString(10)
 			user.Password = util.EncodePassword(cmd.Password, user.Salt)
 			user.Password = util.EncodePassword(cmd.Password, user.Salt)
 		}
 		}
 
 

+ 22 - 0
pkg/services/sqlstore/user_test.go

@@ -15,6 +15,28 @@ func TestUserDataAccess(t *testing.T) {
 	Convey("Testing DB", t, func() {
 	Convey("Testing DB", t, func() {
 		InitTestDB(t)
 		InitTestDB(t)
 
 
+		Convey("Creating a user", func() {
+			cmd := &m.CreateUserCommand{
+				Email: "usertest@test.com",
+				Name:  "user name",
+				Login: "user_test_login",
+			}
+
+			err := CreateUser(context.Background(), cmd)
+			So(err, ShouldBeNil)
+
+			Convey("Loading a user", func() {
+				query := m.GetUserByIdQuery{Id: cmd.Result.Id}
+				err := GetUserById(&query)
+				So(err, ShouldBeNil)
+
+				So(query.Result.Email, ShouldEqual, "usertest@test.com")
+				So(query.Result.Password, ShouldEqual, "")
+				So(query.Result.Rands, ShouldHaveLength, 10)
+				So(query.Result.Salt, ShouldHaveLength, 10)
+			})
+		})
+
 		Convey("Given 5 users", func() {
 		Convey("Given 5 users", func() {
 			var err error
 			var err error
 			var cmd *m.CreateUserCommand
 			var cmd *m.CreateUserCommand

+ 7 - 1
public/app/containers/Explore/Explore.tsx

@@ -207,6 +207,7 @@ export class Explore extends React.Component<any, IExploreState> {
       datasourceError: null,
       datasourceError: null,
       datasourceLoading: true,
       datasourceLoading: true,
       graphResult: null,
       graphResult: null,
+      latency: 0,
       logsResult: null,
       logsResult: null,
       queryErrors: [],
       queryErrors: [],
       queryHints: [],
       queryHints: [],
@@ -254,7 +255,10 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState({
     this.setState({
       graphResult: null,
       graphResult: null,
       logsResult: null,
       logsResult: null,
+      latency: 0,
       queries: ensureQueries(),
       queries: ensureQueries(),
+      queryErrors: [],
+      queryHints: [],
       tableResult: null,
       tableResult: null,
     });
     });
   };
   };
@@ -276,8 +280,10 @@ export class Explore extends React.Component<any, IExploreState> {
 
 
   onClickSplit = () => {
   onClickSplit = () => {
     const { onChangeSplit } = this.props;
     const { onChangeSplit } = this.props;
+    const state = { ...this.state };
+    state.queries = state.queries.map(({ edited, ...rest }) => rest);
     if (onChangeSplit) {
     if (onChangeSplit) {
-      onChangeSplit(true, this.state);
+      onChangeSplit(true, state);
     }
     }
   };
   };
 
 

+ 19 - 0
public/app/containers/Explore/PromQueryField.test.tsx

@@ -94,6 +94,25 @@ describe('PromQueryField typeahead handling', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
     });
     });
 
 
+    it('returns label suggestions on label context but leaves out labels that already exist', () => {
+      const instance = shallow(
+        <PromQueryField {...defaultProps} labelKeys={{ '{job="foo"}': ['bar', 'job'] }} />
+      ).instance() as PromQueryField;
+      const value = Plain.deserialize('{job="foo",}');
+      const range = value.selection.merge({
+        anchorOffset: 11,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.getTypeahead({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-labels'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-labels');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+    });
+
     it('returns a refresher on label context and unavailable metric', () => {
     it('returns a refresher on label context and unavailable metric', () => {
       const instance = shallow(
       const instance = shallow(
         <PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
         <PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />

+ 11 - 5
public/app/containers/Explore/PromQueryField.tsx

@@ -10,7 +10,7 @@ import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
 import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
 import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
 import BracesPlugin from './slate-plugins/braces';
 import BracesPlugin from './slate-plugins/braces';
 import RunnerPlugin from './slate-plugins/runner';
 import RunnerPlugin from './slate-plugins/runner';
-import { processLabels, RATE_RANGES, cleanText, getCleanSelector } from './utils/prometheus';
+import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
 
 
 import TypeaheadField, {
 import TypeaheadField, {
   Suggestion,
   Suggestion,
@@ -328,7 +328,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
     const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
     // foo{bar="1"}
     // foo{bar="1"}
     const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
     const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
-    const selector = getCleanSelector(selectorString, selectorString.length - 2);
+    const selector = parseSelector(selectorString, selectorString.length - 2).selector;
 
 
     const labelKeys = this.state.labelKeys[selector];
     const labelKeys = this.state.labelKeys[selector];
     if (labelKeys) {
     if (labelKeys) {
@@ -353,12 +353,15 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
 
 
     // Get normalized selector
     // Get normalized selector
     let selector;
     let selector;
+    let parsedSelector;
     try {
     try {
-      selector = getCleanSelector(line, cursorOffset);
+      parsedSelector = parseSelector(line, cursorOffset);
+      selector = parsedSelector.selector;
     } catch {
     } catch {
       selector = EMPTY_SELECTOR;
       selector = EMPTY_SELECTOR;
     }
     }
     const containsMetric = selector.indexOf('__name__=') > -1;
     const containsMetric = selector.indexOf('__name__=') > -1;
+    const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
 
 
     if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
     if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
       // Label values
       // Label values
@@ -374,8 +377,11 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
       // Label keys
       // Label keys
       const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
       const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
       if (labelKeys) {
       if (labelKeys) {
-        context = 'context-labels';
-        suggestions.push({ label: `Labels`, items: labelKeys.map(wrapLabel) });
+        const possibleKeys = _.difference(labelKeys, existingKeys);
+        if (possibleKeys.length > 0) {
+          context = 'context-labels';
+          suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
+        }
       }
       }
     }
     }
 
 

+ 1 - 1
public/app/containers/Explore/QueryField.tsx

@@ -331,7 +331,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
         }
         }
         break;
         break;
       }
       }
-
+      case 'Enter':
       case 'Tab': {
       case 'Tab': {
         if (this.menuEl) {
         if (this.menuEl) {
           // Dont blur input
           // Dont blur input

+ 45 - 17
public/app/containers/Explore/utils/prometheus.test.ts

@@ -1,33 +1,61 @@
-import { getCleanSelector } from './prometheus';
+import { parseSelector } from './prometheus';
+
+describe('parseSelector()', () => {
+  let parsed;
 
 
-describe('getCleanSelector()', () => {
   it('returns a clean selector from an empty selector', () => {
   it('returns a clean selector from an empty selector', () => {
-    expect(getCleanSelector('{}', 1)).toBe('{}');
+    parsed = parseSelector('{}', 1);
+    expect(parsed.selector).toBe('{}');
+    expect(parsed.labelKeys).toEqual([]);
   });
   });
+
   it('throws if selector is broken', () => {
   it('throws if selector is broken', () => {
-    expect(() => getCleanSelector('{foo')).toThrow();
+    expect(() => parseSelector('{foo')).toThrow();
   });
   });
+
   it('returns the selector sorted by label key', () => {
   it('returns the selector sorted by label key', () => {
-    expect(getCleanSelector('{foo="bar"}')).toBe('{foo="bar"}');
-    expect(getCleanSelector('{foo="bar",baz="xx"}')).toBe('{baz="xx",foo="bar"}');
+    parsed = parseSelector('{foo="bar"}');
+    expect(parsed.selector).toBe('{foo="bar"}');
+    expect(parsed.labelKeys).toEqual(['foo']);
+
+    parsed = parseSelector('{foo="bar",baz="xx"}');
+    expect(parsed.selector).toBe('{baz="xx",foo="bar"}');
   });
   });
+
   it('returns a clean selector from an incomplete one', () => {
   it('returns a clean selector from an incomplete one', () => {
-    expect(getCleanSelector('{foo}')).toBe('{}');
-    expect(getCleanSelector('{foo="bar",baz}')).toBe('{foo="bar"}');
-    expect(getCleanSelector('{foo="bar",baz="}')).toBe('{foo="bar"}');
+    parsed = parseSelector('{foo}');
+    expect(parsed.selector).toBe('{}');
+
+    parsed = parseSelector('{foo="bar",baz}');
+    expect(parsed.selector).toBe('{foo="bar"}');
+
+    parsed = parseSelector('{foo="bar",baz="}');
+    expect(parsed.selector).toBe('{foo="bar"}');
   });
   });
+
   it('throws if not inside a selector', () => {
   it('throws if not inside a selector', () => {
-    expect(() => getCleanSelector('foo{}', 0)).toThrow();
-    expect(() => getCleanSelector('foo{} + bar{}', 5)).toThrow();
+    expect(() => parseSelector('foo{}', 0)).toThrow();
+    expect(() => parseSelector('foo{} + bar{}', 5)).toThrow();
   });
   });
+
   it('returns the selector nearest to the cursor offset', () => {
   it('returns the selector nearest to the cursor offset', () => {
-    expect(() => getCleanSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow();
-    expect(getCleanSelector('{foo="bar"} + {foo="bar"}', 1)).toBe('{foo="bar"}');
-    expect(getCleanSelector('{foo="bar"} + {baz="xx"}', 1)).toBe('{foo="bar"}');
-    expect(getCleanSelector('{baz="xx"} + {foo="bar"}', 16)).toBe('{foo="bar"}');
+    expect(() => parseSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow();
+
+    parsed = parseSelector('{foo="bar"} + {foo="bar"}', 1);
+    expect(parsed.selector).toBe('{foo="bar"}');
+
+    parsed = parseSelector('{foo="bar"} + {baz="xx"}', 1);
+    expect(parsed.selector).toBe('{foo="bar"}');
+
+    parsed = parseSelector('{baz="xx"} + {foo="bar"}', 16);
+    expect(parsed.selector).toBe('{foo="bar"}');
   });
   });
+
   it('returns a selector with metric if metric is given', () => {
   it('returns a selector with metric if metric is given', () => {
-    expect(getCleanSelector('bar{foo}', 4)).toBe('{__name__="bar"}');
-    expect(getCleanSelector('baz{foo="bar"}', 12)).toBe('{__name__="baz",foo="bar"}');
+    parsed = parseSelector('bar{foo}', 4);
+    expect(parsed.selector).toBe('{__name__="bar"}');
+
+    parsed = parseSelector('baz{foo="bar"}', 12);
+    expect(parsed.selector).toBe('{__name__="baz",foo="bar"}');
   });
   });
 });
 });

+ 10 - 7
public/app/containers/Explore/utils/prometheus.ts

@@ -29,11 +29,14 @@ export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
 // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
 // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
 const selectorRegexp = /\{[^}]*?\}/;
 const selectorRegexp = /\{[^}]*?\}/;
 const labelRegexp = /\b\w+="[^"\n]*?"/g;
 const labelRegexp = /\b\w+="[^"\n]*?"/g;
-export function getCleanSelector(query: string, cursorOffset = 1): string {
+export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
   if (!query.match(selectorRegexp)) {
   if (!query.match(selectorRegexp)) {
     // Special matcher for metrics
     // Special matcher for metrics
     if (query.match(/^\w+$/)) {
     if (query.match(/^\w+$/)) {
-      return `{__name__="${query}"}`;
+      return {
+        selector: `{__name__="${query}"}`,
+        labelKeys: ['__name__'],
+      };
     }
     }
     throw new Error('Query must contain a selector: ' + query);
     throw new Error('Query must contain a selector: ' + query);
   }
   }
@@ -79,10 +82,10 @@ export function getCleanSelector(query: string, cursorOffset = 1): string {
   }
   }
 
 
   // Build sorted selector
   // Build sorted selector
-  const cleanSelector = Object.keys(labels)
-    .sort()
-    .map(key => `${key}=${labels[key]}`)
-    .join(',');
+  const labelKeys = Object.keys(labels).sort();
+  const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(',');
 
 
-  return ['{', cleanSelector, '}'].join('');
+  const selectorString = ['{', cleanSelector, '}'].join('');
+
+  return { labelKeys, selector: selectorString };
 }
 }

+ 108 - 0
public/app/core/utils/dag.test.ts

@@ -0,0 +1,108 @@
+import { Graph } from './dag';
+
+describe('Directed acyclic graph', () => {
+  describe('Given a graph with nodes with different links in between them', () => {
+    let dag = new Graph();
+    let nodeA = dag.createNode('A');
+    let nodeB = dag.createNode('B');
+    let nodeC = dag.createNode('C');
+    let nodeD = dag.createNode('D');
+    let nodeE = dag.createNode('E');
+    let nodeF = dag.createNode('F');
+    let nodeG = dag.createNode('G');
+    let nodeH = dag.createNode('H');
+    let nodeI = dag.createNode('I');
+    dag.link([nodeB, nodeC, nodeD, nodeE, nodeF, nodeG, nodeH], nodeA);
+    dag.link([nodeC, nodeD, nodeE, nodeF, nodeI], nodeB);
+    dag.link([nodeD, nodeE, nodeF, nodeG], nodeC);
+    dag.link([nodeE, nodeF], nodeD);
+    dag.link([nodeF, nodeG], nodeE);
+    //printGraph(dag);
+
+    it('nodes in graph should have expected edges', () => {
+      expect(nodeA.inputEdges).toHaveLength(7);
+      expect(nodeA.outputEdges).toHaveLength(0);
+      expect(nodeA.edges).toHaveLength(7);
+
+      expect(nodeB.inputEdges).toHaveLength(5);
+      expect(nodeB.outputEdges).toHaveLength(1);
+      expect(nodeB.edges).toHaveLength(6);
+
+      expect(nodeC.inputEdges).toHaveLength(4);
+      expect(nodeC.outputEdges).toHaveLength(2);
+      expect(nodeC.edges).toHaveLength(6);
+
+      expect(nodeD.inputEdges).toHaveLength(2);
+      expect(nodeD.outputEdges).toHaveLength(3);
+      expect(nodeD.edges).toHaveLength(5);
+
+      expect(nodeE.inputEdges).toHaveLength(2);
+      expect(nodeE.outputEdges).toHaveLength(4);
+      expect(nodeE.edges).toHaveLength(6);
+
+      expect(nodeF.inputEdges).toHaveLength(0);
+      expect(nodeF.outputEdges).toHaveLength(5);
+      expect(nodeF.edges).toHaveLength(5);
+
+      expect(nodeG.inputEdges).toHaveLength(0);
+      expect(nodeG.outputEdges).toHaveLength(3);
+      expect(nodeG.edges).toHaveLength(3);
+
+      expect(nodeH.inputEdges).toHaveLength(0);
+      expect(nodeH.outputEdges).toHaveLength(1);
+      expect(nodeH.edges).toHaveLength(1);
+
+      expect(nodeI.inputEdges).toHaveLength(0);
+      expect(nodeI.outputEdges).toHaveLength(1);
+      expect(nodeI.edges).toHaveLength(1);
+
+      expect(nodeA.getEdgeFrom(nodeB)).not.toBeUndefined();
+      expect(nodeB.getEdgeTo(nodeA)).not.toBeUndefined();
+    });
+
+    it('when optimizing input edges for node A should return node B and H', () => {
+      const actual = nodeA.getOptimizedInputEdges().map(e => e.inputNode);
+      expect(actual).toHaveLength(2);
+      expect(actual).toEqual(expect.arrayContaining([nodeB, nodeH]));
+    });
+
+    it('when optimizing input edges for node B should return node C', () => {
+      const actual = nodeB.getOptimizedInputEdges().map(e => e.inputNode);
+      expect(actual).toHaveLength(2);
+      expect(actual).toEqual(expect.arrayContaining([nodeC, nodeI]));
+    });
+
+    it('when optimizing input edges for node C should return node D', () => {
+      const actual = nodeC.getOptimizedInputEdges().map(e => e.inputNode);
+      expect(actual).toHaveLength(1);
+      expect(actual).toEqual(expect.arrayContaining([nodeD]));
+    });
+
+    it('when optimizing input edges for node D should return node E', () => {
+      const actual = nodeD.getOptimizedInputEdges().map(e => e.inputNode);
+      expect(actual).toHaveLength(1);
+      expect(actual).toEqual(expect.arrayContaining([nodeE]));
+    });
+
+    it('when optimizing input edges for node E should return node F and G', () => {
+      const actual = nodeE.getOptimizedInputEdges().map(e => e.inputNode);
+      expect(actual).toHaveLength(2);
+      expect(actual).toEqual(expect.arrayContaining([nodeF, nodeG]));
+    });
+
+    it('when optimizing input edges for node F should return zero nodes', () => {
+      const actual = nodeF.getOptimizedInputEdges();
+      expect(actual).toHaveLength(0);
+    });
+
+    it('when optimizing input edges for node G should return zero nodes', () => {
+      const actual = nodeG.getOptimizedInputEdges();
+      expect(actual).toHaveLength(0);
+    });
+
+    it('when optimizing input edges for node H should return zero nodes', () => {
+      const actual = nodeH.getOptimizedInputEdges();
+      expect(actual).toHaveLength(0);
+    });
+  });
+});

+ 201 - 0
public/app/core/utils/dag.ts

@@ -0,0 +1,201 @@
+export class Edge {
+  inputNode: Node;
+  outputNode: Node;
+
+  _linkTo(node, direction) {
+    if (direction <= 0) {
+      node.inputEdges.push(this);
+    }
+
+    if (direction >= 0) {
+      node.outputEdges.push(this);
+    }
+
+    node.edges.push(this);
+  }
+
+  link(inputNode: Node, outputNode: Node) {
+    this.unlink();
+    this.inputNode = inputNode;
+    this.outputNode = outputNode;
+
+    this._linkTo(inputNode, 1);
+    this._linkTo(outputNode, -1);
+    return this;
+  }
+
+  unlink() {
+    let pos;
+    let inode = this.inputNode;
+    let onode = this.outputNode;
+
+    if (!(inode && onode)) {
+      return;
+    }
+
+    pos = inode.edges.indexOf(this);
+    if (pos > -1) {
+      inode.edges.splice(pos, 1);
+    }
+
+    pos = onode.edges.indexOf(this);
+    if (pos > -1) {
+      onode.edges.splice(pos, 1);
+    }
+
+    pos = inode.outputEdges.indexOf(this);
+    if (pos > -1) {
+      inode.outputEdges.splice(pos, 1);
+    }
+
+    pos = onode.inputEdges.indexOf(this);
+    if (pos > -1) {
+      onode.inputEdges.splice(pos, 1);
+    }
+
+    this.inputNode = null;
+    this.outputNode = null;
+  }
+}
+
+export class Node {
+  name: string;
+  edges: Edge[];
+  inputEdges: Edge[];
+  outputEdges: Edge[];
+
+  constructor(name: string) {
+    this.name = name;
+    this.edges = [];
+    this.inputEdges = [];
+    this.outputEdges = [];
+  }
+
+  getEdgeFrom(from: string | Node): Edge {
+    if (!from) {
+      return null;
+    }
+
+    if (typeof from === 'object') {
+      return this.inputEdges.find(e => e.inputNode.name === from.name);
+    }
+
+    return this.inputEdges.find(e => e.inputNode.name === from);
+  }
+
+  getEdgeTo(to: string | Node): Edge {
+    if (!to) {
+      return null;
+    }
+
+    if (typeof to === 'object') {
+      return this.outputEdges.find(e => e.outputNode.name === to.name);
+    }
+
+    return this.outputEdges.find(e => e.outputNode.name === to);
+  }
+
+  getOptimizedInputEdges(): Edge[] {
+    let toBeRemoved = [];
+    this.inputEdges.forEach(e => {
+      let inputEdgesNodes = e.inputNode.inputEdges.map(e => e.inputNode);
+
+      inputEdgesNodes.forEach(n => {
+        let edgeToRemove = n.getEdgeTo(this.name);
+        if (edgeToRemove) {
+          toBeRemoved.push(edgeToRemove);
+        }
+      });
+    });
+
+    return this.inputEdges.filter(e => toBeRemoved.indexOf(e) === -1);
+  }
+}
+
+export class Graph {
+  nodes = {};
+
+  constructor() {}
+
+  createNode(name: string): Node {
+    const n = new Node(name);
+    this.nodes[name] = n;
+    return n;
+  }
+
+  createNodes(names: string[]): Node[] {
+    let nodes = [];
+    names.forEach(name => {
+      nodes.push(this.createNode(name));
+    });
+    return nodes;
+  }
+
+  link(input: string | string[] | Node | Node[], output: string | string[] | Node | Node[]): Edge[] {
+    let inputArr = [];
+    let outputArr = [];
+    let inputNodes = [];
+    let outputNodes = [];
+
+    if (input instanceof Array) {
+      inputArr = input;
+    } else {
+      inputArr = [input];
+    }
+
+    if (output instanceof Array) {
+      outputArr = output;
+    } else {
+      outputArr = [output];
+    }
+
+    for (let n = 0; n < inputArr.length; n++) {
+      const i = inputArr[n];
+      if (typeof i === 'string') {
+        inputNodes.push(this.getNode(i));
+      } else {
+        inputNodes.push(i);
+      }
+    }
+
+    for (let n = 0; n < outputArr.length; n++) {
+      const i = outputArr[n];
+      if (typeof i === 'string') {
+        outputNodes.push(this.getNode(i));
+      } else {
+        outputNodes.push(i);
+      }
+    }
+
+    let edges = [];
+    inputNodes.forEach(input => {
+      outputNodes.forEach(output => {
+        edges.push(this.createEdge().link(input, output));
+      });
+    });
+    return edges;
+  }
+
+  createEdge(): Edge {
+    return new Edge();
+  }
+
+  getNode(name: string): Node {
+    return this.nodes[name];
+  }
+}
+
+export const printGraph = (g: Graph) => {
+  Object.keys(g.nodes).forEach(name => {
+    const n = g.nodes[name];
+    let outputEdges = n.outputEdges.map(e => e.outputNode.name).join(', ');
+    if (!outputEdges) {
+      outputEdges = '<none>';
+    }
+    let inputEdges = n.inputEdges.map(e => e.inputNode.name).join(', ');
+    if (!inputEdges) {
+      inputEdges = '<none>';
+    }
+    console.log(`${n.name}:\n - links to:   ${outputEdges}\n - links from: ${inputEdges}`);
+  });
+};

+ 28 - 10
public/app/features/templating/variable_srv.ts

@@ -2,6 +2,7 @@ import angular from 'angular';
 import _ from 'lodash';
 import _ from 'lodash';
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import { variableTypes } from './variable';
 import { variableTypes } from './variable';
+import { Graph } from 'app/core/utils/dag';
 
 
 export class VariableSrv {
 export class VariableSrv {
   dashboard: any;
   dashboard: any;
@@ -120,16 +121,13 @@ export class VariableSrv {
       return this.$q.when();
       return this.$q.when();
     }
     }
 
 
-    // cascade updates to variables that use this variable
-    var promises = _.map(this.variables, otherVariable => {
-      if (otherVariable === variable) {
-        return;
-      }
-
-      if (otherVariable.dependsOn(variable)) {
-        return this.updateOptions(otherVariable);
-      }
-    });
+    const g = this.createGraph();
+    const promises = g
+      .getNode(variable.name)
+      .getOptimizedInputEdges()
+      .map(e => {
+        return this.updateOptions(this.variables.find(v => v.name === e.inputNode.name));
+      });
 
 
     return this.$q.all(promises).then(() => {
     return this.$q.all(promises).then(() => {
       if (emitChangeEvents) {
       if (emitChangeEvents) {
@@ -288,6 +286,26 @@ export class VariableSrv {
     filter.operator = options.operator;
     filter.operator = options.operator;
     this.variableUpdated(variable, true);
     this.variableUpdated(variable, true);
   }
   }
+
+  createGraph() {
+    let g = new Graph();
+
+    this.variables.forEach(v1 => {
+      g.createNode(v1.name);
+
+      this.variables.forEach(v2 => {
+        if (v1 === v2) {
+          return;
+        }
+
+        if (v1.dependsOn(v2)) {
+          g.link(v1.name, v2.name);
+        }
+      });
+    });
+
+    return g;
+  }
 }
 }
 
 
 coreModule.service('variableSrv', VariableSrv);
 coreModule.service('variableSrv', VariableSrv);

+ 6 - 6
public/app/partials/panelgeneral.html

@@ -18,18 +18,18 @@
 			<span class="gf-form-label width-9">For each value of</span>
 			<span class="gf-form-label width-9">For each value of</span>
 			<dash-repeat-option panel="ctrl.panel"></dash-repeat-option>
 			<dash-repeat-option panel="ctrl.panel"></dash-repeat-option>
 		</div>
 		</div>
-		<div class="gf-form" ng-show="ctrl.panel.repeat">
-			<span class="gf-form-label width-9">Min width</span>
-			<select class="gf-form-input" ng-model="ctrl.panel.minSpan" ng-options="f for f in [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24]">
-				<option value=""></option>
-			</select>
-		</div>
     <div class="gf-form" ng-show="ctrl.panel.repeat">
     <div class="gf-form" ng-show="ctrl.panel.repeat">
 			<span class="gf-form-label width-9">Direction</span>
 			<span class="gf-form-label width-9">Direction</span>
 			<select class="gf-form-input" ng-model="ctrl.panel.repeatDirection" ng-options="f.value as f.text for f in [{value: 'v', text: 'Vertical'}, {value: 'h', text: 'Horizontal'}]">
 			<select class="gf-form-input" ng-model="ctrl.panel.repeatDirection" ng-options="f.value as f.text for f in [{value: 'v', text: 'Vertical'}, {value: 'h', text: 'Horizontal'}]">
 				<option value=""></option>
 				<option value=""></option>
 			</select>
 			</select>
 		</div>
 		</div>
+		<div class="gf-form" ng-show="ctrl.panel.repeat && ctrl.panel.repeatDirection == 'h'">
+			<span class="gf-form-label width-9">Min width</span>
+			<select class="gf-form-input" ng-model="ctrl.panel.minSpan" ng-options="f for f in [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24]">
+				<option value=""></option>
+			</select>
+		</div>
 	</div>
 	</div>
 
 
 	<panel-links-editor panel="ctrl.panel"></panel-links-editor>
 	<panel-links-editor panel="ctrl.panel"></panel-links-editor>

+ 16 - 10
public/app/plugins/datasource/prometheus/datasource.ts

@@ -39,7 +39,7 @@ export function addLabelToQuery(query: string, key: string, value: string): stri
 
 
   // Add empty selector to bare metric name
   // Add empty selector to bare metric name
   let previousWord;
   let previousWord;
-  query = query.replace(/(\w+)\b(?![\({=",])/g, (match, word, offset) => {
+  query = query.replace(/(\w+)\b(?![\(\]{=",])/g, (match, word, offset) => {
     // Check if inside a selector
     // Check if inside a selector
     const nextSelectorStart = query.slice(offset).indexOf('{');
     const nextSelectorStart = query.slice(offset).indexOf('{');
     const nextSelectorEnd = query.slice(offset).indexOf('}');
     const nextSelectorEnd = query.slice(offset).indexOf('}');
@@ -110,10 +110,9 @@ export function determineQueryHints(series: any[], datasource?: any): any[] {
 
 
     // Check for monotony
     // Check for monotony
     const datapoints: [number, number][] = s.datapoints;
     const datapoints: [number, number][] = s.datapoints;
-    const simpleMetric = query.trim().match(/^\w+$/);
-    if (simpleMetric && datapoints.length > 1) {
+    if (datapoints.length > 1) {
       let increasing = false;
       let increasing = false;
-      const monotonic = datapoints.every((dp, index) => {
+      const monotonic = datapoints.filter(dp => dp[0] !== null).every((dp, index) => {
         if (index === 0) {
         if (index === 0) {
           return true;
           return true;
         }
         }
@@ -122,18 +121,25 @@ export function determineQueryHints(series: any[], datasource?: any): any[] {
         return dp[0] >= datapoints[index - 1][0];
         return dp[0] >= datapoints[index - 1][0];
       });
       });
       if (increasing && monotonic) {
       if (increasing && monotonic) {
-        const label = 'Time series is monotonously increasing.';
-        return {
-          label,
-          index,
-          fix: {
+        const simpleMetric = query.trim().match(/^\w+$/);
+        let label = 'Time series is monotonously increasing.';
+        let fix;
+        if (simpleMetric) {
+          fix = {
             label: 'Fix by adding rate().',
             label: 'Fix by adding rate().',
             action: {
             action: {
               type: 'ADD_RATE',
               type: 'ADD_RATE',
               query,
               query,
               index,
               index,
             },
             },
-          },
+          };
+        } else {
+          label = `${label} Try applying a rate() function.`;
+        }
+        return {
+          label,
+          index,
+          fix,
         };
         };
       }
       }
     }
     }

+ 25 - 0
public/app/plugins/datasource/prometheus/specs/datasource.test.ts

@@ -213,6 +213,30 @@ describe('PrometheusDatasource', () => {
       });
       });
     });
     });
 
 
+    it('returns a rate hint w/o action for a complex monotonously increasing series', () => {
+      const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }];
+      const hints = determineQueryHints(series);
+      expect(hints.length).toBe(1);
+      expect(hints[0].label).toContain('rate()');
+      expect(hints[0].fix).toBeUndefined();
+    });
+
+    it('returns a rate hint for a monotonously increasing series with missing data', () => {
+      const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }];
+      const hints = determineQueryHints(series);
+      expect(hints.length).toBe(1);
+      expect(hints[0]).toMatchObject({
+        label: 'Time series is monotonously increasing.',
+        index: 0,
+        fix: {
+          action: {
+            type: 'ADD_RATE',
+            query: 'metric',
+          },
+        },
+      });
+    });
+
     it('returns a histogram hint for a bucket series', () => {
     it('returns a histogram hint for a bucket series', () => {
       const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
       const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
       const hints = determineQueryHints(series);
       const hints = determineQueryHints(series);
@@ -351,6 +375,7 @@ describe('PrometheusDatasource', () => {
     expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe(
     expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe(
       'foo{bar="baz",instance="my-host.com:9100"}'
       'foo{bar="baz",instance="my-host.com:9100"}'
     );
     );
+    expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])');
   });
   });
 });
 });