Browse Source

Merge branch 'master' into feature/enhance_hipchat_card

Joseph Weigl 8 years ago
parent
commit
185b0dcc05
45 changed files with 1618 additions and 459 deletions
  1. 19 2
      CHANGELOG.md
  2. 1 1
      README.md
  3. 10 12
      ROADMAP.md
  4. 2 1
      package.json
  5. 4 1
      pkg/api/avatar/avatar.go
  6. 10 12
      pkg/api/cloudwatch/metrics.go
  7. 88 18
      pkg/api/pluginproxy/ds_proxy.go
  8. 13 0
      pkg/api/pluginproxy/ds_proxy_test.go
  9. 11 5
      pkg/plugins/app_plugin.go
  10. 5 1
      pkg/tsdb/influxdb/query.go
  11. 1 1
      pkg/tsdb/influxdb/query_test.go
  12. 6 2
      pkg/tsdb/mysql/mysql.go
  13. 210 0
      public/app/core/components/code_editor/code_editor.ts
  14. 481 0
      public/app/core/components/code_editor/mode-prometheus.js
  15. 21 0
      public/app/core/components/code_editor/snippets/prometheus.js
  16. 116 0
      public/app/core/components/code_editor/theme-grafana-dark.js
  17. 3 1
      public/app/core/components/form_dropdown/form_dropdown.ts
  18. 1 0
      public/app/core/core.ts
  19. 11 7
      public/app/features/dashboard/export/export_modal.ts
  20. 1 1
      public/app/features/dashboard/partials/shareModal.html
  21. 19 14
      public/app/features/org/org_users_ctrl.ts
  22. 3 3
      public/app/features/org/partials/orgUsers.html
  23. 12 2
      public/app/features/plugins/ds_edit_ctrl.ts
  24. 1 1
      public/app/features/plugins/partials/ds_edit.html
  25. 5 0
      public/app/headers/common.d.ts
  26. 9 5
      public/app/plugins/datasource/elasticsearch/query_builder.js
  27. 6 6
      public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts
  28. 5 2
      public/app/plugins/datasource/influxdb/influx_query.ts
  29. 9 0
      public/app/plugins/datasource/influxdb/query_part.ts
  30. 5 5
      public/app/plugins/datasource/influxdb/specs/influx_query_specs.ts
  31. 3 2
      public/app/plugins/datasource/mysql/partials/query.editor.html
  32. 42 0
      public/app/plugins/datasource/prometheus/completer.ts
  33. 80 76
      public/app/plugins/datasource/prometheus/datasource.ts
  34. 4 12
      public/app/plugins/datasource/prometheus/partials/query.editor.html
  35. 12 15
      public/app/plugins/datasource/prometheus/query_ctrl.ts
  36. 5 0
      public/app/system.conf.js
  37. 1 0
      public/sass/_grafana.scss
  38. 79 0
      public/sass/components/_code_editor.scss
  39. 3 2
      public/sass/components/_modals.scss
  40. 5 0
      public/test/test-main.js
  41. 1 0
      tasks/options/copy.js
  42. 4 1
      tasks/options/watch.js
  43. 17 5
      tests/datasource-test/module.js
  44. 4 4
      tests/datasource-test/plugin.json
  45. 270 239
      yarn.lock

+ 19 - 2
CHANGELOG.md

@@ -1,18 +1,35 @@
 # 5.0.0 (unreleased)
 
+### WIP (in develop branch currently as its unstable or unfinished)
+- Dashboard folders
+- User groups
+- Dashboard permissions (on folder & dashboard level), permissions can be assigned to groups or individual users
+- UX changes to nav & side menu
+- New dashboard grid layout system
+
+# 4.5.0 (unreleased)
+
 ## New Features
 
-* **Table panel**: Render cell values as links that can use url that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754)
+* **Table panel**: Render cell values as links that can have an url template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754)
+* **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052).
 
 ## Enhancements
 
 * **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd)
 * **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboad time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055)
 * **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261)
- 
+- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095).
+
 ## Changes
 
 * **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros)
+* **InfluxDB**: Added paranthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131)
+
+## Bug Fixes
+
+* **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800)
+* **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510)
 
 # 4.4.4 (unreleased)
 

+ 1 - 1
README.md

