浏览代码

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

Marcus Efraimsson 7 年之前
父节点
当前提交
34e448c6d4

+ 1 - 1
.gitignore

@@ -71,4 +71,4 @@ debug.test
 /vendor/**/appengine*
 *.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**: 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**: 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**: 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)

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

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

+ 2 - 2
devenv/setup.sh

@@ -7,11 +7,11 @@ bulkDashboard() {
 		COUNTER=0
 		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}' }"
+				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
 		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() {

+ 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.
 
-### All supported notifier
+### All supported notifiers
 
 Name | Type |Support images | Support reminders
 -----|------------ | ------ | ------ |
@@ -170,6 +170,7 @@ Threema | `threema` | yes | yes
 Pushover | `pushover` | no | yes
 Telegram | `telegram` | no | yes
 Line | `line` | no | yes
+Microsoft Teams | `teams` | yes | yes
 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
 
 	// 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
 	}
 

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

@@ -1,6 +1,12 @@
 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) {
 	userV1 := Table{
@@ -107,4 +113,37 @@ func addUserMigrations(mg *Migrator) {
 	mg.AddMigration("Add last_seen_at column to user", NewAddColumnMigration(userV2, &Column{
 		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 {
 	x          *xorm.Engine
-	dialect    Dialect
+	Dialect    Dialect
 	migrations []Migration
 	Logger     log.Logger
 }
@@ -31,7 +31,7 @@ func NewMigrator(engine *xorm.Engine) *Migrator {
 	mg.x = engine
 	mg.Logger = log.New("migrator")
 	mg.migrations = make([]Migration, 0)
-	mg.dialect = NewDialect(mg.x)
+	mg.Dialect = NewDialect(mg.x)
 	return mg
 }
 
@@ -86,7 +86,7 @@ func (mg *Migrator) Start() error {
 			continue
 		}
 
-		sql := m.Sql(mg.dialect)
+		sql := m.Sql(mg.Dialect)
 
 		record := MigrationLog{
 			MigrationId: m.Id(),
@@ -122,7 +122,7 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
 
 	condition := m.GetCondition()
 	if condition != nil {
-		sql, args := condition.Sql(mg.dialect)
+		sql, args := condition.Sql(mg.Dialect)
 		results, err := sess.SQL(sql).Query(args...)
 		if err != nil || len(results) == 0 {
 			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 {
 		mg.Logger.Error("Executing migration failed", "id", m.Id(), "error", err)
 		return err

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

@@ -3,6 +3,8 @@ package migrator
 import (
 	"fmt"
 	"strings"
+
+	"github.com/go-xorm/xorm"
 )
 
 const (
@@ -19,6 +21,11 @@ type Migration interface {
 	GetCondition() MigrationCondition
 }
 
+type CodeMigration interface {
+	Migration
+	Exec(sess *xorm.Session, migrator *Migrator) error
+}
+
 type SQLType 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),
 		}
 
+		user.Salt = util.GetRandomString(10)
+		user.Rands = util.GetRandomString(10)
+
 		if len(cmd.Password) > 0 {
-			user.Salt = util.GetRandomString(10)
-			user.Rands = util.GetRandomString(10)
 			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() {
 		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() {
 			var err error
 			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,
       datasourceLoading: true,
       graphResult: null,
+      latency: 0,
       logsResult: null,
       queryErrors: [],
       queryHints: [],
@@ -254,7 +255,10 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState({
       graphResult: null,
       logsResult: null,
+      latency: 0,
       queries: ensureQueries(),
+      queryErrors: [],
+      queryHints: [],
       tableResult: null,
     });
   };
@@ -276,8 +280,10 @@ export class Explore extends React.Component<any, IExploreState> {
 
   onClickSplit = () => {
     const { onChangeSplit } = this.props;
+    const state = { ...this.state };
+    state.queries = state.queries.map(({ edited, ...rest }) => rest);
     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' }]);
     });
 
+    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', () => {
       const instance = shallow(
         <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 BracesPlugin from './slate-plugins/braces';
 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, {
   Suggestion,
@@ -328,7 +328,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
     // foo{bar="1"}
     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];
     if (labelKeys) {
@@ -353,12 +353,15 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
 
     // Get normalized selector
     let selector;
+    let parsedSelector;
     try {
-      selector = getCleanSelector(line, cursorOffset);
+      parsedSelector = parseSelector(line, cursorOffset);
+      selector = parsedSelector.selector;
     } catch {
       selector = EMPTY_SELECTOR;
     }
     const containsMetric = selector.indexOf('__name__=') > -1;
+    const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
 
     if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
       // Label values
@@ -374,8 +377,11 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
       // Label keys
       const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
       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;
       }
-
+      case 'Enter':
       case 'Tab': {
         if (this.menuEl) {
           // 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', () => {
-    expect(getCleanSelector('{}', 1)).toBe('{}');
+    parsed = parseSelector('{}', 1);
+    expect(parsed.selector).toBe('{}');
+    expect(parsed.labelKeys).toEqual([]);
   });
+
   it('throws if selector is broken', () => {
-    expect(() => getCleanSelector('{foo')).toThrow();
+    expect(() => parseSelector('{foo')).toThrow();
   });
+
   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', () => {
-    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', () => {
-    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', () => {
-    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', () => {
-    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 selectorRegexp = /\{[^}]*?\}/;
 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)) {
     // Special matcher for metrics
     if (query.match(/^\w+$/)) {
-      return `{__name__="${query}"}`;
+      return {
+        selector: `{__name__="${query}"}`,
+        labelKeys: ['__name__'],
+      };
     }
     throw new Error('Query must contain a selector: ' + query);
   }
@@ -79,10 +82,10 @@ export function getCleanSelector(query: string, cursorOffset = 1): string {
   }
 
   // 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 coreModule from 'app/core/core_module';
 import { variableTypes } from './variable';
+import { Graph } from 'app/core/utils/dag';
 
 export class VariableSrv {
   dashboard: any;
@@ -120,16 +121,13 @@ export class VariableSrv {
       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(() => {
       if (emitChangeEvents) {
@@ -288,6 +286,26 @@ export class VariableSrv {
     filter.operator = options.operator;
     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);

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

@@ -18,18 +18,18 @@
 			<span class="gf-form-label width-9">For each value of</span>
 			<dash-repeat-option panel="ctrl.panel"></dash-repeat-option>
 		</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">
 			<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'}]">
 				<option value=""></option>
 			</select>
 		</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>
 
 	<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
   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
     const nextSelectorStart = 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
     const datapoints: [number, number][] = s.datapoints;
-    const simpleMetric = query.trim().match(/^\w+$/);
-    if (simpleMetric && datapoints.length > 1) {
+    if (datapoints.length > 1) {
       let increasing = false;
-      const monotonic = datapoints.every((dp, index) => {
+      const monotonic = datapoints.filter(dp => dp[0] !== null).every((dp, index) => {
         if (index === 0) {
           return true;
         }
@@ -122,18 +121,25 @@ export function determineQueryHints(series: any[], datasource?: any): any[] {
         return dp[0] >= datapoints[index - 1][0];
       });
       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().',
             action: {
               type: 'ADD_RATE',
               query,
               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', () => {
       const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
       const hints = determineQueryHints(series);
@@ -351,6 +375,7 @@ describe('PrometheusDatasource', () => {
     expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe(
       'foo{bar="baz",instance="my-host.com:9100"}'
     );
+    expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])');
   });
 });