@@ -1,4 +1,4 @@
-[Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana)
+[Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) [![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana)
 ================
 [Website](https://grafana.com) |
 [Twitter](https://twitter.com/grafana) |

+ 10 - 12
ROADMAP.md

@@ -1,31 +1,29 @@
-# Roadmap (2017-04-23)
+# Roadmap (2017-08-29)
 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 But it will give you an idea of our current vision and plan. 
 
 ### Short term (1-4 months)
 
- - New Heatmap Panel (Implemented and available in master)
- - Support for MySQL & Postgres as data sources (Work started and a alpha version for MySQL is available in master)
- - User Groups & Dashboard folders with ACLs (work started, not yet completed, https://github.com/grafana/grafana/issues/1611#issuecomment-287742633)
- - Improve new user UX
- - Improve docs
- - Support for alerting for Elasticsearch (can be tested in [branch](https://github.com/grafana/grafana/tree/alerting-elasticsearch) but needs more work)
-  - Graph annotations (create from grafana, region annotations, better annotation viz)
-  - Improve alerting (clustering, silence rules)
+ - Release Grafana v4.5 with fixes and minor enhancements 
+ - Release Grafana v5
+  - User groups
+  - Dashboard folders
+  - Dashboard permissions (on folders as well), permissions on groups or users
+  - New Dashboard layout engine
+  - New sidemenu & nav UX
+  - Elasticsearch alerting
   
 ### Long term 
 
-- Improved dashboard panel layout engine (to make it easier and enable more flexible layouts) 
 - Backend plugins to support more Auth options, Alerting data sources & notifications
 - Universial time series transformations for any data source (meta queries)
 - Reporting
 - Web socket & live data streams
-- Migrate to Angular2 
+- Migrate to Angular2 or react
 
 
 ### Outside contributions
 We know this is being worked on right now by contributors (and we hope to merge it when it's ready). 
 
-- Dashboard revisions (be able to revert dashboard changes)
 - Clustering for alert engine (load distribution)  

+ 2 - 1
package.json

@@ -4,7 +4,7 @@
     "company": "Coding Instinct AB"
   },
   "name": "grafana",
-  "version": "5.0.0-pre1",
+  "version": "4.5.0-pre1",
   "repository": {
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"
@@ -63,6 +63,7 @@
   },
   "license": "Apache-2.0",
   "dependencies": {
+    "ace-builds": "^1.2.8",
     "eventemitter3": "^2.0.2",
     "gaze": "^1.1.2",
     "grunt-jscs": "3.0.1",

+ 4 - 1
pkg/api/avatar/avatar.go

@@ -217,7 +217,10 @@ func (this *thunderTask) Fetch() {
 	this.Done()
 }
 
-var client = &http.Client{}
+var client *http.Client = &http.Client{
+	Timeout:   time.Second * 2,
+	Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
+}
 
 func (this *thunderTask) fetch() error {
 	this.Avatar.timestamp = time.Now()

+ 10 - 12
pkg/api/cloudwatch/metrics.go

@@ -91,7 +91,7 @@ func init() {
 		"AWS/SWF": {"DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "StartedDecisionTasksTimedOutOnClose", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut",
 			"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat"},
 		"AWS/VPN":        {"TunnelState", "TunnelDataIn", "TunnelDataOut"},
-		"AWS/WAF":        {"AllowedRequests", "BlockedRequests", "CountedRequests"},
+		"WAF":            {"AllowedRequests", "BlockedRequests", "CountedRequests"},
 		"AWS/WorkSpaces": {"Available", "Unhealthy", "ConnectionAttempt", "ConnectionSuccess", "ConnectionFailure", "SessionLaunchTime", "InSessionLatency", "SessionDisconnect"},
 		"KMS":            {"SecondsUntilKeyMaterialExpiration"},
 	}
@@ -133,7 +133,7 @@ func init() {
 		"AWS/StorageGateway":   {"GatewayId", "GatewayName", "VolumeId"},
 		"AWS/SWF":              {"Domain", "WorkflowTypeName", "WorkflowTypeVersion", "ActivityTypeName", "ActivityTypeVersion"},
 		"AWS/VPN":              {"VpnId", "TunnelIpAddress"},
-		"AWS/WAF":              {"Rule", "WebACL"},
+		"WAF":                  {"Rule", "WebACL"},
 		"AWS/WorkSpaces":       {"DirectoryId", "WorkspaceId"},
 		"KMS":                  {"KeyId"},
 	}
@@ -292,11 +292,6 @@ func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error)
 var metricsCacheLock sync.Mutex
 
 func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*datasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) {
-	result, err := getAllMetrics(dsInfo)
-	if err != nil {
-		return []string{}, err
-	}
-
 	metricsCacheLock.Lock()
 	defer metricsCacheLock.Unlock()
 
@@ -314,6 +309,10 @@ func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*data
 	if customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire.After(time.Now()) {
 		return customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache, nil
 	}
+	result, err := getAllMetrics(dsInfo)
+	if err != nil {
+		return []string{}, err
+	}
 	customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache = make([]string, 0)
 	customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire = time.Now().Add(5 * time.Minute)
 
@@ -330,11 +329,6 @@ func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*data
 var dimensionsCacheLock sync.Mutex
 
 func getDimensionsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*datasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) {
-	result, err := getAllMetrics(dsInfo)
-	if err != nil {
-		return []string{}, err
-	}
-
 	dimensionsCacheLock.Lock()
 	defer dimensionsCacheLock.Unlock()
 
@@ -352,6 +346,10 @@ func getDimensionsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*d
 	if customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire.After(time.Now()) {
 		return customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache, nil
 	}
+	result, err := getAllMetrics(dsInfo)
+	if err != nil {
+		return []string{}, err
+	}
 	customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Cache = make([]string, 0)
 	customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][dsInfo.Namespace].Expire = time.Now().Add(5 * time.Minute)
 

+ 88 - 18
pkg/api/pluginproxy/ds_proxy.go

@@ -2,15 +2,17 @@ package pluginproxy
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
-	"html/template"
 	"io/ioutil"
 	"net"
 	"net/http"
 	"net/http/httputil"
 	"net/url"
+	"strconv"
 	"strings"
+	"text/template"
 	"time"
 
 	"github.com/grafana/grafana/pkg/api/cloudwatch"
@@ -23,9 +25,20 @@ import (
 )
 
 var (
-	logger log.Logger = log.New("data-proxy-log")
+	logger log.Logger   = log.New("data-proxy-log")
+	client *http.Client = &http.Client{
+		Timeout:   time.Second * 30,
+		Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
+	}
+	tokenCache = map[int64]*jwtToken{}
 )
 
+type jwtToken struct {
+	ExpiresOn       time.Time `json:"-"`
+	ExpiresOnString string    `json:"expires_on"`
+	AccessToken     string    `json:"access_token"`
+}
+
 type DataSourceProxy struct {
 	ds        *m.DataSource
 	ctx       *middleware.Context
@@ -229,8 +242,6 @@ func checkWhiteList(c *middleware.Context, host string) bool {
 }
 
 func (proxy *DataSourceProxy) applyRoute(req *http.Request) {
-	logger.Info("ApplyDataSourceRouteRules", "route", proxy.route.Path, "proxyPath", proxy.proxyPath)
-
 	proxy.proxyPath = strings.TrimPrefix(proxy.proxyPath, proxy.route.Path)
 
 	data := templateData{
@@ -238,8 +249,6 @@ func (proxy *DataSourceProxy) applyRoute(req *http.Request) {
 		SecureJsonData: proxy.ds.SecureJsonData.Decrypt(),
 	}
 
-	logger.Info("Apply Route Rule", "rule", proxy.route.Path)
-
 	routeUrl, err := url.Parse(proxy.route.Url)
 	if err != nil {
 		logger.Error("Error parsing plugin route url")
@@ -254,25 +263,86 @@ func (proxy *DataSourceProxy) applyRoute(req *http.Request) {
 	if err := addHeaders(&req.Header, proxy.route, data); err != nil {
 		logger.Error("Failed to render plugin headers", "error", err)
 	}
+
+	if proxy.route.TokenAuth != nil {
+		if token, err := proxy.getAccessToken(data); err != nil {
+			logger.Error("Failed to get access token", "error", err)
+		} else {
+			req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+		}
+	}
+
+	logger.Info("Requesting", "url", req.URL.String())
 }
 
-func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error {
-	for _, header := range route.Headers {
-		var contentBuf bytes.Buffer
-		t, err := template.New("content").Parse(header.Content)
-		if err != nil {
-			return errors.New(fmt.Sprintf("could not parse header content template for header %s.", header.Name))
+func (proxy *DataSourceProxy) getAccessToken(data templateData) (string, error) {
+	if cachedToken, found := tokenCache[proxy.ds.Id]; found {
+		if cachedToken.ExpiresOn.After(time.Now().Add(time.Second * 10)) {
+			logger.Info("Using token from cache")
+			return cachedToken.AccessToken, nil
 		}
+	}
 
-		err = t.Execute(&contentBuf, data)
-		if err != nil {
-			return errors.New(fmt.Sprintf("failed to execute header content template for header %s.", header.Name))
+	urlInterpolated, err := interpolateString(proxy.route.TokenAuth.Url, data)
+	if err != nil {
+		return "", err
+	}
+
+	params := make(url.Values)
+	for key, value := range proxy.route.TokenAuth.Params {
+		if interpolatedParam, err := interpolateString(value, data); err != nil {
+			return "", err
+		} else {
+			params.Add(key, interpolatedParam)
 		}
+	}
 
-		value := contentBuf.String()
+	getTokenReq, _ := http.NewRequest("POST", urlInterpolated, bytes.NewBufferString(params.Encode()))
+	getTokenReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	getTokenReq.Header.Add("Content-Length", strconv.Itoa(len(params.Encode())))
 
-		logger.Info("Adding headers", "name", header.Name, "value", value)
-		reqHeaders.Add(header.Name, value)
+	resp, err := client.Do(getTokenReq)
+	if err != nil {
+		return "", err
+	}
+
+	defer resp.Body.Close()
+
+	var token jwtToken
+	if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
+		return "", err
+	}
+
+	expiresOnEpoch, _ := strconv.ParseInt(token.ExpiresOnString, 10, 64)
+	token.ExpiresOn = time.Unix(expiresOnEpoch, 0)
+	tokenCache[proxy.ds.Id] = &token
+
+	logger.Info("Got new access token", "ExpiresOn", token.ExpiresOn)
+	return token.AccessToken, nil
+}
+
+func interpolateString(text string, data templateData) (string, error) {
+	t, err := template.New("content").Parse(text)
+	if err != nil {
+		return "", errors.New(fmt.Sprintf("Could not parse template %s.", text))
+	}
+
+	var contentBuf bytes.Buffer
+	err = t.Execute(&contentBuf, data)
+	if err != nil {
+		return "", errors.New(fmt.Sprintf("Failed to execute template %s.", text))
+	}
+
+	return contentBuf.String(), nil
+}
+
+func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error {
+	for _, header := range route.Headers {
+		interpolated, err := interpolateString(header.Content, data)
+		if err != nil {
+			return err
+		}
+		reqHeaders.Add(header.Name, interpolated)
 	}
 
 	return nil

+ 13 - 0
pkg/api/pluginproxy/ds_proxy_test.go

@@ -148,5 +148,18 @@ func TestDSRouteRule(t *testing.T) {
 				So(queryVals["p"][0], ShouldEqual, "password")
 			})
 		})
+
+		Convey("When interpolating string", func() {
+			data := templateData{
+				SecureJsonData: map[string]string{
+					"Test": "0asd+asd",
+				},
+			}
+
+			interpolated, err := interpolateString("{{.SecureJsonData.Test}}", data)
+			So(err, ShouldBeNil)
+			So(interpolated, ShouldEqual, "0asd+asd")
+		})
+
 	})
 }

+ 11 - 5
pkg/plugins/app_plugin.go

@@ -23,11 +23,12 @@ type AppPlugin struct {
 }
 
 type AppPluginRoute struct {
-	Path    string                 `json:"path"`
-	Method  string                 `json:"method"`
-	ReqRole models.RoleType        `json:"reqRole"`
-	Url     string                 `json:"url"`
-	Headers []AppPluginRouteHeader `json:"headers"`
+	Path      string                 `json:"path"`
+	Method    string                 `json:"method"`
+	ReqRole   models.RoleType        `json:"reqRole"`
+	Url       string                 `json:"url"`
+	Headers   []AppPluginRouteHeader `json:"headers"`
+	TokenAuth *JwtTokenAuth          `json:"tokenAuth"`
 }
 
 type AppPluginRouteHeader struct {
@@ -35,6 +36,11 @@ type AppPluginRouteHeader struct {
 	Content string `json:"content"`
 }
 
+type JwtTokenAuth struct {
+	Url    string            `json:"url"`
+	Params map[string]string `json:"params"`
+}
+
 func (app *AppPlugin) Load(decoder *json.Decoder, pluginDir string) error {
 	if err := decoder.Decode(&app); err != nil {
 		return err

+ 5 - 1
pkg/tsdb/influxdb/query.go

@@ -151,8 +151,12 @@ func (query *Query) renderMeasurement() string {
 func (query *Query) renderWhereClause() string {
 	res := " WHERE "
 	conditions := query.renderTags()
-	res += strings.Join(conditions, " ")
 	if len(conditions) > 0 {
+		if len(conditions) > 1 {
+			res += "(" + strings.Join(conditions, " ") + ")"
+		} else {
+			res += conditions[0]
+		}
 		res += " AND "
 	}
 

+ 1 - 1
pkg/tsdb/influxdb/query_test.go

@@ -57,7 +57,7 @@ func TestInfluxdbQueryBuilder(t *testing.T) {
 
 			rawQuery, err := query.Build(queryContext)
 			So(err, ShouldBeNil)
-			So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE "hostname" = 'server1' OR "hostname" = 'server2' AND time > now() - 5m GROUP BY time(5s), "datacenter" fill(null)`)
+			So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE ("hostname" = 'server1' OR "hostname" = 'server2') AND time > now() - 5m GROUP BY time(5s), "datacenter" fill(null)`)
 		})
 
 		Convey("can build query with math part", func() {

+ 6 - 2
pkg/tsdb/mysql/mysql.go

@@ -1,6 +1,7 @@
 package mysql
 
 import (
+	"container/list"
 	"context"
 	"database/sql"
 	"fmt"
@@ -245,6 +246,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows)
 
 func (e MysqlExecutor) TransformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
 	pointsBySeries := make(map[string]*tsdb.TimeSeries)
+	seriesByQueryOrder := list.New()
 	columnNames, err := rows.Columns()
 
 	if err != nil {
@@ -282,11 +284,13 @@ func (e MysqlExecutor) TransformToTimeSeries(query *tsdb.Query, rows *core.Rows,
 			series := &tsdb.TimeSeries{Name: rowData.metric}
 			series.Points = append(series.Points, tsdb.TimePoint{rowData.value, rowData.time})
 			pointsBySeries[rowData.metric] = series
+			seriesByQueryOrder.PushBack(rowData.metric)
 		}
 	}
 
-	for _, value := range pointsBySeries {
-		result.Series = append(result.Series, value)
+	for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() {
+		key := elem.Value.(string)
+		result.Series = append(result.Series, pointsBySeries[key])
 	}
 
 	result.Meta.Set("rowCount", rowCount)

+ 210 - 0
public/app/core/components/code_editor/code_editor.ts

@@ -0,0 +1,210 @@
+/**
+ * codeEditor directive based on Ace code editor
+ * https://github.com/ajaxorg/ace
+ *
+ * Basic usage:
+ * <code-editor content="ctrl.target.query" on-change="ctrl.panelCtrl.refresh()"
+ *  data-mode="sql" data-show-gutter>
+ * </code-editor>
+ *
+ * Params:
+ * content:      Editor content.
+ * onChange:     Function called on content change (invoked on editor blur, ctrl+enter, not on every change).
+ * getCompleter: Function returned external completer. Completer is an object implemented getCompletions() method,
+ *               see Prometheus Data Source implementation for details.
+ *
+ * Some Ace editor options available via data-* attributes:
+ * data-mode               - Language mode (text, sql, javascript, etc.). Default is 'text'.
+ * data-theme              - Editor theme (eg 'solarized_dark').
+ * data-max-lines          - Max editor height in lines. Editor grows automatically from 1 to maxLines.
+ * data-show-gutter        - Show gutter (contains line numbers and additional info).
+ * data-tab-size           - Tab size, default is 2.
+ * data-behaviours-enabled - Specifies whether to use behaviors or not. "Behaviors" in this case is the auto-pairing of
+ *                           special characters, like quotation marks, parenthesis, or brackets.
+ *
+ * Keybindings:
+ * Ctrl-Enter (Command-Enter): run onChange() function
+ */
+
+///<reference path="../../../headers/common.d.ts" />
+import _ from 'lodash';
+import coreModule from 'app/core/core_module';
+import config from 'app/core/config';
+import ace from 'ace';
+
+const ACE_SRC_BASE = "public/vendor/npm/ace-builds/src-noconflict/";
+const DEFAULT_THEME_DARK = "grafana-dark";
+const DEFAULT_THEME_LIGHT = "textmate";
+const DEFAULT_MODE = "text";
+const DEFAULT_MAX_LINES = 10;
+const DEFAULT_TAB_SIZE = 2;
+const DEFAULT_BEHAVIOURS = true;
+
+const GRAFANA_MODULES = ['mode-prometheus', 'snippets-prometheus', 'theme-grafana-dark'];
+const GRAFANA_MODULE_BASE = "public/app/core/components/code_editor/";
+
+// Trick for loading additional modules
+function setModuleUrl(moduleType, name) {
+  let baseUrl = ACE_SRC_BASE;
+  let aceModeName = `ace/${moduleType}/${name}`;
+  let moduleName = `${moduleType}-${name}`;
+  let componentName = `${moduleName}.js`;
+
+  if (_.includes(GRAFANA_MODULES, moduleName)) {
+    baseUrl = GRAFANA_MODULE_BASE;
+  }
+
+  if (moduleType === 'snippets') {
+    componentName = `${moduleType}/${name}.js`;
+  }
+
+  ace.config.setModuleUrl(aceModeName, baseUrl + componentName);
+}
+
+setModuleUrl("ext", "language_tools");
+setModuleUrl("mode", "text");
+setModuleUrl("snippets", "text");
+
+let editorTemplate = `<div></div>`;
+
+function link(scope, elem, attrs) {
+  let lightTheme = config.bootData.user.lightTheme;
+  let default_theme = lightTheme ? DEFAULT_THEME_LIGHT : DEFAULT_THEME_DARK;
+
+  // Options
+  let langMode = attrs.mode || DEFAULT_MODE;
+  let maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
+  let showGutter = attrs.showGutter !== undefined;
+  let theme = attrs.theme || default_theme;
+  let tabSize = attrs.tabSize || DEFAULT_TAB_SIZE;
+  let behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS;
+
+  // Initialize editor
+  let aceElem = elem.get(0);
+  let codeEditor = ace.edit(aceElem);
+  let editorSession = codeEditor.getSession();
+
+  let editorOptions = {
+    maxLines: maxLines,
+    showGutter: showGutter,
+    tabSize: tabSize,
+    behavioursEnabled: behavioursEnabled,
+    highlightActiveLine: false,
+    showPrintMargin: false,
+    autoScrollEditorIntoView: true // this is needed if editor is inside scrollable page
+  };
+
+  // Set options
+  codeEditor.setOptions(editorOptions);
+  // disable depreacation warning
+  codeEditor.$blockScrolling = Infinity;
+  // Padding hacks
+  codeEditor.renderer.setScrollMargin(15, 15);
+  codeEditor.renderer.setPadding(10);
+
+  setThemeMode(theme);
+  setLangMode(langMode);
+  setEditorContent(scope.content);
+
+  // Add classes
+  elem.addClass("gf-code-editor");
+  let textarea = elem.find("textarea");
+  textarea.addClass('gf-form-input');
+
+  // Event handlers
+  editorSession.on('change', (e) => {
+    scope.$apply(() => {
+      let newValue = codeEditor.getValue();
+      scope.content = newValue;
+    });
+  });
+
+  // Sync with outer scope - update editor content if model has been changed from outside of directive.
+  scope.$watch('content', (newValue, oldValue) => {
+    let editorValue = codeEditor.getValue();
+    if (newValue !== editorValue && newValue !== oldValue) {
+      scope.$$postDigest(function() {
+        setEditorContent(newValue);
+      });
+    }
+  });
+
+  codeEditor.on('blur', () => {
+    scope.onChange();
+  });
+
+  scope.$on("$destroy", () => {
+    codeEditor.destroy();
+  });
+
+  // Keybindings
+  codeEditor.commands.addCommand({
+    name: 'executeQuery',
+    bindKey: {win: 'Ctrl-Enter', mac: 'Command-Enter'},
+    exec: () => {
+      scope.onChange();
+    }
+  });
+
+  function setLangMode(lang) {
+    let aceModeName = `ace/mode/${lang}`;
+    setModuleUrl("mode", lang);
+    setModuleUrl("snippets", lang);
+    editorSession.setMode(aceModeName);
+
+    ace.config.loadModule("ace/ext/language_tools", (language_tools) => {
+      codeEditor.setOptions({
+        enableBasicAutocompletion: true,
+        enableLiveAutocompletion: true,
+        enableSnippets: true
+      });
+
+      console.log('getting completer', lang);
+      if (scope.getCompleter()) {
+        // make copy of array as ace seems to share completers array between instances
+        codeEditor.completers = codeEditor.completers.slice();
+        codeEditor.completers.push(scope.getCompleter());
+      }
+    });
+  }
+
+  function setThemeMode(theme) {
+    setModuleUrl("theme", theme);
+    let themeModule = `ace/theme/${theme}`;
+    ace.config.loadModule(themeModule, (theme_module) => {
+      // Check is theme light or dark and fix if needed
+      let lightTheme = config.bootData.user.lightTheme;
+      let fixedTheme = theme;
+      if (lightTheme && theme_module.isDark) {
+        fixedTheme = DEFAULT_THEME_LIGHT;
+      } else if (!lightTheme && !theme_module.isDark) {
+        fixedTheme = DEFAULT_THEME_DARK;
+      }
+      setModuleUrl("theme", fixedTheme);
+      themeModule = `ace/theme/${fixedTheme}`;
+      codeEditor.setTheme(themeModule);
+
+      elem.addClass("gf-code-editor--theme-loaded");
+    });
+  }
+
+  function setEditorContent(value) {
+    codeEditor.setValue(value);
+    codeEditor.clearSelection();
+  }
+}
+
+export function codeEditorDirective() {
+  return {
+    restrict: 'E',
+    template: editorTemplate,
+    scope: {
+      content: "=",
+      onChange: "&",
+      getCompleter: "&"
+    },
+    link: link
+  };
+}
+
+coreModule.directive('codeEditor', codeEditorDirective);

+ 481 - 0
public/app/core/components/code_editor/mode-prometheus.js

@@ -0,0 +1,481 @@
+// jshint ignore: start
+// jscs: disable
+ace.define("ace/mode/prometheus_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../lib/oop");
+var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules;
+
+var PrometheusHighlightRules = function() {
+  var keywords = (
+    "by|without|keep_common|offset|bool|and|or|unless|ignoring|on|group_left|group_right|" +
+    "count|count_values|min|max|avg|sum|stddev|stdvar|bottomk|topk|quantile"
+  );
+
+  var builtinConstants = (
+    "true|false|null|__name__|job"
+  );
+
+  var builtinFunctions = (
+    "abs|absent|ceil|changes|clamp_max|clamp_min|count_scalar|day_of_month|day_of_week|days_in_month|delta|deriv|" + "drop_common_labels|exp|floor|histogram_quantile|holt_winters|hour|idelta|increase|irate|label_replace|ln|log2|" +
+    "log10|minute|month|predict_linear|rate|resets|round|scalar|sort|sort_desc|sqrt|time|vector|year|avg_over_time|" +
+    "min_over_time|max_over_time|sum_over_time|count_over_time|quantile_over_time|stddev_over_time|stdvar_over_time"
+  );
+
+  var keywordMapper = this.createKeywordMapper({
+    "support.function": builtinFunctions,
+    "keyword": keywords,
+    "constant.language": builtinConstants
+  }, "identifier", true);
+
+  this.$rules = {
+    "start" : [ {
+      token : "string", // single line
+      regex : /"(?:[^"\\]|\\.)*?"/
+    }, {
+      token : "string", // string
+      regex : "'.*?'"
+    }, {
+      token : "constant.numeric", // float
+      regex : "[-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b"
+    }, {
+      token : "constant.language", // time
+      regex : "\\d+[smhdwy]"
+    }, {
+      token : keywordMapper,
+      regex : "[a-zA-Z_$][a-zA-Z0-9_$]*\\b"
+    }, {
+      token : "keyword.operator",
+      regex : "\\+|\\-|\\*|\\/|%|\\^|=|==|!=|<=|>=|<|>|=\\~|!\\~"
+    }, {
+      token : "paren.lparen",
+      regex : "[[({]"
+    }, {
+      token : "paren.rparen",
+      regex : "[\\])}]"
+    }, {
+      token : "text",
+      regex : "\\s+"
+    } ]
+  };
+};
+
+oop.inherits(PrometheusHighlightRules, TextHighlightRules);
+
+exports.PrometheusHighlightRules = PrometheusHighlightRules;
+});
+
+ace.define("ace/mode/prometheus_completions",["require","exports","module","ace/token_iterator", "ace/lib/lang"], function(require, exports, module) {
+"use strict";
+
+var lang = require("../lib/lang");
+
+var prometheusKeyWords = [
+  "by", "without", "keep_common", "offset", "bool", "and", "or", "unless", "ignoring", "on", "group_left",
+  "group_right", "count", "count_values", "min", "max", "avg", "sum", "stddev", "stdvar", "bottomk", "topk", "quantile"
+];
+
+var keyWordsCompletions = prometheusKeyWords.map(function(word) {
+  return {
+    caption: word,
+    value: word,
+    meta: "keyword",
+    score: Number.MAX_VALUE
+  }
+});
+
+var prometheusFunctions = [
+  {
+    name: 'abs()', value: 'abs',
+    def: 'abs(v instant-vector)',
+    docText: 'Returns the input vector with all sample values converted to their absolute value.'
+  },
+  {
+    name: 'abs()', value: 'abs',
+    def: 'abs(v instant-vector)',
+    docText: 'Returns the input vector with all sample values converted to their absolute value.'
+  },
+  {
+    name: 'absent()', value: 'absent',
+    def: 'absent(v instant-vector)',
+    docText: 'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.'
+  },
+  {
+    name: 'ceil()', value: 'ceil',
+    def: 'ceil(v instant-vector)',
+    docText: 'Rounds the sample values of all elements in `v` up to the nearest integer.'
+  },
+  {
+    name: 'changes()', value: 'changes',
+    def: 'changes(v range-vector)',
+    docText: 'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.'
+  },
+  {
+    name: 'clamp_max()', value: 'clamp_max',
+    def: 'clamp_max(v instant-vector, max scalar)',
+    docText: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.'
+  },
+  {
+    name: 'clamp_min()', value: 'clamp_min',
+    def: 'clamp_min(v instant-vector, min scalar)',
+    docText: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.'
+  },
+  {
+    name: 'count_scalar()', value: 'count_scalar',
+    def: 'count_scalar(v instant-vector)',
+    docText: 'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.'
+  },
+  {
+    name: 'day_of_month()', value: 'day_of_month',
+    def: 'day_of_month(v=vector(time()) instant-vector)',
+    docText: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.'
+  },
+  {
+    name: 'day_of_week()', value: 'day_of_week',
+    def: 'day_of_week(v=vector(time()) instant-vector)',
+    docText: 'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.'
+  },
+  {
+    name: 'days_in_month()', value: 'days_in_month',
+    def: 'days_in_month(v=vector(time()) instant-vector)',
+    docText: 'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.'
+  },
+  {
+    name: 'delta()', value: 'delta',
+    def: 'delta(v range-vector)',
+    docText: 'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.'
+  },
+  {
+    name: 'deriv()', value: 'deriv',
+    def: 'deriv(v range-vector)',
+    docText: 'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.'
+  },
+  {
+    name: 'drop_common_labels()', value: 'drop_common_labels',
+    def: 'drop_common_labels(instant-vector)',
+    docText: 'Drops all labels that have the same name and value across all series in the input vector.'
+  },
+  {
+    name: 'exp()', value: 'exp',
+    def: 'exp(v instant-vector)',
+    docText: 'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`'
+  },
+  {
+    name: 'floor()', value: 'floor',
+    def: 'floor(v instant-vector)',
+    docText: 'Rounds the sample values of all elements in `v` down to the nearest integer.'
+  },
+  {
+    name: 'histogram_quantile()', value: 'histogram_quantile',
+    def: 'histogram_quantile(φ float, b instant-vector)',
+    docText: 'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.'
+  },
+  {
+    name: 'holt_winters()', value: 'holt_winters',
+    def: 'holt_winters(v range-vector, sf scalar, tf scalar)',
+    docText: 'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.'
+  },
+  {
+    name: 'hour()', value: 'hour',
+    def: 'hour(v=vector(time()) instant-vector)',
+    docText: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.'
+  },
+  {
+    name: 'idelta()', value: 'idelta',
+    def: 'idelta(v range-vector)',
+    docText: 'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.'
+  },
+  {
+    name: 'increase()', value: 'increase',
+    def: 'increase(v range-vector)',
+    docText: 'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.'
+  },
+  {
+    name: 'irate()', value: 'irate',
+    def: 'irate(v range-vector)',
+    docText: 'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.'
+  },
+  {
+    name: 'label_replace()', value: 'label_replace',
+    def: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)',
+    docText: 'For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)`  matches the regular expression `regex` against the label `src_label`.  If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn\'t match then the timeseries is returned unchanged.'
+  },
+  {
+    name: 'ln()', value: 'ln',
+    def: 'ln(v instant-vector)',
+    docText: 'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`'
+  },
+  {
+    name: 'log2()', value: 'log2',
+    def: 'log2(v instant-vector)',
+    docText: 'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.'
+  },
+  {
+    name: 'log10()', value: 'log10',
+    def: 'log10(v instant-vector)',
+    docText: 'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.'
+  },
+  {
+    name: 'minute()', value: 'minute',
+    def: 'minute(v=vector(time()) instant-vector)',
+    docText: 'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.'
+  },
+  {
+    name: 'month()', value: 'month',
+    def: 'month(v=vector(time()) instant-vector)',
+    docText: 'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.'
+  },
+  {
+    name: 'predict_linear()', value: 'predict_linear',
+    def: 'predict_linear(v range-vector, t scalar)',
+    docText: 'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.'
+  },
+  {
+    name: 'rate()', value: 'rate',
+    def: 'rate(v range-vector)',
+    docText: "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period."
+  },
+  {
+    name: 'resets()', value: 'resets',
+    def: 'resets(v range-vector)',
+    docText: 'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.'
+  },
+  {
+    name: 'round()', value: 'round',
+    def: 'round(v instant-vector, to_nearest=1 scalar)',
+    docText: 'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.'
+  },
+  {
+    name: 'scalar()', value: 'scalar',
+    def: 'scalar(v instant-vector)',
+    docText: 'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.'
+  },
+  {
+    name: 'sort()', value: 'sort',
+    def: 'sort(v instant-vector)',
+    docText: 'Returns vector elements sorted by their sample values, in ascending order.'
+  },
+  {
+    name: 'sort_desc()', value: 'sort_desc',
+    def: 'sort_desc(v instant-vector)',
+    docText: 'Returns vector elements sorted by their sample values, in descending order.'
+  },
+  {
+    name: 'sqrt()', value: 'sqrt',
+    def: 'sqrt(v instant-vector)',
+    docText: 'Calculates the square root of all elements in `v`.'
+  },
+  {
+    name: 'time()', value: 'time',
+    def: 'time()',
+    docText: 'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.'
+  },
+  {
+    name: 'vector()', value: 'vector',
+    def: 'vector(s scalar)',
+    docText: 'Returns the scalar `s` as a vector with no labels.'
+  },
+  {
+    name: 'year()', value: 'year',
+    def: 'year(v=vector(time()) instant-vector)',
+    docText: 'Returns the year for each of the given times in UTC.'
+  },
+  {
+    name: 'avg_over_time()', value: 'avg_over_time',
+    def: 'avg_over_time(range-vector)',
+    docText: 'The average value of all points in the specified interval.'
+  },
+  {
+    name: 'min_over_time()', value: 'min_over_time',
+    def: 'min_over_time(range-vector)',
+    docText: 'The minimum value of all points in the specified interval.'
+  },
+  {
+    name: 'max_over_time()', value: 'max_over_time',
+    def: 'max_over_time(range-vector)',
+    docText: 'The maximum value of all points in the specified interval.'
+  },
+  {
+    name: 'sum_over_time()', value: 'sum_over_time',
+    def: 'sum_over_time(range-vector)',
+    docText: 'The sum of all values in the specified interval.'
+  },
+  {
+    name: 'count_over_time()', value: 'count_over_time',
+    def: 'count_over_time(range-vector)',
+    docText: 'The count of all values in the specified interval.'
+  },
+  {
+    name: 'quantile_over_time()', value: 'quantile_over_time',
+    def: 'quantile_over_time(scalar, range-vector)',
+    docText: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.'
+  },
+  {
+    name: 'stddev_over_time()', value: 'stddev_over_time',
+    def: 'stddev_over_time(range-vector)',
+    docText: 'The population standard deviation of the values in the specified interval.'
+  },
+  {
+    name: 'stdvar_over_time()', value: 'stdvar_over_time',
+    def: 'stdvar_over_time(range-vector)',
+    docText: 'The population standard variance of the values in the specified interval.'
+  },
+];
+
+function wrapText(str, len) {
+  len = len || 60;
+  var lines = [];
+  var space_index = 0;
+  var line_start = 0;
+  var next_line_end = len;
+  var line = "";
+  for (var i = 0; i < str.length; i++) {
+    if (str[i] === ' ') {
+      space_index = i;
+    } else if (i >= next_line_end  && space_index != 0) {
+      line = str.slice(line_start, space_index);
+      lines.push(line);
+      line_start = space_index + 1;
+      next_line_end = i + len;
+      space_index = 0;
+    }
+  }
+  line = str.slice(line_start);
+  lines.push(line);
+  return lines.join("&nbsp<br>");
+}
+
+function convertMarkDownTags(text) {
+  text = text.replace(/```(.+)```/, "<pre>$1</pre>");
+  text = text.replace(/`([^`]+)`/, "<code>$1</code>");
+  return text;
+}
+
+function convertToHTML(item) {
+  var docText = lang.escapeHTML(item.docText);
+  docText = convertMarkDownTags(wrapText(docText, 40));
+  return [
+    "<b>", lang.escapeHTML(item.def), "</b>", "<hr></hr>", docText, "<br>&nbsp"
+  ].join("");
+}
+
+var functionsCompletions = prometheusFunctions.map(function(item) {
+  return {
+    caption: item.name,
+    value: item.value,
+    docHTML: convertToHTML(item),
+    meta: "function",
+    score: Number.MAX_VALUE
+  };
+});
+
+var PrometheusCompletions = function() {};
+
+(function() {
+  this.getCompletions = function(state, session, pos, prefix, callback) {
+    var completions = keyWordsCompletions.concat(functionsCompletions);
+    callback(null, completions);
+  };
+
+}).call(PrometheusCompletions.prototype);
+
+exports.PrometheusCompletions = PrometheusCompletions;
+});
+
+ace.define("ace/mode/behaviour/prometheus",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/mode/behaviour/cstyle","ace/token_iterator"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../../lib/oop");
+var Behaviour = require("../behaviour").Behaviour;
+var CstyleBehaviour = require("./cstyle").CstyleBehaviour;
+var TokenIterator = require("../../token_iterator").TokenIterator;
+
+function getWrapped(selection, selected, opening, closing) {
+  var rowDiff = selection.end.row - selection.start.row;
+  return {
+    text: opening + selected + closing,
+    selection: [
+      0,
+      selection.start.column + 1,
+      rowDiff,
+      selection.end.column + (rowDiff ? 0 : 1)
+    ]
+  };
+};
+
+var PrometheusBehaviour = function () {
+  this.inherit(CstyleBehaviour);
+
+  // Rewrite default CstyleBehaviour for {} braces
+  this.add("braces", "insertion", function(state, action, editor, session, text) {
+    if (text == '{') {
+      var selection = editor.getSelectionRange();
+      var selected = session.doc.getTextRange(selection);
+      if (selected !== "" && editor.getWrapBehavioursEnabled()) {
+        return getWrapped(selection, selected, '{', '}');
+      } else if (CstyleBehaviour.isSaneInsertion(editor, session)) {
+        return {
+          text: '{}',
+          selection: [1, 1]
+        };
+      }
+    } else if (text == '}') {
+      var cursor = editor.getCursorPosition();
+      var line = session.doc.getLine(cursor.row);
+      var rightChar = line.substring(cursor.column, cursor.column + 1);
+      if (rightChar == '}') {
+        var matching = session.$findOpeningBracket('}', {column: cursor.column + 1, row: cursor.row});
+        if (matching !== null && CstyleBehaviour.isAutoInsertedClosing(cursor, line, text)) {
+          return {
+            text: '',
+            selection: [1, 1]
+          };
+        }
+      }
+    }
+  });
+
+  this.add("braces", "deletion", function(state, action, editor, session, range) {
+    var selected = session.doc.getTextRange(range);
+    if (!range.isMultiLine() && selected == '{') {
+      var line = session.doc.getLine(range.start.row);
+      var rightChar = line.substring(range.start.column + 1, range.start.column + 2);
+      if (rightChar == '}') {
+        range.end.column++;
+        return range;
+      }
+    }
+  });
+
+}
+oop.inherits(PrometheusBehaviour, CstyleBehaviour);
+
+exports.PrometheusBehaviour = PrometheusBehaviour;
+});
+
+ace.define("ace/mode/prometheus",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/prometheus_highlight_rules"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../lib/oop");
+var TextMode = require("./text").Mode;
+var PrometheusHighlightRules = require("./prometheus_highlight_rules").PrometheusHighlightRules;
+var PrometheusCompletions = require("./prometheus_completions").PrometheusCompletions;
+var PrometheusBehaviour = require("./behaviour/prometheus").PrometheusBehaviour;
+
+var Mode = function() {
+  this.HighlightRules = PrometheusHighlightRules;
+  this.$behaviour = new PrometheusBehaviour();
+  this.$completer = new PrometheusCompletions();
+  // replace keyWordCompleter
+  this.completer = this.$completer;
+};
+oop.inherits(Mode, TextMode);
+
+(function() {
+
+  this.$id = "ace/mode/prometheus";
+}).call(Mode.prototype);
+
+exports.Mode = Mode;
+
+});

+ 21 - 0
public/app/core/components/code_editor/snippets/prometheus.js

@@ -0,0 +1,21 @@
+// jshint ignore: start
+// jscs: disable
+ace.define("ace/snippets/prometheus",["require","exports","module"], function(require, exports, module) {
+"use strict";
+
+// exports.snippetText = "# rate\n\
+// snippet r\n\
+//   rate(${1:metric}[${2:range}])\n\
+// ";
+
+exports.snippets = [
+  {
+    "content": "rate(${1:metric}[${2:range}])",
+    "name": "rate()",
+    "scope": "prometheus",
+    "tabTrigger": "r"
+  }
+];
+
+exports.scope = "prometheus";
+});

+ 116 - 0
public/app/core/components/code_editor/theme-grafana-dark.js

@@ -0,0 +1,116 @@
+/* jshint ignore:start */
+
+ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"], function(require, exports, module) {
+  "use strict";
+
+  exports.isDark = true;
+  exports.cssClass = "gf-code-dark";
+  exports.cssText = ".gf-code-dark .ace_gutter {\
+  background: #2f3129;\
+  color: #8f908a\
+  }\
+  .gf-code-dark .ace_print-margin {\
+  width: 1px;\
+  background: #555651\
+  }\
+  .gf-code-dark {\
+  background-color: #111;\
+  color: #e0e0e0\
+  }\
+  .gf-code-dark .ace_cursor {\
+  color: #f8f8f0\
+  }\
+  .gf-code-dark .ace_marker-layer .ace_selection {\
+  background: #49483e\
+  }\
+  .gf-code-dark.ace_multiselect .ace_selection.ace_start {\
+  box-shadow: 0 0 3px 0px #272822;\
+  }\
+  .gf-code-dark .ace_marker-layer .ace_step {\
+  background: rgb(102, 82, 0)\
+  }\
+  .gf-code-dark .ace_marker-layer .ace_bracket {\
+  margin: -1px 0 0 -1px;\
+  border: 1px solid #49483e\
+  }\
+  .gf-code-dark .ace_marker-layer .ace_active-line {\
+  background: #202020\
+  }\
+  .gf-code-dark .ace_gutter-active-line {\
+  background-color: #272727\
+  }\
+  .gf-code-dark .ace_marker-layer .ace_selected-word {\
+  border: 1px solid #49483e\
+  }\
+  .gf-code-dark .ace_invisible {\
+  color: #52524d\
+  }\
+  .gf-code-dark .ace_entity.ace_name.ace_tag,\
+  .gf-code-dark .ace_keyword,\
+  .gf-code-dark .ace_meta.ace_tag,\
+  .gf-code-dark .ace_storage {\
+  color: #66d9ef\
+  }\
+  .gf-code-dark .ace_punctuation,\
+  .gf-code-dark .ace_punctuation.ace_tag {\
+  color: #fff\
+  }\
+  .gf-code-dark .ace_constant.ace_character,\
+  .gf-code-dark .ace_constant.ace_language,\
+  .gf-code-dark .ace_constant.ace_numeric,\
+  .gf-code-dark .ace_constant.ace_other {\
+  color: #fe85fc\
+  }\
+  .gf-code-dark .ace_invalid {\
+  color: #f8f8f0;\
+  background-color: #f92672\
+  }\
+  .gf-code-dark .ace_invalid.ace_deprecated {\
+  color: #f8f8f0;\
+  background-color: #ae81ff\
+  }\
+  .gf-code-dark .ace_support.ace_constant,\
+  .gf-code-dark .ace_support.ace_function {\
+  color: #59e6e3\
+  }\
+  .gf-code-dark .ace_fold {\
+  background-color: #a6e22e;\
+  border-color: #f8f8f2\
+  }\
+  .gf-code-dark .ace_storage.ace_type,\
+  .gf-code-dark .ace_support.ace_class,\
+  .gf-code-dark .ace_support.ace_type {\
+  font-style: italic;\
+  color: #66d9ef\
+  }\
+  .gf-code-dark .ace_entity.ace_name.ace_function,\
+  .gf-code-dark .ace_entity.ace_other,\
+  .gf-code-dark .ace_entity.ace_other.ace_attribute-name,\
+  .gf-code-dark .ace_variable {\
+  color: #a6e22e\
+  }\
+  .gf-code-dark .ace_variable.ace_parameter {\
+  font-style: italic;\
+  color: #fd971f\
+  }\
+  .gf-code-dark .ace_string {\
+  color: #74e680\
+  }\
+  .gf-code-dark .ace_paren {\
+    color: #f0a842\
+  }\
+  .gf-code-dark .ace_operator {\
+    color: #FFF\
+  }\
+  .gf-code-dark .ace_comment {\
+  color: #75715e\
+  }\
+  .gf-code-dark .ace_indent-guide {\
+  background: url(data:image/png;base64,ivborw0kggoaaaansuheugaaaaeaaaaccayaaaczgbynaaaaekleqvqimwpq0fd0zxbzd/wpaajvaoxesgneaaaaaelftksuqmcc) right repeat-y\
+  }";
+
+  var dom = require("../lib/dom");
+  dom.importCssString(exports.cssText, exports.cssClass);
+});
+
+/* jshint ignore:end */

+ 3 - 1
public/app/core/components/form_dropdown/form_dropdown.ts

@@ -115,7 +115,9 @@ export class FormDropdownCtrl {
       this.optionCache = options;
 
       // extract texts
-      let optionTexts = _.map(options, 'text');
+      let optionTexts = _.map(options, op => {
+        return _.escape(op.text);
+      });
 
       // add custom values
       if (this.allowCustom) {

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

@@ -19,6 +19,7 @@ import "./directives/diff-view";
 import './jquery_extended';
 import './partials';
 import './components/jsontree/jsontree';
+import './components/code_editor/code_editor';
 
 import {grafanaAppDirective} from './components/grafana_app';
 import {sideMenuDirective} from './components/sidemenu/sidemenu';

+ 11 - 7
public/app/features/dashboard/export/export_modal.ts

@@ -12,13 +12,14 @@ import {DashboardExporter} from './exporter';
 export class DashExportCtrl {
   dash: any;
   exporter: DashboardExporter;
+  dismiss: () => void;
 
   /** @ngInject */
-  constructor(private backendSrv, dashboardSrv, datasourceSrv, $scope) {
+  constructor(private backendSrv, private dashboardSrv, datasourceSrv, private $scope) {
     this.exporter = new DashboardExporter(datasourceSrv);
 
-    this.exporter.makeExportable(dashboardSrv.getCurrent()).then(dash => {
-      $scope.$apply(() => {
+    this.exporter.makeExportable(this.dashboardSrv.getCurrent()).then(dash => {
+      this.$scope.$apply(() => {
         this.dash = dash;
       });
     });
@@ -31,11 +32,13 @@ export class DashExportCtrl {
   }
 
   saveJson() {
-    var html = angular.toJson(this.dash, true);
-    var uri = "data:application/json," + encodeURIComponent(html);
-    var newWindow = window.open(uri);
-  }
+    var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
 
+    this.$scope.$root.appEvent('show-json-editor', {
+      object: clone,
+    });
+    this.dismiss();
+  }
 }
 
 export function dashExportDirective() {
@@ -45,6 +48,7 @@ export function dashExportDirective() {
     controller: DashExportCtrl,
     bindToController: true,
     controllerAs: 'ctrl',
+    scope: {dismiss: "&"}
   };
 }
 

+ 1 - 1
public/app/features/dashboard/partials/shareModal.html

@@ -47,7 +47,7 @@
 </script>
 
 <script type="text/ng-template" id="shareExport.html">
-	<dash-export-modal></dash-export-modal>
+	<dash-export-modal dismiss="dismiss()"></dash-export-modal>
 </script>
 
 <script type="text/ng-template" id="shareLinkOptions.html">

+ 19 - 14
public/app/features/org/org_users_ctrl.ts

@@ -92,20 +92,25 @@ export class OrgUsersCtrl {
     evt.stopPropagation();
   }
 
-  openAddUsersView() {
-    var modalScope = this.$scope.$new();
-    modalScope.invitesSent = this.get.bind(this);
-
-    var src = config.disableLoginForm
-      ? 'public/app/features/org/partials/add_user.html'
-      : 'public/app/features/org/partials/invite.html';
-
-    this.$scope.appEvent('show-modal', {
-      src: src,
-      modalClass: 'invite-modal',
-      scope: modalScope
-    });
-  }
+ getInviteUrl(invite) {
+   return invite.url;
+ }
+
+ openAddUsersView() {
+   var modalScope = this.$scope.$new();
+   modalScope.invitesSent = this.get.bind(this);
+
+   var src = config.disableLoginForm
+     ? 'public/app/features/org/partials/add_user.html'
+     : 'public/app/features/org/partials/invite.html';
+
+     this.$scope.appEvent('show-modal', {
+       src: src,
+       modalClass: 'invite-modal',
+       scope: modalScope
+     });
+ }
+
 }
 
 coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);

+ 3 - 3
public/app/features/org/partials/orgUsers.html

@@ -22,7 +22,7 @@
 						Users ({{ctrl.users.length}})
 					</a>
 				</li>
-				<li class="gf-tabs-item" ng-show="ctrl.showInviteUI">
+				<li class="gf-tabs-item" ng-show="ctrl.pendingInvites.length">
 					<a class="gf-tabs-link" ng-click="ctrl.editor.index = 1" ng-class="{active: ctrl.editor.index === 1}">
 						Pending Invitations ({{ctrl.pendingInvites.length}})
 					</a>
@@ -66,7 +66,7 @@
     </table>
   </div>
 
-  <div ng-if="ctrl.editor.index === 1 && ctrl.showInviteUI">
+  <div ng-if="ctrl.editor.index === 1">
     <table class="filter-table form-inline">
       <thead>
         <tr>
@@ -80,7 +80,7 @@
           <td>{{invite.email}}</td>
           <td>{{invite.name}}</td>
           <td class="text-right">
-            <button class="btn btn-inverse btn-mini " data-clipboard-text="{{invite.url}}" clipboard-button ng-click="ctrl.copyInviteToClipboard($event)">
+            <button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
               <i class="fa fa-clipboard"></i> Copy Invite
             </button>
             &nbsp;

+ 12 - 2
public/app/features/plugins/ds_edit_ctrl.ts

@@ -58,7 +58,7 @@ export class DataSourceEditCtrl {
   }
 
   initNewDatasourceModel() {
-    this.current = angular.copy(defaults);
+    this.current = _.cloneDeep(defaults);
 
     // We are coming from getting started
     if (this.$location.search().gettingstarted) {
@@ -93,11 +93,21 @@ export class DataSourceEditCtrl {
     });
   }
 
+  userChangedType() {
+    // reset model but keep name & default flag
+    this.current = _.defaults({
+      id: this.current.id,
+      name: this.current.name,
+      isDefault: this.current.isDefault,
+      type: this.current.type,
+    }, _.cloneDeep(defaults));
+    this.typeChanged();
+  }
+
   typeChanged() {
     this.hasDashboards = false;
     return this.backendSrv.get('/api/plugins/' + this.current.type + '/settings').then(pluginInfo => {
       this.datasourceMeta = pluginInfo;
-      console.log(this.datasourceMeta) ;
       this.hasDashboards = _.find(pluginInfo.includes, {type: 'dashboard'});
     });
   }

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

@@ -42,7 +42,7 @@
 				<div class="gf-form">
 					<span class="gf-form-label width-7">Type</span>
 					<div class="gf-form-select-wrapper max-width-23">
-						<select class="gf-form-input" ng-model="ctrl.current.type" ng-options="v.id as v.name for v in ctrl.types" ng-change="ctrl.typeChanged()"></select>
+						<select class="gf-form-input" ng-model="ctrl.current.type" ng-options="v.id as v.name for v in ctrl.types" ng-change="ctrl.userChangedType()"></select>
 					</div>
 				</div>
 			</div>

+ 5 - 0
public/app/headers/common.d.ts

@@ -72,3 +72,8 @@ declare module 'd3' {
   var d3: any;
   export default d3;
 }
+
+declare module 'ace' {
+  var ace: any;
+  export default ace;
+}

+ 9 - 5
public/app/plugins/datasource/elasticsearch/query_builder.js

@@ -1,7 +1,8 @@
 define([
-  './query_def'
+  './query_def',
+  'lodash',
 ],
-function (queryDef) {
+function (queryDef, _) {
   'use strict';
 
   function ElasticQueryBuilder(options) {
@@ -133,17 +134,20 @@ function (queryDef) {
       return;
     }
 
-    var i, filter, condition;
+    var i, filter, condition, queryCondition;
     for (i = 0; i < adhocFilters.length; i++) {
       filter = adhocFilters[i];
       condition = {};
       condition[filter.key] = filter.value;
+      queryCondition = {};
+      queryCondition[filter.key] = {query: filter.value};
+
       switch(filter.operator){
         case "=":
-          query.query.bool.filter.push({"term": condition});
+          _.set(query, "query.bool.must.match_phrase", queryCondition);
           break;
         case "!=":
-          query.query.bool.filter.push({"bool": {"must_not": {"term": condition}}});
+          _.set(query, "query.bool.must_not.match_phrase", queryCondition);
           break;
         case "<":
           condition[filter.key] = {"lt": filter.value};

+ 6 - 6
public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts

@@ -289,11 +289,11 @@ describe('ElasticQueryBuilder', function() {
       {key: 'key6', operator: '!~', value: 'value6'},
     ]);
 
-    expect(query.query.bool.filter[2].term["key1"]).to.be("value1");
-    expect(query.query.bool.filter[3].bool.must_not.term["key2"]).to.be("value2");
-    expect(query.query.bool.filter[4].range["key3"].lt).to.be("value3");
-    expect(query.query.bool.filter[5].range["key4"].gt).to.be("value4");
-    expect(query.query.bool.filter[6].regexp["key5"]).to.be("value5");
-    expect(query.query.bool.filter[7].bool.must_not.regexp["key6"]).to.be("value6");
+    expect(query.query.bool.must.match_phrase["key1"].query).to.be("value1");
+    expect(query.query.bool.must_not.match_phrase["key2"].query).to.be("value2");
+    expect(query.query.bool.filter[2].range["key3"].lt).to.be("value3");
+    expect(query.query.bool.filter[3].range["key4"].gt).to.be("value4");
+    expect(query.query.bool.filter[4].regexp["key5"]).to.be("value5");
+    expect(query.query.bool.filter[5].bool.must_not.regexp["key6"]).to.be("value6");
   });
 });

+ 5 - 2
public/app/plugins/datasource/influxdb/influx_query.ts

@@ -229,8 +229,11 @@ export default class InfluxQuery {
       return this.renderTagCondition(tag, index, interpolate);
     });
 
-    query += conditions.join(' ');
-    query += (conditions.length > 0 ? ' AND ' : '') + '$timeFilter';
+    if (conditions.length > 0) {
+      query += '(' + conditions.join(' ') + ') AND ';
+    }
+
+    query += '$timeFilter';
 
     var groupBySection = "";
     for (i = 0; i < this.groupByParts.length; i++) {

+ 9 - 0
public/app/plugins/datasource/influxdb/query_part.ts

@@ -230,6 +230,15 @@ register({
   renderer: functionRenderer,
 });
 
+register({
+  type: 'non_negative_difference',
+  addStrategy: addTransformationStrategy,
+  category: categories.Transformations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
 register({
   type: 'moving_average',
   addStrategy: addTransformationStrategy,

+ 5 - 5
public/app/plugins/datasource/influxdb/specs/influx_query_specs.ts

@@ -57,7 +57,7 @@ describe('InfluxQuery', function() {
 
       var queryText = query.render();
 
-      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "hostname" = \'server\\\\1\' AND $timeFilter'
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE ("hostname" = \'server\\\\1\') AND $timeFilter'
                           + ' GROUP BY time($__interval)');
     });
 
@@ -69,7 +69,7 @@ describe('InfluxQuery', function() {
       }, templateSrv, {});
 
       var queryText = query.render();
-      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "app" =~ /e.*/ AND $timeFilter GROUP BY time($__interval)');
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE ("app" =~ /e.*/) AND $timeFilter GROUP BY time($__interval)');
     });
   });
 
@@ -82,7 +82,7 @@ describe('InfluxQuery', function() {
       }, templateSrv, {});
 
       var queryText = query.render();
-      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "hostname" = \'server1\' AND "app" = \'email\' AND ' +
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE ("hostname" = \'server1\' AND "app" = \'email\') AND ' +
                           '$timeFilter GROUP BY time($__interval)');
     });
   });
@@ -96,7 +96,7 @@ describe('InfluxQuery', function() {
       }, templateSrv, {});
 
       var queryText = query.render();
-      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "hostname" = \'server1\' OR "hostname" = \'server2\' AND ' +
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE ("hostname" = \'server1\' OR "hostname" = \'server2\') AND ' +
                           '$timeFilter GROUP BY time($__interval)');
     });
   });
@@ -110,7 +110,7 @@ describe('InfluxQuery', function() {
       }, templateSrv, {});
 
       var queryText = query.render();
-      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "value" > 5 AND $timeFilter');
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE ("value" > 5) AND $timeFilter');
     });
   });
 

+ 3 - 2
public/app/plugins/datasource/mysql/partials/query.editor.html

@@ -1,7 +1,8 @@
 <query-editor-row query-ctrl="ctrl" can-collapse="false">
-  <div class="gf-form-inline">
+	<div class="gf-form-inline">
 		<div class="gf-form gf-form--grow">
-			<textarea rows="10" class="gf-form-input" ng-model="ctrl.target.rawSql" spellcheck="false" placeholder="query expression" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.panelCtrl.refresh()"></textarea>
+			<code-editor content="ctrl.target.rawSql" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
+			</code-editor>
 		</div>
 	</div>
 

+ 42 - 0
public/app/plugins/datasource/prometheus/completer.ts

@@ -0,0 +1,42 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import {PrometheusDatasource} from "./datasource";
+
+export class PromCompleter {
+  identifierRegexps = [/[\[\]a-zA-Z_0-9=]/];
+
+  constructor(private datasource: PrometheusDatasource) {
+  }
+
+  getCompletions(editor, session, pos, prefix, callback) {
+    if (prefix === '[') {
+      var vectors = [];
+      for (let unit of ['s', 'm', 'h']) {
+        for (let value of [1,5,10,30]) {
+         vectors.push({caption: value+unit, value: '['+value+unit, meta: 'range vector'});
+        }
+      }
+      callback(null, vectors);
+      return;
+    }
+
+    var query = prefix;
+    var line = editor.session.getLine(pos.row);
+
+    return this.datasource.performSuggestQuery(query).then(metricNames => {
+      callback(null, metricNames.map(name => {
+        let value = name;
+        if (prefix === '(') {
+          value = '(' + name;
+        }
+
+        return {
+          caption: name,
+          value: value,
+          meta: 'metric',
+        };
+      }));
+    });
+  }
+
+}

+ 80 - 76
public/app/plugins/datasource/prometheus/datasource.ts

@@ -11,18 +11,37 @@ import TableModel from 'app/core/table_model';
 
 var durationSplitRegexp = /(\d+)(ms|s|m|h|d|w|M|y)/;
 
-/** @ngInject */
-export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateSrv, timeSrv) {
-  this.type = 'prometheus';
-  this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
-  this.name = instanceSettings.name;
-  this.supportMetrics = true;
-  this.url = instanceSettings.url;
-  this.directUrl = instanceSettings.directUrl;
-  this.basicAuth = instanceSettings.basicAuth;
-  this.withCredentials = instanceSettings.withCredentials;
-
-  this._request = function(method, url, requestId) {
+function prometheusSpecialRegexEscape(value) {
+  return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
+}
+
+export class PrometheusDatasource {
+  type: string;
+  editorSrc: string;
+  name: string;
+  supportMetrics: boolean;
+  url: string;
+  directUrl: string;
+  basicAuth: any;
+  withCredentials: any;
+
+  /** @ngInject */
+  constructor(instanceSettings,
+              private $q,
+              private backendSrv,
+              private templateSrv,
+              private timeSrv) {
+    this.type = 'prometheus';
+    this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
+    this.name = instanceSettings.name;
+    this.supportMetrics = true;
+    this.url = instanceSettings.url;
+    this.directUrl = instanceSettings.directUrl;
+    this.basicAuth = instanceSettings.basicAuth;
+    this.withCredentials = instanceSettings.withCredentials;
+  }
+
+  _request(method, url, requestId?) {
     var options: any = {
       url: this.url + url,
       method: method,
@@ -32,20 +51,17 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
     if (this.basicAuth || this.withCredentials) {
       options.withCredentials = true;
     }
+
     if (this.basicAuth) {
       options.headers = {
         "Authorization": this.basicAuth
       };
     }
 
-    return backendSrv.datasourceRequest(options);
-  };
-
-  function prometheusSpecialRegexEscape(value) {
-    return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
+    return this.backendSrv.datasourceRequest(options);
   }
 
-  this.interpolateQueryExpr = function(value, variable, defaultFormatFn) {
+  interpolateQueryExpr(value, variable, defaultFormatFn) {
     // if no multi or include all do not regexEscape
     if (!variable.multi && !variable.includeAll) {
       return value;
@@ -57,14 +73,13 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
 
     var escapedValues = _.map(value, prometheusSpecialRegexEscape);
     return escapedValues.join('|');
-  };
+  }
 
-  this.targetContainsTemplate = function(target) {
-    return templateSrv.variableExists(target.expr);
-  };
+  targetContainsTemplate(target) {
+    return this.templateSrv.variableExists(target.expr);
+  }
 
-  // Called once per panel (graph)
-  this.query = function(options) {
+  query(options) {
     var self = this;
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
@@ -82,10 +97,10 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
       activeTargets.push(target);
 
       var query: any = {};
-      query.expr = templateSrv.replace(target.expr, options.scopedVars, self.interpolateQueryExpr);
+      query.expr = this.templateSrv.replace(target.expr, options.scopedVars, self.interpolateQueryExpr);
       query.requestId = options.panelId + target.refId;
 
-      var interval = templateSrv.replace(target.interval, options.scopedVars) || options.interval;
+      var interval = this.templateSrv.replace(target.interval, options.scopedVars) || options.interval;
       var intervalFactor = target.intervalFactor || 1;
       target.step = query.step = this.calculateInterval(interval, intervalFactor);
       var range = Math.ceil(end - start);
@@ -95,14 +110,14 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
 
     // No valid targets, return the empty result to save a round trip.
     if (_.isEmpty(queries)) {
-      return $q.when({ data: [] });
+      return this.$q.when({ data: [] });
     }
 
     var allQueryPromise = _.map(queries, query => {
       return this.performTimeSeriesQuery(query, start, end);
     });
 
-    return $q.all(allQueryPromise).then(responseList => {
+    return this.$q.all(allQueryPromise).then(responseList => {
       var result = [];
       var index = 0;
 
@@ -122,27 +137,27 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
 
       return { data: result };
     });
-  };
+  }
 
-  this.adjustStep = function(step, autoStep, range) {
+  adjustStep(step, autoStep, range) {
     // Prometheus drop query if range/step > 11000
     // calibrate step if it is too big
     if (step !== 0 && range / step > 11000) {
-      return Math.ceil(range / 11000);
+      step = Math.ceil(range / 11000);
     }
     return Math.max(step, autoStep);
-  };
+  }
 
-  this.performTimeSeriesQuery = function(query, start, end) {
+  performTimeSeriesQuery(query, start, end) {
     if (start > end) {
       throw { message: 'Invalid time range' };
     }
 
     var url = '/api/v1/query_range?query=' + encodeURIComponent(query.expr) + '&start=' + start + '&end=' + end + '&step=' + query.step;
     return this._request('GET', url, query.requestId);
-  };
+  }
 
-  this.performSuggestQuery = function(query) {
+  performSuggestQuery(query) {
     var url = '/api/v1/label/__name__/values';
 
     return this._request('GET', url).then(function(result) {
@@ -150,41 +165,30 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
         return metricName.indexOf(query) !== 1;
       });
     });
-  };
-
-  this.metricFindQuery = function(query) {
-    if (!query) { return $q.when([]); }
+  }
 
-    var interpolated;
-    try {
-      interpolated = templateSrv.replace(query, {}, this.interpolateQueryExpr);
-    } catch (err) {
-      return $q.reject(err);
-    }
+  metricFindQuery(query) {
+    if (!query) { return this.$q.when([]); }
 
-    var metricFindQuery = new PrometheusMetricFindQuery(this, interpolated, timeSrv);
+    let interpolated = this.templateSrv.replace(query, {}, this.interpolateQueryExpr);
+    var metricFindQuery = new PrometheusMetricFindQuery(this, interpolated, this.timeSrv);
     return metricFindQuery.process();
-  };
+  }
 
-  this.annotationQuery = function(options) {
+  annotationQuery(options) {
     var annotation = options.annotation;
     var expr = annotation.expr || '';
     var tagKeys = annotation.tagKeys || '';
     var titleFormat = annotation.titleFormat || '';
     var textFormat = annotation.textFormat || '';
 
-    if (!expr) { return $q.when([]); }
+    if (!expr) { return this.$q.when([]); }
 
-    var interpolated;
-    try {
-      interpolated = templateSrv.replace(expr, {}, this.interpolateQueryExpr);
-    } catch (err) {
-      return $q.reject(err);
-    }
+    var interpolated = this.templateSrv.replace(expr, {}, this.interpolateQueryExpr);
 
     var step = '60s';
     if (annotation.step) {
-      step = templateSrv.replace(annotation.step);
+      step = this.templateSrv.replace(annotation.step);
     }
 
     var start = this.getPrometheusTime(options.range.from, false);
@@ -222,19 +226,19 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
 
       return eventList;
     });
-  };
+  }
 
-  this.testDatasource = function() {
+  testDatasource() {
     return this.metricFindQuery('metrics(.*)').then(function() {
       return { status: 'success', message: 'Data source is working', title: 'Success' };
     });
-  };
+  }
 
-  this.calculateInterval = function(interval, intervalFactor) {
+  calculateInterval(interval, intervalFactor) {
     return Math.ceil(this.intervalSeconds(interval) * intervalFactor);
-  };
+  }
 
-  this.intervalSeconds = function(interval) {
+  intervalSeconds(interval) {
     var m = interval.match(durationSplitRegexp);
     var dur = moment.duration(parseInt(m[1]), m[2]);
     var sec = dur.asSeconds();
@@ -243,9 +247,9 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
     }
 
     return sec;
-  };
+  }
 
-  this.transformMetricData = function(md, options, start, end) {
+  transformMetricData(md, options, start, end) {
     var dps = [],
       metricLabel = null;
 
@@ -273,9 +277,9 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
     }
 
     return { target: metricLabel, datapoints: dps };
-  };
+  }
 
-  this.transformMetricDataToTable = function(md) {
+  transformMetricDataToTable(md) {
     var table = new TableModel();
     var i, j;
     var metricLabels = {};
@@ -325,17 +329,17 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
     });
 
     return table;
-  };
+  }
 
-  this.createMetricLabel = function(labelData, options) {
+  createMetricLabel(labelData, options) {
     if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
       return this.getOriginalMetricName(labelData);
     }
 
-    return this.renderTemplate(templateSrv.replace(options.legendFormat), labelData) || '{}';
-  };
+    return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
+  }
 
-  this.renderTemplate = function(aliasPattern, aliasData) {
+  renderTemplate(aliasPattern, aliasData) {
     var aliasRegex = /\{\{\s*(.+?)\s*\}\}/g;
     return aliasPattern.replace(aliasRegex, function(match, g1) {
       if (aliasData[g1]) {
@@ -343,21 +347,21 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
       }
       return g1;
     });
-  };
+  }
 
-  this.getOriginalMetricName = function(labelData) {
+  getOriginalMetricName(labelData) {
     var metricName = labelData.__name__ || '';
     delete labelData.__name__;
     var labelPart = _.map(_.toPairs(labelData), function(label) {
       return label[0] + '="' + label[1] + '"';
     }).join(',');
     return metricName + '{' + labelPart + '}';
-  };
+  }
 
-  this.getPrometheusTime = function(date, roundUp) {
+  getPrometheusTime(date, roundUp) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);
     }
     return Math.ceil(date.valueOf() / 1000);
-  };
+  }
 }

+ 4 - 12
public/app/plugins/datasource/prometheus/partials/query.editor.html

@@ -1,7 +1,9 @@
 <query-editor-row query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="false">
 	<div class="gf-form-inline">
 		<div class="gf-form gf-form--grow">
-			<textarea rows="3" class="gf-form-input" ng-model="ctrl.target.expr" spellcheck="false" placeholder="query expression" data-min-length=0 data-items=100 give-focus="ctrl.target.refId == 'A'" ng-model-onblur ng-change="ctrl.refreshMetricData()"></textarea>
+			<code-editor content="ctrl.target.expr" on-change="ctrl.refreshMetricData()"
+				get-completer="ctrl.getCompleter()" data-mode="prometheus">
+			</code-editor>
 		</div>
 	</div>
 
@@ -38,17 +40,6 @@
 			</div>
 		</div>
 
-		<div class="gf-form gf-form--grow">
-			<div class="gf-form-label gf-form-label--grow"></div>
-		</div>
-	</div>
-
-	<div class="gf-form-inline">
-		<div class="gf-form max-width-26">
-			<label class="gf-form-label width-8">Metric lookup</label>
-			<input type="text" class="gf-form-input" ng-model="ctrl.target.metric" spellcheck='false' bs-typeahead="ctrl.suggestMetrics" placeholder="metric name" data-min-length=0 data-items=100>
-		</div>
-
 		<div class="gf-form">
 			<label class="gf-form-label width-6">Format as</label>
 			<div class="gf-form-select-wrapper width-8">
@@ -66,4 +57,5 @@
 		</div>
 	</div>
 
+
 </query-editor-row>

+ 12 - 15
public/app/plugins/datasource/prometheus/query_ctrl.ts

@@ -6,6 +6,7 @@ import moment from 'moment';
 
 import * as dateMath from 'app/core/utils/datemath';
 import {QueryCtrl} from 'app/plugins/sdk';
+import {PromCompleter} from './completer';
 
 class PrometheusQueryCtrl extends QueryCtrl {
   static templateUrl = 'partials/query.editor.html';
@@ -15,6 +16,7 @@ class PrometheusQueryCtrl extends QueryCtrl {
   formats: any;
   oldTarget: any;
   suggestMetrics: any;
+  getMetricsAutocomplete: any;
   linkToPrometheus: any;
 
   /** @ngInject */
@@ -36,24 +38,19 @@ class PrometheusQueryCtrl extends QueryCtrl {
       {text: 'Table', value: 'table'},
     ];
 
-    $scope.$on('typeahead-updated', () => {
-      this.$scope.$apply(() => {
-        this.target.expr += this.target.metric;
-        this.metric = '';
-        this.refreshMetricData();
-      });
-    });
-
-    // called from typeahead so need this
-    // here in order to ensure this ref
-    this.suggestMetrics = (query, callback) => {
-      console.log(this);
-      this.datasource.performSuggestQuery(query).then(callback);
-    };
-
     this.updateLink();
   }
 
+  getCompleter(query) {
+    return new PromCompleter(this.datasource);
+    // console.log('getquery);
+    // return this.datasource.performSuggestQuery(query).then(res => {
+    //   return res.map(item => {
+    //     return {word: item, type: 'metric'};
+    //   });
+    // });
+  }
+
   getDefaultFormat() {
     if (this.panelCtrl.panel.type === 'table') {
       return 'table';

+ 5 - 0
public/app/system.conf.js

@@ -33,6 +33,7 @@ System.config({
     "jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
     "d3": "vendor/d3/d3.js",
     "jquery.flot.dashes": "vendor/flot/jquery.flot.dashes",
+    "ace": "vendor/npm/ace-builds/src-noconflict/ace"
   },
 
   packages: {
@@ -73,5 +74,9 @@ System.config({
       format: 'global',
       exports: 'Mousetrap'
     },
+    'vendor/npm/ace-builds/src-noconflict/ace.js': {
+      format: 'global',
+      exports: 'ace'
+    }
   }
 });

+ 1 - 0
public/sass/_grafana.scss

@@ -77,6 +77,7 @@
 @import "components/row.scss";
 @import "components/json_explorer.scss";
 @import "components/collapse_box.scss";
+@import "components/code_editor.scss";
 
 // PAGES
 @import "pages/login";

+ 79 - 0
public/sass/components/_code_editor.scss

@@ -0,0 +1,79 @@
+.gf-code-editor {
+  min-height: 2.60rem;
+  min-width: 20rem;
+  flex-grow: 1;
+  margin-right: 0.25rem;
+  visibility: hidden;
+
+  &.ace_editor {
+    @include font-family-monospace();
+    font-size: 1rem;
+    min-height: 2.60rem;
+
+    @include border-radius($input-border-radius-sm);
+    border: $input-btn-border-width solid $input-border-color;
+  }
+
+  &--theme-loaded {
+    visibility: visible;
+  }
+}
+
+.ace_editor.ace_autocomplete {
+  @include font-family-monospace();
+  font-size: 1rem;
+
+  // Ace editor adds <style> tag at the end of <head>, after grafana.css, so !important
+  // is used for overriding styles with the same CSS specificity.
+  background-color: $dropdownBackground !important;
+  color: $dropdownLinkColor !important;
+  border: 1px solid $dropdownBorder !important;
+  width: 320px !important;
+
+  .ace_scroller {
+    .ace_selected, .ace_active-line, .ace_line-hover {
+      color: $dropdownLinkColorHover;
+      background-color: $dropdownLinkBackgroundHover !important;
+    }
+
+    .ace_line-hover {
+      border-color: transparent;
+    }
+
+    .ace_completion-highlight {
+      color: $yellow;
+    }
+
+    .ace_rightAlignedText {
+      color: $text-muted;
+      z-index: 0;
+    }
+  }
+}
+
+$doc-font-size: $font-size-sm;
+
+.ace_tooltip.ace_doc-tooltip {
+  @include font-family-monospace();
+  font-size: $doc-font-size;
+
+  background-color: $popover-help-bg;
+  color: $popover-help-color;
+  background-image: none;
+  border: 1px solid $dropdownBorder;
+  padding: 0.5rem 1rem;
+
+  hr {
+    background-color: $popover-help-color;
+    margin: 0.5rem 0rem;
+  }
+
+  code {
+    padding: 0px 1px;
+    margin: 0px;
+  }
+}
+
+.ace_tooltip {
+  border-radius: 3px;
+}

+ 3 - 2
public/sass/components/_modals.scss

@@ -20,7 +20,8 @@
 
 // Base modal
 .modal {
-  position: absolute;
+  position: fixed;
+  overflow: hidden;
   z-index: $zindex-modal;
   width: 100%;
 	background-color: $panel-bg;
@@ -33,7 +34,7 @@
   right: 0;
   margin-left: auto;
   margin-right: auto;
-  top: 20%;
+  top: 10%;
 }
 
 .modal-header {

+ 5 - 0
public/test/test-main.js

@@ -41,6 +41,7 @@
       "jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
       "d3": "vendor/d3/d3.js",
       "jquery.flot.dashes": "vendor/flot/jquery.flot.dashes",
+      "ace": "vendor/npm/ace-builds/src-noconflict/ace",
     },
 
     packages: {
@@ -73,6 +74,10 @@
         format: 'global',
         exports: 'Mousetrap'
       },
+      'vendor/npm/ace-builds/src-noconflict/ace.js': {
+        format: 'global',
+        exports: 'ace'
+      },
     }
   });
 

+ 1 - 0
tasks/options/copy.js

@@ -19,6 +19,7 @@ module.exports = function(config) {
       cwd: './node_modules',
       expand: true,
       src: [
+        'ace-builds/src-noconflict/**/*',
         'eventemitter3/*.js',
         'systemjs/dist/*.js',
         'es6-promise/**/*',

+ 4 - 1
tasks/options/watch.js

@@ -21,7 +21,10 @@ module.exports = function(config, grunt) {
       return;
     }
 
-    gaze(config.srcDir + '/**/*', function(err, watcher) {
+    gaze([
+      config.srcDir + '/app/**/*',
+      config.srcDir + '/sass/**/*',
+    ], function(err, watcher) {
 
       console.log('Gaze watchers setup');
 

+ 17 - 5
tests/datasource-test/module.js

@@ -8,12 +8,20 @@ System.register([], function (_export) {
       function Datasource(instanceSettings, backendSrv) {
         this.url = instanceSettings.url;
 
+        // this.testDatasource = function() {
+        //   return backendSrv.datasourceRequest({
+        //     method: 'GET',
+        //     url: this.url  + '/api/v4/search'
+        //   });
+        // }
+        //
         this.testDatasource = function() {
           return backendSrv.datasourceRequest({
             method: 'GET',
-            url: this.url  + '/api/v4/search'
+            url: this.url  + '/tokenTest'
           });
         }
+
       }
 
       function ConfigCtrl() {
@@ -22,12 +30,16 @@ System.register([], function (_export) {
 
       ConfigCtrl.template = `
         <div class="gf-form">
-          <label class="gf-form-label width-13">Email </label>
-          <input type="text" class="gf-form-input max-width-18" ng-model='ctrl.current.jsonData.email'></input>
+          <label class="gf-form-label width-13">TenantId </label>
+          <input type="text" class="gf-form-input max-width-18" ng-model='ctrl.current.jsonData.tenantId'></input>
+         </div>
+         <div class="gf-form">
+          <label class="gf-form-label width-13">ClientId </label>
+          <input type="text" class="gf-form-input max-width-18" ng-model='ctrl.current.jsonData.clientId'></input>
          </div>
          <div class="gf-form">
-          <label class="gf-form-label width-13">Access key ID </label>
-          <input type="text" class="gf-form-input max-width-18" ng-model='ctrl.current.secureJsonData.token'></input>
+          <label class="gf-form-label width-13">Client secret</label>
+          <input type="text" class="gf-form-input max-width-18" ng-model='ctrl.current.secureJsonData.clientSecret'></input>
          </div>
       `;
 

+ 4 - 4
tests/datasource-test/plugin.json

@@ -5,12 +5,12 @@
 
   "routes": [
     {
-      "path": "api/v5/",
+      "path": "tokenTest",
       "method": "*",
-      "url": "https://grafana-api.kentik.com/api/v5",
+      "url": "https://management.azure.com",
       "tokenAuth": {
-        "url":  "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token",
-        "body": {
+        "url": "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token",
+        "params": {
           "grant_type":  "client_credentials",
           "client_id": "{{.JsonData.clientId}}",
           "client_secret": "{{.SecureJsonData.clientSecret}}",

File diff suppressed because it is too large
+ 270 - 239
yarn.lock


Some files were not shown because too many files changed in this diff