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

Merge pull request #13289 from grafana/stackdriver-plugin

Stackdriver Datasource - Fixes #6733
Daniel Lee 7 лет назад
Родитель
Сommit
9c2bca411c
45 измененных файлов с 4189 добавлено и 164 удалено
  1. 168 0
      docs/sources/features/datasources/stackdriver.md
  2. 1 0
      docs/versions.json
  3. 1 1
      package.json
  4. 171 0
      pkg/api/pluginproxy/access_token_provider.go
  5. 94 0
      pkg/api/pluginproxy/access_token_provider_test.go
  6. 93 0
      pkg/api/pluginproxy/ds_auth_provider.go
  7. 21 0
      pkg/api/pluginproxy/ds_auth_provider_test.go
  8. 3 130
      pkg/api/pluginproxy/ds_proxy.go
  9. 5 17
      pkg/api/pluginproxy/ds_proxy_test.go
  10. 1 0
      pkg/cmd/grafana-server/main.go
  11. 3 1
      pkg/models/datasource.go
  12. 10 6
      pkg/plugins/app_plugin.go
  13. 120 0
      pkg/tsdb/stackdriver/annotation_query.go
  14. 33 0
      pkg/tsdb/stackdriver/annotation_query_test.go
  15. 460 0
      pkg/tsdb/stackdriver/stackdriver.go
  16. 360 0
      pkg/tsdb/stackdriver/stackdriver_test.go
  17. 46 0
      pkg/tsdb/stackdriver/test-data/1-series-response-agg-one-metric.json
  18. 145 0
      pkg/tsdb/stackdriver/test-data/2-series-response-no-agg.json
  19. 43 0
      pkg/tsdb/stackdriver/types.go
  20. 10 5
      public/app/features/dashboard/upload.ts
  21. 2 0
      public/app/features/plugins/built_in_plugins.ts
  22. 6 1
      public/app/features/plugins/partials/ds_edit.html
  23. 7 0
      public/app/plugins/datasource/stackdriver/README.md
  24. 31 0
      public/app/plugins/datasource/stackdriver/annotations_query_ctrl.ts
  25. 74 0
      public/app/plugins/datasource/stackdriver/config_ctrl.ts
  26. 258 0
      public/app/plugins/datasource/stackdriver/constants.ts
  27. 264 0
      public/app/plugins/datasource/stackdriver/datasource.ts
  28. 116 0
      public/app/plugins/datasource/stackdriver/filter_segments.ts
  29. BIN
      public/app/plugins/datasource/stackdriver/img/stackdriver_logo.png
  30. 11 0
      public/app/plugins/datasource/stackdriver/module.ts
  31. 37 0
      public/app/plugins/datasource/stackdriver/partials/annotations.editor.html
  32. 84 0
      public/app/plugins/datasource/stackdriver/partials/config.html
  33. 46 0
      public/app/plugins/datasource/stackdriver/partials/query.aggregation.html
  34. 62 0
      public/app/plugins/datasource/stackdriver/partials/query.editor.html
  35. 37 0
      public/app/plugins/datasource/stackdriver/partials/query.filter.html
  36. 56 0
      public/app/plugins/datasource/stackdriver/plugin.json
  37. 86 0
      public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts
  38. 106 0
      public/app/plugins/datasource/stackdriver/query_ctrl.ts
  39. 288 0
      public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts
  40. 274 0
      public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
  41. 60 0
      public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts
  42. 442 0
      public/app/plugins/datasource/stackdriver/specs/query_filter_ctrl.test.ts
  43. 42 0
      public/app/plugins/datasource/stackdriver/specs/testData.ts
  44. 9 0
      public/sass/components/_infobox.scss
  45. 3 3
      yarn.lock

+ 168 - 0
docs/sources/features/datasources/stackdriver.md

@@ -0,0 +1,168 @@
++++
+title = "Using Stackdriver in Grafana"
+description = "Guide for using Stackdriver in Grafana"
+keywords = ["grafana", "stackdriver", "google", "guide"]
+type = "docs"
+aliases = ["/datasources/stackdriver"]
+[menu.docs]
+name = "Stackdriver"
+parent = "datasources"
+weight = 11
++++
+
+# Using Google Stackdriver in Grafana
+
+Grafana ships with built-in support for Google Stackdriver. Just add it as a datasource and you are ready to build dashboards for your Stackdriver metrics. It is only available in Grafana 5.3+. The datasource is currently a beta feature and is subject to change.
+
+## Adding the data source to Grafana
+
+1. Open the side menu by clicking the Grafana icon in the top header.
+2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`.
+3. Click the `+ Add data source` button in the top header.
+4. Select `Stackdriver` from the *Type* dropdown.
+5. Upload or paste in the Service Account Key file. See below for steps on how to create a Service Account Key file.
+
+> NOTE: If you're not seeing the `Data Sources` link in your side menu it means that your current user does not have the `Admin` role for the current organization.
+
+| Name                  | Description                                                                         |
+| --------------------- | ----------------------------------------------------------------------------------- |
+| _Name_                | The datasource name. This is how you refer to the datasource in panels & queries.   |
+| _Default_             | Default datasource means that it will be pre-selected for new panels.               |
+| _Service Account Key_ | Service Account Key File for a GCP Project. Instructions below on how to create it. |
+
+## Authentication
+
+### Service Account Credentials - Private Key File
+
+To authenticate with the Stackdriver API, you need to create a Google Cloud Platform (GCP) Service Account for the Project you want to show data for. A Grafana datasource integrates with one GCP Project. If you want to visualize data from multiple GCP Projects then you need to create one datasource per GCP Project.
+
+#### Enable APIs
+
+The following APIs need to be enabled first:
+
+- [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com)
+- [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com)
+
+Click on the links above and click the `Enable` button:
+
+![Enable GCP APIs](/img/docs/v54/stackdriver_enable_api.png)
+
+#### Create a GCP Service Account for a Project
+
+1. Navigate to the [APIs & Services Credentials page](https://console.cloud.google.com/apis/credentials).
+2. Click on the `Create credentials` dropdown/button and choose the `Service account key` option.
+
+    ![Create service account button](/img/docs/v54/stackdriver_create_service_account_button.png)
+3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option:
+
+    ![Create service account key](/img/docs/v54/stackdriver_create_service_account_key.png)
+4. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown:
+
+    ![Choose role](/img/docs/v54/stackdriver_service_account_choose_role.png)
+5. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Stackdriver data.
+6. Upload it to Grafana on the datasource Configuration page. You can either upload the file or paste in the contents of the file.
+    
+    ![Choose role](/img/docs/v54/stackdriver_grafana_upload_key.png)
+7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file!
+    
+    ![Choose role](/img/docs/v54/stackdriver_grafana_key_uploaded.png)
+
+## Metric Query Editor
+
+Choose a metric from the `Metric` dropdown.
+
+To add a filter, click the plus icon and choose a field to filter by and enter a filter value e.g. `instance_name = grafana-1`
+
+### Aggregation
+
+The aggregation field lets you combine time series based on common statistics. Read more about this option [here](https://cloud.google.com/monitoring/charts/metrics-selector#aggregation-options).
+
+The `Aligner` field allows you to align multiple time series after the same group by time interval. Read more about how it works [here](https://cloud.google.com/monitoring/charts/metrics-selector#alignment).
+
+#### Alignment Period/Group by Time
+
+The `Alignment Period` groups a metric by time if an aggregation is chosen. The default is to use the GCP Stackdriver default groupings (which allows you to compare graphs in Grafana with graphs in the Stackdriver UI).
+The option is called `Stackdriver auto` and the defaults are:
+
+- 1m for time ranges < 23 hours
+- 5m for time ranges >= 23 hours and < 6 days
+- 1h for time ranges >= 6 days
+
+The other automatic option is `Grafana auto`. This will automatically set the group by time depending on the time range chosen and the width of the graph panel. Read more about the details [here](http://docs.grafana.org/reference/templating/#the-interval-variable).
+
+It is also possible to choose fixed time intervals to group by, like `1h` or `1d`.
+
+### Group By
+
+Group by resource or metric labels to reduce the number of time series and to aggregate the results by a group by. E.g. Group by instance_name to see an aggregated metric for a Compute instance.
+
+### Alias Patterns
+
+The Alias By field allows you to control the format of the legend keys. The default is to show the metric name and labels. This can be long and hard to read. Using the following patterns in the alias field, you can format the legend key the way you want it.
+
+#### Metric Type Patterns
+
+Alias Pattern | Description | Example Result
+----------------- | ---------------------------- | -------------
+`{{metric.type}}` | returns the full Metric Type | `compute.googleapis.com/instance/cpu/utilization`
+`{{metric.name}}` | returns the metric name part | `instance/cpu/utilization`
+`{{metric.service}}` | returns the service part | `compute`
+
+#### Label Patterns
+
+In the Group By dropdown, you can see a list of metric and resource labels for a metric. These can be included in the legend key using alias patterns.
+
+Alias Pattern Format | Description | Alias Pattern Example | Example Result
+---------------------- | ---------------------------------- | ---------------------------- | -------------
+`{{metric.label.xxx}}` | returns the metric label value | `{{metric.label.instance_name}}` | `grafana-1-prod`
+`{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}` | `us-east1-b`
+
+Example Alias By: `{{metric.type}} - {{metric.labels.instance_name}}`
+
+Example Result: `compute.googleapis.com/instance/cpu/usage_time - server1-prod`
+
+## Templating
+
+Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place.
+Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns makes it easy to change the data
+being displayed in your dashboard.
+
+Checkout the [Templating]({{< relref "reference/templating.md" >}}) documentation for an introduction to the templating feature and the different
+types of template variables.
+
+### Query Variable
+
+Writing variable queries is not supported yet.
+
+### Using variables in queries
+
+There are two syntaxes:
+
+- `$<varname>`  Example: rate(http_requests_total{job=~"$job"}[5m])
+- `[[varname]]` Example: rate(http_requests_total{job=~"[[job]]"}[5m])
+
+Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the *Multi-value* or *Include all value* options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`.
+
+## Annotations
+
+[Annotations]({{< relref "reference/annotations.md" >}}) allows you to overlay rich event information on top of graphs. You add annotation
+queries via the Dashboard menu / Annotations view.
+
+## Configure the Datasource with Provisioning
+
+It's now possible to configure datasources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources)
+
+Here is a provisioning example for this datasource.
+
+```yaml
+apiVersion: 1
+
+datasources:
+  - name: Stackdriver
+    type: stackdriver
+    jsonData:
+      tokenUri: https://oauth2.googleapis.com/token
+      clientEmail: stackdriver@myproject.iam.gserviceaccount.com
+    secureJsonData:
+      privateKey: "<contents of your Service Account JWT Key file>"
+```

+ 1 - 0
docs/versions.json

@@ -1,4 +1,5 @@
 [
+  { "version": "v5.3", "path": "/v5.3", "archived": false, "current": false },
   { "version": "v5.2", "path": "/", "archived": false, "current": true },
   { "version": "v5.1", "path": "/v5.1", "archived": true },
   { "version": "v5.0", "path": "/v5.0", "archived": true },

+ 1 - 1
package.json

@@ -12,7 +12,7 @@
   "devDependencies": {
     "@types/d3": "^4.10.1",
     "@types/enzyme": "^3.1.13",
-    "@types/jest": "^21.1.4",
+    "@types/jest": "^23.3.2",
     "@types/node": "^8.0.31",
     "@types/react": "^16.4.14",
     "@types/react-custom-scrollbars": "^4.0.5",

+ 171 - 0
pkg/api/pluginproxy/access_token_provider.go

@@ -0,0 +1,171 @@
+package pluginproxy
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"sync"
+	"time"
+
+	"golang.org/x/oauth2"
+
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/plugins"
+	"golang.org/x/oauth2/jwt"
+)
+
+var (
+	tokenCache = tokenCacheType{
+		cache: map[string]*jwtToken{},
+	}
+	oauthJwtTokenCache = oauthJwtTokenCacheType{
+		cache: map[string]*oauth2.Token{},
+	}
+)
+
+type tokenCacheType struct {
+	cache map[string]*jwtToken
+	sync.Mutex
+}
+
+type oauthJwtTokenCacheType struct {
+	cache map[string]*oauth2.Token
+	sync.Mutex
+}
+
+type accessTokenProvider struct {
+	route             *plugins.AppPluginRoute
+	datasourceId      int64
+	datasourceVersion int
+}
+
+type jwtToken struct {
+	ExpiresOn       time.Time `json:"-"`
+	ExpiresOnString string    `json:"expires_on"`
+	AccessToken     string    `json:"access_token"`
+}
+
+func newAccessTokenProvider(ds *models.DataSource, pluginRoute *plugins.AppPluginRoute) *accessTokenProvider {
+	return &accessTokenProvider{
+		datasourceId:      ds.Id,
+		datasourceVersion: ds.Version,
+		route:             pluginRoute,
+	}
+}
+
+func (provider *accessTokenProvider) getAccessToken(data templateData) (string, error) {
+	tokenCache.Lock()
+	defer tokenCache.Unlock()
+	if cachedToken, found := tokenCache.cache[provider.getAccessTokenCacheKey()]; found {
+		if cachedToken.ExpiresOn.After(time.Now().Add(time.Second * 10)) {
+			logger.Info("Using token from cache")
+			return cachedToken.AccessToken, nil
+		}
+	}
+
+	urlInterpolated, err := interpolateString(provider.route.TokenAuth.Url, data)
+	if err != nil {
+		return "", err
+	}
+
+	params := make(url.Values)
+	for key, value := range provider.route.TokenAuth.Params {
+		interpolatedParam, err := interpolateString(value, data)
+		if err != nil {
+			return "", err
+		}
+		params.Add(key, interpolatedParam)
+	}
+
+	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())))
+
+	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.cache[provider.getAccessTokenCacheKey()] = &token
+
+	logger.Info("Got new access token", "ExpiresOn", token.ExpiresOn)
+
+	return token.AccessToken, nil
+}
+
+func (provider *accessTokenProvider) getJwtAccessToken(ctx context.Context, data templateData) (string, error) {
+	oauthJwtTokenCache.Lock()
+	defer oauthJwtTokenCache.Unlock()
+	if cachedToken, found := oauthJwtTokenCache.cache[provider.getAccessTokenCacheKey()]; found {
+		if cachedToken.Expiry.After(time.Now().Add(time.Second * 10)) {
+			logger.Debug("Using token from cache")
+			return cachedToken.AccessToken, nil
+		}
+	}
+
+	conf := &jwt.Config{}
+
+	if val, ok := provider.route.JwtTokenAuth.Params["client_email"]; ok {
+		interpolatedVal, err := interpolateString(val, data)
+		if err != nil {
+			return "", err
+		}
+		conf.Email = interpolatedVal
+	}
+
+	if val, ok := provider.route.JwtTokenAuth.Params["private_key"]; ok {
+		interpolatedVal, err := interpolateString(val, data)
+		if err != nil {
+			return "", err
+		}
+		conf.PrivateKey = []byte(interpolatedVal)
+	}
+
+	if val, ok := provider.route.JwtTokenAuth.Params["token_uri"]; ok {
+		interpolatedVal, err := interpolateString(val, data)
+		if err != nil {
+			return "", err
+		}
+		conf.TokenURL = interpolatedVal
+	}
+
+	conf.Scopes = provider.route.JwtTokenAuth.Scopes
+
+	token, err := getTokenSource(conf, ctx)
+	if err != nil {
+		return "", err
+	}
+
+	oauthJwtTokenCache.cache[provider.getAccessTokenCacheKey()] = token
+
+	logger.Info("Got new access token", "ExpiresOn", token.Expiry)
+
+	return token.AccessToken, nil
+}
+
+var getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+	tokenSrc := conf.TokenSource(ctx)
+	token, err := tokenSrc.Token()
+	if err != nil {
+		return nil, err
+	}
+
+	return token, nil
+}
+
+func (provider *accessTokenProvider) getAccessTokenCacheKey() string {
+	return fmt.Sprintf("%v_%v_%v_%v", provider.datasourceId, provider.datasourceVersion, provider.route.Path, provider.route.Method)
+}

+ 94 - 0
pkg/api/pluginproxy/access_token_provider_test.go

@@ -0,0 +1,94 @@
+package pluginproxy
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/plugins"
+	. "github.com/smartystreets/goconvey/convey"
+	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/jwt"
+)
+
+func TestAccessToken(t *testing.T) {
+	Convey("Plugin with JWT token auth route", t, func() {
+		pluginRoute := &plugins.AppPluginRoute{
+			Path:   "pathwithjwttoken1",
+			Url:    "https://api.jwt.io/some/path",
+			Method: "GET",
+			JwtTokenAuth: &plugins.JwtTokenAuth{
+				Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token",
+				Scopes: []string{
+					"https://www.testapi.com/auth/monitoring.read",
+					"https://www.testapi.com/auth/cloudplatformprojects.readonly",
+				},
+				Params: map[string]string{
+					"token_uri":    "{{.JsonData.tokenUri}}",
+					"client_email": "{{.JsonData.clientEmail}}",
+					"private_key":  "{{.SecureJsonData.privateKey}}",
+				},
+			},
+		}
+
+		templateData := templateData{
+			JsonData: map[string]interface{}{
+				"clientEmail": "test@test.com",
+				"tokenUri":    "login.url.com/token",
+			},
+			SecureJsonData: map[string]string{
+				"privateKey": "testkey",
+			},
+		}
+
+		ds := &models.DataSource{Id: 1, Version: 2}
+
+		Convey("should fetch token using jwt private key", func() {
+			getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+				return &oauth2.Token{AccessToken: "abc"}, nil
+			}
+			provider := newAccessTokenProvider(ds, pluginRoute)
+			token, err := provider.getJwtAccessToken(context.Background(), templateData)
+			So(err, ShouldBeNil)
+
+			So(token, ShouldEqual, "abc")
+		})
+
+		Convey("should set jwt config values", func() {
+			getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+				So(conf.Email, ShouldEqual, "test@test.com")
+				So(conf.PrivateKey, ShouldResemble, []byte("testkey"))
+				So(len(conf.Scopes), ShouldEqual, 2)
+				So(conf.Scopes[0], ShouldEqual, "https://www.testapi.com/auth/monitoring.read")
+				So(conf.Scopes[1], ShouldEqual, "https://www.testapi.com/auth/cloudplatformprojects.readonly")
+				So(conf.TokenURL, ShouldEqual, "login.url.com/token")
+
+				return &oauth2.Token{AccessToken: "abc"}, nil
+			}
+
+			provider := newAccessTokenProvider(ds, pluginRoute)
+			_, err := provider.getJwtAccessToken(context.Background(), templateData)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("should use cached token on second call", func() {
+			getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+				return &oauth2.Token{
+					AccessToken: "abc",
+					Expiry:      time.Now().Add(1 * time.Minute)}, nil
+			}
+			provider := newAccessTokenProvider(ds, pluginRoute)
+			token1, err := provider.getJwtAccessToken(context.Background(), templateData)
+			So(err, ShouldBeNil)
+			So(token1, ShouldEqual, "abc")
+
+			getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+				return &oauth2.Token{AccessToken: "error: cache not used"}, nil
+			}
+			token2, err := provider.getJwtAccessToken(context.Background(), templateData)
+			So(err, ShouldBeNil)
+			So(token2, ShouldEqual, "abc")
+		})
+	})
+}

+ 93 - 0
pkg/api/pluginproxy/ds_auth_provider.go

@@ -0,0 +1,93 @@
+package pluginproxy
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"text/template"
+
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+//ApplyRoute should use the plugin route data to set auth headers and custom headers
+func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route *plugins.AppPluginRoute, ds *m.DataSource) {
+	proxyPath = strings.TrimPrefix(proxyPath, route.Path)
+
+	data := templateData{
+		JsonData:       ds.JsonData.Interface().(map[string]interface{}),
+		SecureJsonData: ds.SecureJsonData.Decrypt(),
+	}
+
+	interpolatedURL, err := interpolateString(route.Url, data)
+	if err != nil {
+		logger.Error("Error interpolating proxy url", "error", err)
+		return
+	}
+
+	routeURL, err := url.Parse(interpolatedURL)
+	if err != nil {
+		logger.Error("Error parsing plugin route url", "error", err)
+		return
+	}
+
+	req.URL.Scheme = routeURL.Scheme
+	req.URL.Host = routeURL.Host
+	req.Host = routeURL.Host
+	req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxyPath)
+
+	if err := addHeaders(&req.Header, route, data); err != nil {
+		logger.Error("Failed to render plugin headers", "error", err)
+	}
+
+	tokenProvider := newAccessTokenProvider(ds, route)
+
+	if route.TokenAuth != nil {
+		if token, err := tokenProvider.getAccessToken(data); err != nil {
+			logger.Error("Failed to get access token", "error", err)
+		} else {
+			req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+		}
+	}
+
+	if route.JwtTokenAuth != nil {
+		if token, err := tokenProvider.getJwtAccessToken(ctx, 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 interpolateString(text string, data templateData) (string, error) {
+	t, err := template.New("content").Parse(text)
+	if err != nil {
+		return "", fmt.Errorf("could not parse template %s", text)
+	}
+
+	var contentBuf bytes.Buffer
+	err = t.Execute(&contentBuf, data)
+	if err != nil {
+		return "", fmt.Errorf("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
+}

+ 21 - 0
pkg/api/pluginproxy/ds_auth_provider_test.go

@@ -0,0 +1,21 @@
+package pluginproxy
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDsAuthProvider(t *testing.T) {
+	Convey("When interpolating string", t, func() {
+		data := templateData{
+			SecureJsonData: map[string]string{
+				"Test": "0asd+asd",
+			},
+		}
+
+		interpolated, err := interpolateString("{{.SecureJsonData.Test}}", data)
+		So(err, ShouldBeNil)
+		So(interpolated, ShouldEqual, "0asd+asd")
+	})
+}

+ 3 - 130
pkg/api/pluginproxy/ds_proxy.go

@@ -2,7 +2,6 @@ package pluginproxy
 
 import (
 	"bytes"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io/ioutil"
@@ -12,7 +11,6 @@ import (
 	"net/url"
 	"strconv"
 	"strings"
-	"text/template"
 	"time"
 
 	"github.com/opentracing/opentracing-go"
@@ -25,17 +23,10 @@ import (
 )
 
 var (
-	logger     = log.New("data-proxy-log")
-	tokenCache = map[string]*jwtToken{}
-	client     = newHTTPClient()
+	logger = log.New("data-proxy-log")
+	client = newHTTPClient()
 )
 
-type jwtToken struct {
-	ExpiresOn       time.Time `json:"-"`
-	ExpiresOnString string    `json:"expires_on"`
-	AccessToken     string    `json:"access_token"`
-}
-
 type DataSourceProxy struct {
 	ds        *m.DataSource
 	ctx       *m.ReqContext
@@ -162,7 +153,6 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
 		} else {
 			req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath)
 		}
-
 		if proxy.ds.BasicAuth {
 			req.Header.Del("Authorization")
 			req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.BasicAuthUser, proxy.ds.BasicAuthPassword))
@@ -219,7 +209,7 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
 		}
 
 		if proxy.route != nil {
-			proxy.applyRoute(req)
+			ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 		}
 	}
 }
@@ -311,120 +301,3 @@ func checkWhiteList(c *m.ReqContext, host string) bool {
 
 	return true
 }
-
-func (proxy *DataSourceProxy) applyRoute(req *http.Request) {
-	proxy.proxyPath = strings.TrimPrefix(proxy.proxyPath, proxy.route.Path)
-
-	data := templateData{
-		JsonData:       proxy.ds.JsonData.Interface().(map[string]interface{}),
-		SecureJsonData: proxy.ds.SecureJsonData.Decrypt(),
-	}
-
-	interpolatedURL, err := interpolateString(proxy.route.Url, data)
-	if err != nil {
-		logger.Error("Error interpolating proxy url", "error", err)
-		return
-	}
-
-	routeURL, err := url.Parse(interpolatedURL)
-	if err != nil {
-		logger.Error("Error parsing plugin route url", "error", err)
-		return
-	}
-
-	req.URL.Scheme = routeURL.Scheme
-	req.URL.Host = routeURL.Host
-	req.Host = routeURL.Host
-	req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxy.proxyPath)
-
-	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 (proxy *DataSourceProxy) getAccessToken(data templateData) (string, error) {
-	if cachedToken, found := tokenCache[proxy.getAccessTokenCacheKey()]; found {
-		if cachedToken.ExpiresOn.After(time.Now().Add(time.Second * 10)) {
-			logger.Info("Using token from cache")
-			return cachedToken.AccessToken, nil
-		}
-	}
-
-	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 {
-		interpolatedParam, err := interpolateString(value, data)
-		if err != nil {
-			return "", err
-		}
-		params.Add(key, interpolatedParam)
-	}
-
-	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())))
-
-	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.getAccessTokenCacheKey()] = &token
-
-	logger.Info("Got new access token", "ExpiresOn", token.ExpiresOn)
-	return token.AccessToken, nil
-}
-
-func (proxy *DataSourceProxy) getAccessTokenCacheKey() string {
-	return fmt.Sprintf("%v_%v_%v", proxy.ds.Id, proxy.route.Path, proxy.route.Method)
-}
-
-func interpolateString(text string, data templateData) (string, error) {
-	t, err := template.New("content").Parse(text)
-	if err != nil {
-		return "", fmt.Errorf("could not parse template %s", text)
-	}
-
-	var contentBuf bytes.Buffer
-	err = t.Execute(&contentBuf, data)
-	if err != nil {
-		return "", fmt.Errorf("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
-}

+ 5 - 17
pkg/api/pluginproxy/ds_proxy_test.go

@@ -83,7 +83,7 @@ func TestDSRouteRule(t *testing.T) {
 			Convey("When matching route path", func() {
 				proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
 				proxy.route = plugin.Routes[0]
-				proxy.applyRoute(req)
+				ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 
 				Convey("should add headers and update url", func() {
 					So(req.URL.String(), ShouldEqual, "https://www.google.com/some/method")
@@ -94,7 +94,7 @@ func TestDSRouteRule(t *testing.T) {
 			Convey("When matching route path and has dynamic url", func() {
 				proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method")
 				proxy.route = plugin.Routes[3]
-				proxy.applyRoute(req)
+				ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
 
 				Convey("should add headers and interpolate the url", func() {
 					So(req.URL.String(), ShouldEqual, "https://dynamic.grafana.com/some/method")
@@ -188,7 +188,7 @@ func TestDSRouteRule(t *testing.T) {
 					client = newFakeHTTPClient(json)
 					proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
 					proxy1.route = plugin.Routes[0]
-					proxy1.applyRoute(req)
+					ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds)
 
 					authorizationHeaderCall1 = req.Header.Get("Authorization")
 					So(req.URL.String(), ShouldEqual, "https://api.nr1.io/some/path")
@@ -202,7 +202,7 @@ func TestDSRouteRule(t *testing.T) {
 						client = newFakeHTTPClient(json2)
 						proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2")
 						proxy2.route = plugin.Routes[1]
-						proxy2.applyRoute(req)
+						ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds)
 
 						authorizationHeaderCall2 = req.Header.Get("Authorization")
 
@@ -217,7 +217,7 @@ func TestDSRouteRule(t *testing.T) {
 							client = newFakeHTTPClient([]byte{})
 							proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
 							proxy3.route = plugin.Routes[0]
-							proxy3.applyRoute(req)
+							ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds)
 
 							authorizationHeaderCall3 := req.Header.Get("Authorization")
 							So(req.URL.String(), ShouldEqual, "https://api.nr1.io/some/path")
@@ -331,18 +331,6 @@ func TestDSRouteRule(t *testing.T) {
 			})
 		})
 
-		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")
-		})
-
 		Convey("When proxying a data source with custom headers specified", func() {
 			plugin := &plugins.DataSourcePlugin{}
 

+ 1 - 0
pkg/cmd/grafana-server/main.go

@@ -29,6 +29,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/tsdb/opentsdb"
 	_ "github.com/grafana/grafana/pkg/tsdb/postgres"
 	_ "github.com/grafana/grafana/pkg/tsdb/prometheus"
+	_ "github.com/grafana/grafana/pkg/tsdb/stackdriver"
 	_ "github.com/grafana/grafana/pkg/tsdb/testdata"
 )
 

+ 3 - 1
pkg/models/datasource.go

@@ -22,6 +22,7 @@ const (
 	DS_MSSQL         = "mssql"
 	DS_ACCESS_DIRECT = "direct"
 	DS_ACCESS_PROXY  = "proxy"
+	DS_STACKDRIVER   = "stackdriver"
 )
 
 var (
@@ -70,12 +71,12 @@ var knownDatasourcePlugins = map[string]bool{
 	DS_POSTGRES:                           true,
 	DS_MYSQL:                              true,
 	DS_MSSQL:                              true,
+	DS_STACKDRIVER:                        true,
 	"opennms":                             true,
 	"abhisant-druid-datasource":           true,
 	"dalmatinerdb-datasource":             true,
 	"gnocci":                              true,
 	"zabbix":                              true,
-	"alexanderzobnin-zabbix-datasource":   true,
 	"newrelic-app":                        true,
 	"grafana-datadog-datasource":          true,
 	"grafana-simple-json":                 true,
@@ -88,6 +89,7 @@ var knownDatasourcePlugins = map[string]bool{
 	"ayoungprogrammer-finance-datasource": true,
 	"monasca-datasource":                  true,
 	"vertamedia-clickhouse-datasource":    true,
+	"alexanderzobnin-zabbix-datasource":   true,
 }
 
 func IsKnownDataSourcePlugin(dsType string) bool {

+ 10 - 6
pkg/plugins/app_plugin.go

@@ -23,12 +23,13 @@ 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"`
-	TokenAuth *JwtTokenAuth          `json:"tokenAuth"`
+	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"`
+	JwtTokenAuth *JwtTokenAuth          `json:"jwtTokenAuth"`
 }
 
 type AppPluginRouteHeader struct {
@@ -36,8 +37,11 @@ type AppPluginRouteHeader struct {
 	Content string `json:"content"`
 }
 
+// JwtTokenAuth struct is both for normal Token Auth and JWT Token Auth with
+// an uploaded JWT file.
 type JwtTokenAuth struct {
 	Url    string            `json:"url"`
+	Scopes []string          `json:"scopes"`
 	Params map[string]string `json:"params"`
 }
 

+ 120 - 0
pkg/tsdb/stackdriver/annotation_query.go

@@ -0,0 +1,120 @@
+package stackdriver
+
+import (
+	"context"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/grafana/grafana/pkg/tsdb"
+)
+
+func (e *StackdriverExecutor) executeAnnotationQuery(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+	result := &tsdb.Response{
+		Results: make(map[string]*tsdb.QueryResult),
+	}
+
+	firstQuery := tsdbQuery.Queries[0]
+
+	queries, err := e.buildQueries(tsdbQuery)
+	if err != nil {
+		return nil, err
+	}
+
+	queryRes, resp, err := e.executeQuery(ctx, queries[0], tsdbQuery)
+	if err != nil {
+		return nil, err
+	}
+	title := firstQuery.Model.Get("title").MustString()
+	text := firstQuery.Model.Get("text").MustString()
+	tags := firstQuery.Model.Get("tags").MustString()
+	err = e.parseToAnnotations(queryRes, resp, queries[0], title, text, tags)
+	result.Results[firstQuery.RefId] = queryRes
+
+	return result, err
+}
+
+func (e *StackdriverExecutor) parseToAnnotations(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery, title string, text string, tags string) error {
+	annotations := make([]map[string]string, 0)
+
+	for _, series := range data.TimeSeries {
+		// reverse the order to be ascending
+		for i := len(series.Points) - 1; i >= 0; i-- {
+			point := series.Points[i]
+			value := strconv.FormatFloat(point.Value.DoubleValue, 'f', 6, 64)
+			if series.ValueType == "STRING" {
+				value = point.Value.StringValue
+			}
+			annotation := make(map[string]string)
+			annotation["time"] = point.Interval.EndTime.UTC().Format(time.RFC3339)
+			annotation["title"] = formatAnnotationText(title, value, series.Metric.Type, series.Metric.Labels, series.Resource.Labels)
+			annotation["tags"] = tags
+			annotation["text"] = formatAnnotationText(text, value, series.Metric.Type, series.Metric.Labels, series.Resource.Labels)
+			annotations = append(annotations, annotation)
+		}
+	}
+
+	transformAnnotationToTable(annotations, queryRes)
+	return nil
+}
+
+func transformAnnotationToTable(data []map[string]string, result *tsdb.QueryResult) {
+	table := &tsdb.Table{
+		Columns: make([]tsdb.TableColumn, 4),
+		Rows:    make([]tsdb.RowValues, 0),
+	}
+	table.Columns[0].Text = "time"
+	table.Columns[1].Text = "title"
+	table.Columns[2].Text = "tags"
+	table.Columns[3].Text = "text"
+
+	for _, r := range data {
+		values := make([]interface{}, 4)
+		values[0] = r["time"]
+		values[1] = r["title"]
+		values[2] = r["tags"]
+		values[3] = r["text"]
+		table.Rows = append(table.Rows, values)
+	}
+	result.Tables = append(result.Tables, table)
+	result.Meta.Set("rowCount", len(data))
+	slog.Info("anno", "len", len(data))
+}
+
+func formatAnnotationText(annotationText string, pointValue string, metricType string, metricLabels map[string]string, resourceLabels map[string]string) string {
+	result := legendKeyFormat.ReplaceAllFunc([]byte(annotationText), func(in []byte) []byte {
+		metaPartName := strings.Replace(string(in), "{{", "", 1)
+		metaPartName = strings.Replace(metaPartName, "}}", "", 1)
+		metaPartName = strings.TrimSpace(metaPartName)
+
+		if metaPartName == "metric.type" {
+			return []byte(metricType)
+		}
+
+		metricPart := replaceWithMetricPart(metaPartName, metricType)
+
+		if metricPart != nil {
+			return metricPart
+		}
+
+		if metaPartName == "metric.value" {
+			return []byte(pointValue)
+		}
+
+		metaPartName = strings.Replace(metaPartName, "metric.label.", "", 1)
+
+		if val, exists := metricLabels[metaPartName]; exists {
+			return []byte(val)
+		}
+
+		metaPartName = strings.Replace(metaPartName, "resource.label.", "", 1)
+
+		if val, exists := resourceLabels[metaPartName]; exists {
+			return []byte(val)
+		}
+
+		return in
+	})
+
+	return string(result)
+}

+ 33 - 0
pkg/tsdb/stackdriver/annotation_query_test.go

@@ -0,0 +1,33 @@
+package stackdriver
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/tsdb"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestStackdriverAnnotationQuery(t *testing.T) {
+	Convey("Stackdriver Annotation Query Executor", t, func() {
+		executor := &StackdriverExecutor{}
+		Convey("When parsing the stackdriver api response", func() {
+			data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+			So(err, ShouldBeNil)
+			So(len(data.TimeSeries), ShouldEqual, 3)
+
+			res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "annotationQuery"}
+			query := &StackdriverQuery{}
+			err = executor.parseToAnnotations(res, data, query, "atitle {{metric.label.instance_name}} {{metric.value}}", "atext {{resource.label.zone}}", "atag")
+			So(err, ShouldBeNil)
+
+			Convey("Should return annotations table", func() {
+				So(len(res.Tables), ShouldEqual, 1)
+				So(len(res.Tables[0].Rows), ShouldEqual, 9)
+				So(res.Tables[0].Rows[0][1], ShouldEqual, "atitle collector-asia-east-1 9.856650")
+				So(res.Tables[0].Rows[0][3], ShouldEqual, "atext asia-east1-a")
+			})
+		})
+	})
+}

+ 460 - 0
pkg/tsdb/stackdriver/stackdriver.go

@@ -0,0 +1,460 @@
+package stackdriver
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"math"
+	"net/http"
+	"net/url"
+	"path"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"golang.org/x/net/context/ctxhttp"
+
+	"github.com/grafana/grafana/pkg/api/pluginproxy"
+	"github.com/grafana/grafana/pkg/components/null"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/tsdb"
+	"github.com/opentracing/opentracing-go"
+)
+
+var (
+	slog             log.Logger
+	legendKeyFormat  *regexp.Regexp
+	metricNameFormat *regexp.Regexp
+)
+
+// StackdriverExecutor executes queries for the Stackdriver datasource
+type StackdriverExecutor struct {
+	httpClient *http.Client
+	dsInfo     *models.DataSource
+}
+
+// NewStackdriverExecutor initializes a http client
+func NewStackdriverExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
+	httpClient, err := dsInfo.GetHttpClient()
+	if err != nil {
+		return nil, err
+	}
+
+	return &StackdriverExecutor{
+		httpClient: httpClient,
+		dsInfo:     dsInfo,
+	}, nil
+}
+
+func init() {
+	slog = log.New("tsdb.stackdriver")
+	tsdb.RegisterTsdbQueryEndpoint("stackdriver", NewStackdriverExecutor)
+	legendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
+	metricNameFormat = regexp.MustCompile(`([\w\d_]+)\.googleapis\.com/(.+)`)
+}
+
+// Query takes in the frontend queries, parses them into the Stackdriver query format
+// executes the queries against the Stackdriver API and parses the response into
+// the time series or table format
+func (e *StackdriverExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+	var result *tsdb.Response
+	var err error
+	queryType := tsdbQuery.Queries[0].Model.Get("type").MustString("")
+
+	switch queryType {
+	case "annotationQuery":
+		result, err = e.executeAnnotationQuery(ctx, tsdbQuery)
+	case "timeSeriesQuery":
+		fallthrough
+	default:
+		result, err = e.executeTimeSeriesQuery(ctx, tsdbQuery)
+	}
+
+	return result, err
+}
+
+func (e *StackdriverExecutor) executeTimeSeriesQuery(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+	result := &tsdb.Response{
+		Results: make(map[string]*tsdb.QueryResult),
+	}
+
+	queries, err := e.buildQueries(tsdbQuery)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, query := range queries {
+		queryRes, resp, err := e.executeQuery(ctx, query, tsdbQuery)
+		if err != nil {
+			return nil, err
+		}
+		err = e.parseResponse(queryRes, resp, query)
+		if err != nil {
+			queryRes.Error = err
+		}
+		result.Results[query.RefID] = queryRes
+	}
+
+	return result, nil
+}
+
+func (e *StackdriverExecutor) buildQueries(tsdbQuery *tsdb.TsdbQuery) ([]*StackdriverQuery, error) {
+	stackdriverQueries := []*StackdriverQuery{}
+
+	startTime, err := tsdbQuery.TimeRange.ParseFrom()
+	if err != nil {
+		return nil, err
+	}
+
+	endTime, err := tsdbQuery.TimeRange.ParseTo()
+	if err != nil {
+		return nil, err
+	}
+
+	durationSeconds := int(endTime.Sub(startTime).Seconds())
+
+	for _, query := range tsdbQuery.Queries {
+		var target string
+
+		metricType := query.Model.Get("metricType").MustString()
+		filterParts := query.Model.Get("filters").MustArray()
+
+		params := url.Values{}
+		params.Add("interval.startTime", startTime.UTC().Format(time.RFC3339))
+		params.Add("interval.endTime", endTime.UTC().Format(time.RFC3339))
+		params.Add("filter", buildFilterString(metricType, filterParts))
+		params.Add("view", query.Model.Get("view").MustString())
+		setAggParams(&params, query, durationSeconds)
+
+		target = params.Encode()
+
+		if setting.Env == setting.DEV {
+			slog.Debug("Stackdriver request", "params", params)
+		}
+
+		groupBys := query.Model.Get("groupBys").MustArray()
+		groupBysAsStrings := make([]string, 0)
+		for _, groupBy := range groupBys {
+			groupBysAsStrings = append(groupBysAsStrings, groupBy.(string))
+		}
+
+		aliasBy := query.Model.Get("aliasBy").MustString()
+
+		stackdriverQueries = append(stackdriverQueries, &StackdriverQuery{
+			Target:   target,
+			Params:   params,
+			RefID:    query.RefId,
+			GroupBys: groupBysAsStrings,
+			AliasBy:  aliasBy,
+		})
+	}
+
+	return stackdriverQueries, nil
+}
+
+func buildFilterString(metricType string, filterParts []interface{}) string {
+	filterString := ""
+	for i, part := range filterParts {
+		mod := i % 4
+		if part == "AND" {
+			filterString += " "
+		} else if mod == 2 {
+			filterString += fmt.Sprintf(`"%s"`, part)
+		} else {
+			filterString += part.(string)
+		}
+	}
+	return strings.Trim(fmt.Sprintf(`metric.type="%s" %s`, metricType, filterString), " ")
+}
+
+func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) {
+	primaryAggregation := query.Model.Get("primaryAggregation").MustString()
+	perSeriesAligner := query.Model.Get("perSeriesAligner").MustString()
+	alignmentPeriod := query.Model.Get("alignmentPeriod").MustString()
+
+	if primaryAggregation == "" {
+		primaryAggregation = "REDUCE_NONE"
+	}
+
+	if perSeriesAligner == "" {
+		perSeriesAligner = "ALIGN_MEAN"
+	}
+
+	if alignmentPeriod == "grafana-auto" || alignmentPeriod == "" {
+		alignmentPeriodValue := int(math.Max(float64(query.IntervalMs)/1000, 60.0))
+		alignmentPeriod = "+" + strconv.Itoa(alignmentPeriodValue) + "s"
+	}
+
+	if alignmentPeriod == "stackdriver-auto" {
+		alignmentPeriodValue := int(math.Max(float64(durationSeconds), 60.0))
+		if alignmentPeriodValue < 60*60*23 {
+			alignmentPeriod = "+60s"
+		} else if alignmentPeriodValue < 60*60*24*6 {
+			alignmentPeriod = "+300s"
+		} else {
+			alignmentPeriod = "+3600s"
+		}
+	}
+
+	re := regexp.MustCompile("[0-9]+")
+	seconds, err := strconv.ParseInt(re.FindString(alignmentPeriod), 10, 64)
+	if err != nil || seconds > 3600 {
+		alignmentPeriod = "+3600s"
+	}
+
+	params.Add("aggregation.crossSeriesReducer", primaryAggregation)
+	params.Add("aggregation.perSeriesAligner", perSeriesAligner)
+	params.Add("aggregation.alignmentPeriod", alignmentPeriod)
+
+	groupBys := query.Model.Get("groupBys").MustArray()
+	if len(groupBys) > 0 {
+		for i := 0; i < len(groupBys); i++ {
+			params.Add("aggregation.groupByFields", groupBys[i].(string))
+		}
+	}
+}
+
+func (e *StackdriverExecutor) executeQuery(ctx context.Context, query *StackdriverQuery, tsdbQuery *tsdb.TsdbQuery) (*tsdb.QueryResult, StackdriverResponse, error) {
+	queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID}
+
+	req, err := e.createRequest(ctx, e.dsInfo)
+	if err != nil {
+		queryResult.Error = err
+		return queryResult, StackdriverResponse{}, nil
+	}
+
+	req.URL.RawQuery = query.Params.Encode()
+	queryResult.Meta.Set("rawQuery", req.URL.RawQuery)
+	alignmentPeriod, ok := req.URL.Query()["aggregation.alignmentPeriod"]
+
+	if ok {
+		re := regexp.MustCompile("[0-9]+")
+		seconds, err := strconv.ParseInt(re.FindString(alignmentPeriod[0]), 10, 64)
+		if err == nil {
+			queryResult.Meta.Set("alignmentPeriod", seconds)
+		}
+	}
+
+	span, ctx := opentracing.StartSpanFromContext(ctx, "stackdriver query")
+	span.SetTag("target", query.Target)
+	span.SetTag("from", tsdbQuery.TimeRange.From)
+	span.SetTag("until", tsdbQuery.TimeRange.To)
+	span.SetTag("datasource_id", e.dsInfo.Id)
+	span.SetTag("org_id", e.dsInfo.OrgId)
+
+	defer span.Finish()
+
+	opentracing.GlobalTracer().Inject(
+		span.Context(),
+		opentracing.HTTPHeaders,
+		opentracing.HTTPHeadersCarrier(req.Header))
+
+	res, err := ctxhttp.Do(ctx, e.httpClient, req)
+	if err != nil {
+		queryResult.Error = err
+		return queryResult, StackdriverResponse{}, nil
+	}
+
+	data, err := e.unmarshalResponse(res)
+	if err != nil {
+		queryResult.Error = err
+		return queryResult, StackdriverResponse{}, nil
+	}
+
+	return queryResult, data, nil
+}
+
+func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (StackdriverResponse, error) {
+	body, err := ioutil.ReadAll(res.Body)
+	defer res.Body.Close()
+	if err != nil {
+		return StackdriverResponse{}, err
+	}
+
+	if res.StatusCode/100 != 2 {
+		slog.Error("Request failed", "status", res.Status, "body", string(body))
+		return StackdriverResponse{}, fmt.Errorf(string(body))
+	}
+
+	var data StackdriverResponse
+	err = json.Unmarshal(body, &data)
+	if err != nil {
+		slog.Error("Failed to unmarshal Stackdriver response", "error", err, "status", res.Status, "body", string(body))
+		return StackdriverResponse{}, err
+	}
+
+	return data, nil
+}
+
+func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery) error {
+	metricLabels := make(map[string][]string)
+	resourceLabels := make(map[string][]string)
+
+	for _, series := range data.TimeSeries {
+		points := make([]tsdb.TimePoint, 0)
+
+		// reverse the order to be ascending
+		for i := len(series.Points) - 1; i >= 0; i-- {
+			point := series.Points[i]
+			value := point.Value.DoubleValue
+
+			if series.ValueType == "INT64" {
+				parsedValue, err := strconv.ParseFloat(point.Value.IntValue, 64)
+				if err == nil {
+					value = parsedValue
+				}
+			}
+
+			if series.ValueType == "BOOL" {
+				if point.Value.BoolValue {
+					value = 1
+				} else {
+					value = 0
+				}
+			}
+
+			points = append(points, tsdb.NewTimePoint(null.FloatFrom(value), float64((point.Interval.EndTime).Unix())*1000))
+		}
+
+		defaultMetricName := series.Metric.Type
+
+		for key, value := range series.Metric.Labels {
+			if !containsLabel(metricLabels[key], value) {
+				metricLabels[key] = append(metricLabels[key], value)
+			}
+			if len(query.GroupBys) == 0 || containsLabel(query.GroupBys, "metric.label."+key) {
+				defaultMetricName += " " + value
+			}
+		}
+
+		for key, value := range series.Resource.Labels {
+			if !containsLabel(resourceLabels[key], value) {
+				resourceLabels[key] = append(resourceLabels[key], value)
+			}
+
+			if containsLabel(query.GroupBys, "resource.label."+key) {
+				defaultMetricName += " " + value
+			}
+		}
+
+		metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, series.Metric.Labels, series.Resource.Labels, query)
+
+		queryRes.Series = append(queryRes.Series, &tsdb.TimeSeries{
+			Name:   metricName,
+			Points: points,
+		})
+	}
+
+	queryRes.Meta.Set("resourceLabels", resourceLabels)
+	queryRes.Meta.Set("metricLabels", metricLabels)
+	queryRes.Meta.Set("groupBys", query.GroupBys)
+
+	return nil
+}
+
+func containsLabel(labels []string, newLabel string) bool {
+	for _, val := range labels {
+		if val == newLabel {
+			return true
+		}
+	}
+	return false
+}
+
+func formatLegendKeys(metricType string, defaultMetricName string, metricLabels map[string]string, resourceLabels map[string]string, query *StackdriverQuery) string {
+	if query.AliasBy == "" {
+		return defaultMetricName
+	}
+
+	result := legendKeyFormat.ReplaceAllFunc([]byte(query.AliasBy), func(in []byte) []byte {
+		metaPartName := strings.Replace(string(in), "{{", "", 1)
+		metaPartName = strings.Replace(metaPartName, "}}", "", 1)
+		metaPartName = strings.TrimSpace(metaPartName)
+
+		if metaPartName == "metric.type" {
+			return []byte(metricType)
+		}
+
+		metricPart := replaceWithMetricPart(metaPartName, metricType)
+
+		if metricPart != nil {
+			return metricPart
+		}
+
+		metaPartName = strings.Replace(metaPartName, "metric.label.", "", 1)
+
+		if val, exists := metricLabels[metaPartName]; exists {
+			return []byte(val)
+		}
+
+		metaPartName = strings.Replace(metaPartName, "resource.label.", "", 1)
+
+		if val, exists := resourceLabels[metaPartName]; exists {
+			return []byte(val)
+		}
+
+		return in
+	})
+
+	return string(result)
+}
+
+func replaceWithMetricPart(metaPartName string, metricType string) []byte {
+	// https://cloud.google.com/monitoring/api/v3/metrics-details#label_names
+	shortMatches := metricNameFormat.FindStringSubmatch(metricType)
+
+	if metaPartName == "metric.name" {
+		if len(shortMatches) > 0 {
+			return []byte(shortMatches[2])
+		}
+	}
+
+	if metaPartName == "metric.service" {
+		if len(shortMatches) > 0 {
+			return []byte(shortMatches[1])
+		}
+	}
+
+	return nil
+}
+
+func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models.DataSource) (*http.Request, error) {
+	u, _ := url.Parse(dsInfo.Url)
+	u.Path = path.Join(u.Path, "render")
+
+	req, err := http.NewRequest(http.MethodGet, "https://monitoring.googleapis.com/", nil)
+	if err != nil {
+		slog.Error("Failed to create request", "error", err)
+		return nil, fmt.Errorf("Failed to create request. error: %v", err)
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
+
+	// find plugin
+	plugin, ok := plugins.DataSources[dsInfo.Type]
+	if !ok {
+		return nil, errors.New("Unable to find datasource plugin Stackdriver")
+	}
+	projectName := dsInfo.JsonData.Get("defaultProject").MustString()
+	proxyPass := fmt.Sprintf("stackdriver%s", "v3/projects/"+projectName+"/timeSeries")
+
+	var stackdriverRoute *plugins.AppPluginRoute
+	for _, route := range plugin.Routes {
+		if route.Path == "stackdriver" {
+			stackdriverRoute = route
+			break
+		}
+	}
+
+	pluginproxy.ApplyRoute(ctx, req, proxyPass, stackdriverRoute, dsInfo)
+
+	return req, nil
+}

+ 360 - 0
pkg/tsdb/stackdriver/stackdriver_test.go

@@ -0,0 +1,360 @@
+package stackdriver
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/tsdb"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestStackdriver(t *testing.T) {
+	Convey("Stackdriver", t, func() {
+		executor := &StackdriverExecutor{}
+
+		Convey("Parse queries from frontend and build Stackdriver API queries", func() {
+			fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
+			tsdbQuery := &tsdb.TsdbQuery{
+				TimeRange: &tsdb.TimeRange{
+					From: fmt.Sprintf("%v", fromStart.Unix()*1000),
+					To:   fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000),
+				},
+				Queries: []*tsdb.Query{
+					{
+						Model: simplejson.NewFromAny(map[string]interface{}{
+							"metricType": "a/metric/type",
+							"view":       "FULL",
+							"aliasBy":    "testalias",
+							"type":       "timeSeriesQuery",
+						}),
+						RefId: "A",
+					},
+				},
+			}
+
+			Convey("and query has no aggregation set", func() {
+				queries, err := executor.buildQueries(tsdbQuery)
+				So(err, ShouldBeNil)
+
+				So(len(queries), ShouldEqual, 1)
+				So(queries[0].RefID, ShouldEqual, "A")
+				So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+				So(len(queries[0].Params), ShouldEqual, 7)
+				So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
+				So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
+				So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
+				So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
+				So(queries[0].Params["view"][0], ShouldEqual, "FULL")
+				So(queries[0].AliasBy, ShouldEqual, "testalias")
+			})
+
+			Convey("and query has filters", func() {
+				tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+					"metricType": "a/metric/type",
+					"filters":    []interface{}{"key", "=", "value", "AND", "key2", "=", "value2"},
+				})
+
+				queries, err := executor.buildQueries(tsdbQuery)
+				So(err, ShouldBeNil)
+				So(len(queries), ShouldEqual, 1)
+				So(queries[0].Params["filter"][0], ShouldEqual, `metric.type="a/metric/type" key="value" key2="value2"`)
+			})
+
+			Convey("and alignmentPeriod is set to grafana-auto", func() {
+				Convey("and IntervalMs is larger than 60000", func() {
+					tsdbQuery.Queries[0].IntervalMs = 1000000
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"alignmentPeriod": "grafana-auto",
+						"filters":         []interface{}{"key", "=", "value", "AND", "key2", "=", "value2"},
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+1000s`)
+				})
+				Convey("and IntervalMs is less than 60000", func() {
+					tsdbQuery.Queries[0].IntervalMs = 30000
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"alignmentPeriod": "grafana-auto",
+						"filters":         []interface{}{"key", "=", "value", "AND", "key2", "=", "value2"},
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`)
+				})
+			})
+
+			Convey("and alignmentPeriod is set to stackdriver-auto", func() {
+				Convey("and range is two hours", func() {
+					tsdbQuery.TimeRange.From = "1538033322461"
+					tsdbQuery.TimeRange.To = "1538040522461"
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"target":          "target",
+						"alignmentPeriod": "stackdriver-auto",
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`)
+				})
+
+				Convey("and range is 22 hours", func() {
+					tsdbQuery.TimeRange.From = "1538034524922"
+					tsdbQuery.TimeRange.To = "1538113724922"
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"target":          "target",
+						"alignmentPeriod": "stackdriver-auto",
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`)
+				})
+
+				Convey("and range is 23 hours", func() {
+					tsdbQuery.TimeRange.From = "1538034567985"
+					tsdbQuery.TimeRange.To = "1538117367985"
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"target":          "target",
+						"alignmentPeriod": "stackdriver-auto",
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+300s`)
+				})
+
+				Convey("and range is 7 days", func() {
+					tsdbQuery.TimeRange.From = "1538036324073"
+					tsdbQuery.TimeRange.To = "1538641124073"
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"target":          "target",
+						"alignmentPeriod": "stackdriver-auto",
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+3600s`)
+				})
+			})
+
+			Convey("and alignmentPeriod is set in frontend", func() {
+				Convey("and alignment period is too big", func() {
+					tsdbQuery.Queries[0].IntervalMs = 1000
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"alignmentPeriod": "+360000s",
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+3600s`)
+				})
+
+				Convey("and alignment period is within accepted range", func() {
+					tsdbQuery.Queries[0].IntervalMs = 1000
+					tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+						"alignmentPeriod": "+600s",
+					})
+
+					queries, err := executor.buildQueries(tsdbQuery)
+					So(err, ShouldBeNil)
+					So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+600s`)
+				})
+			})
+
+			Convey("and query has aggregation mean set", func() {
+				tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+					"metricType":         "a/metric/type",
+					"primaryAggregation": "REDUCE_MEAN",
+					"view":               "FULL",
+				})
+
+				queries, err := executor.buildQueries(tsdbQuery)
+				So(err, ShouldBeNil)
+
+				So(len(queries), ShouldEqual, 1)
+				So(queries[0].RefID, ShouldEqual, "A")
+				So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_MEAN&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+				So(len(queries[0].Params), ShouldEqual, 7)
+				So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
+				So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
+				So(queries[0].Params["aggregation.crossSeriesReducer"][0], ShouldEqual, "REDUCE_MEAN")
+				So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
+				So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, "+60s")
+				So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
+				So(queries[0].Params["view"][0], ShouldEqual, "FULL")
+			})
+
+			Convey("and query has group bys", func() {
+				tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+					"metricType":         "a/metric/type",
+					"primaryAggregation": "REDUCE_NONE",
+					"groupBys":           []interface{}{"metric.label.group1", "metric.label.group2"},
+					"view":               "FULL",
+				})
+
+				queries, err := executor.buildQueries(tsdbQuery)
+				So(err, ShouldBeNil)
+
+				So(len(queries), ShouldEqual, 1)
+				So(queries[0].RefID, ShouldEqual, "A")
+				So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+				So(len(queries[0].Params), ShouldEqual, 8)
+				So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
+				So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
+				So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
+				So(queries[0].Params["aggregation.groupByFields"][0], ShouldEqual, "metric.label.group1")
+				So(queries[0].Params["aggregation.groupByFields"][1], ShouldEqual, "metric.label.group2")
+				So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
+				So(queries[0].Params["view"][0], ShouldEqual, "FULL")
+			})
+
+		})
+
+		Convey("Parse stackdriver response in the time series format", func() {
+			Convey("when data from query aggregated to one time series", func() {
+				data, err := loadTestFile("./test-data/1-series-response-agg-one-metric.json")
+				So(err, ShouldBeNil)
+				So(len(data.TimeSeries), ShouldEqual, 1)
+
+				res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+				query := &StackdriverQuery{}
+				err = executor.parseResponse(res, data, query)
+				So(err, ShouldBeNil)
+
+				So(len(res.Series), ShouldEqual, 1)
+				So(res.Series[0].Name, ShouldEqual, "serviceruntime.googleapis.com/api/request_count")
+				So(len(res.Series[0].Points), ShouldEqual, 3)
+
+				Convey("timestamps should be in ascending order", func() {
+					So(res.Series[0].Points[0][0].Float64, ShouldEqual, 0.05)
+					So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1536670020000)
+
+					So(res.Series[0].Points[1][0].Float64, ShouldEqual, 1.05)
+					So(res.Series[0].Points[1][1].Float64, ShouldEqual, 1536670080000)
+
+					So(res.Series[0].Points[2][0].Float64, ShouldEqual, 1.0666666666667)
+					So(res.Series[0].Points[2][1].Float64, ShouldEqual, 1536670260000)
+				})
+			})
+
+			Convey("when data from query with no aggregation", func() {
+				data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+				So(err, ShouldBeNil)
+				So(len(data.TimeSeries), ShouldEqual, 3)
+
+				res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+				query := &StackdriverQuery{}
+				err = executor.parseResponse(res, data, query)
+				So(err, ShouldBeNil)
+
+				Convey("Should add labels to metric name", func() {
+					So(len(res.Series), ShouldEqual, 3)
+					So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-asia-east-1")
+					So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-europe-west-1")
+					So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-us-east-1")
+				})
+
+				Convey("Should parse to time series", func() {
+					So(len(res.Series[0].Points), ShouldEqual, 3)
+					So(res.Series[0].Points[0][0].Float64, ShouldEqual, 9.8566497180145)
+					So(res.Series[0].Points[1][0].Float64, ShouldEqual, 9.7323568146676)
+					So(res.Series[0].Points[2][0].Float64, ShouldEqual, 9.7730520330369)
+				})
+
+				Convey("Should add meta for labels to the response", func() {
+					metricLabels := res.Meta.Get("metricLabels").Interface().(map[string][]string)
+					So(metricLabels, ShouldNotBeNil)
+					So(len(metricLabels["instance_name"]), ShouldEqual, 3)
+					So(metricLabels["instance_name"][0], ShouldEqual, "collector-asia-east-1")
+					So(metricLabels["instance_name"][1], ShouldEqual, "collector-europe-west-1")
+					So(metricLabels["instance_name"][2], ShouldEqual, "collector-us-east-1")
+
+					resourceLabels := res.Meta.Get("resourceLabels").Interface().(map[string][]string)
+					So(resourceLabels, ShouldNotBeNil)
+					So(len(resourceLabels["zone"]), ShouldEqual, 3)
+					So(resourceLabels["zone"][0], ShouldEqual, "asia-east1-a")
+					So(resourceLabels["zone"][1], ShouldEqual, "europe-west1-b")
+					So(resourceLabels["zone"][2], ShouldEqual, "us-east1-b")
+
+					So(len(resourceLabels["project_id"]), ShouldEqual, 1)
+					So(resourceLabels["project_id"][0], ShouldEqual, "grafana-prod")
+				})
+			})
+
+			Convey("when data from query with no aggregation and group bys", func() {
+				data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+				So(err, ShouldBeNil)
+				So(len(data.TimeSeries), ShouldEqual, 3)
+
+				res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+				query := &StackdriverQuery{GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}
+				err = executor.parseResponse(res, data, query)
+				So(err, ShouldBeNil)
+
+				Convey("Should add instance name and zone labels to metric name", func() {
+					So(len(res.Series), ShouldEqual, 3)
+					So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-asia-east-1 asia-east1-a")
+					So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-europe-west-1 europe-west1-b")
+					So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-us-east-1 us-east1-b")
+				})
+			})
+
+			Convey("when data from query with no aggregation and alias by", func() {
+				data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+				So(err, ShouldBeNil)
+				So(len(data.TimeSeries), ShouldEqual, 3)
+
+				res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+
+				Convey("and the alias pattern is for metric type, a metric label and a resource label", func() {
+
+					query := &StackdriverQuery{AliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}
+					err = executor.parseResponse(res, data, query)
+					So(err, ShouldBeNil)
+
+					Convey("Should use alias by formatting and only show instance name", func() {
+						So(len(res.Series), ShouldEqual, 3)
+						So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time - collector-asia-east-1 - asia-east1-a")
+						So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time - collector-europe-west-1 - europe-west1-b")
+						So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time - collector-us-east-1 - us-east1-b")
+					})
+				})
+
+				Convey("and the alias pattern is for metric name", func() {
+
+					query := &StackdriverQuery{AliasBy: "metric {{metric.name}} service {{metric.service}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}
+					err = executor.parseResponse(res, data, query)
+					So(err, ShouldBeNil)
+
+					Convey("Should use alias by formatting and only show instance name", func() {
+						So(len(res.Series), ShouldEqual, 3)
+						So(res.Series[0].Name, ShouldEqual, "metric instance/cpu/usage_time service compute")
+						So(res.Series[1].Name, ShouldEqual, "metric instance/cpu/usage_time service compute")
+						So(res.Series[2].Name, ShouldEqual, "metric instance/cpu/usage_time service compute")
+					})
+				})
+			})
+		})
+	})
+}
+
+func loadTestFile(path string) (StackdriverResponse, error) {
+	var data StackdriverResponse
+
+	jsonBody, err := ioutil.ReadFile(path)
+	if err != nil {
+		return data, err
+	}
+	err = json.Unmarshal(jsonBody, &data)
+	if err != nil {
+		return data, err
+	}
+	return data, nil
+}

+ 46 - 0
pkg/tsdb/stackdriver/test-data/1-series-response-agg-one-metric.json

@@ -0,0 +1,46 @@
+{
+  "timeSeries": [
+    {
+      "metric": {
+        "type": "serviceruntime.googleapis.com\/api\/request_count"
+      },
+      "resource": {
+        "type": "consumed_api",
+        "labels": {
+          "project_id": "grafana-prod"
+        }
+      },
+      "metricKind": "GAUGE",
+      "valueType": "DOUBLE",
+      "points": [
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:51:00Z",
+            "endTime": "2018-09-11T12:51:00Z"
+          },
+          "value": {
+            "doubleValue": 1.0666666666667
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:48:00Z",
+            "endTime": "2018-09-11T12:48:00Z"
+          },
+          "value": {
+            "doubleValue": 1.05
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:47:00Z",
+            "endTime": "2018-09-11T12:47:00Z"
+          },
+          "value": {
+            "doubleValue": 0.05
+          }
+        }
+      ]
+    }
+  ]
+}

+ 145 - 0
pkg/tsdb/stackdriver/test-data/2-series-response-no-agg.json

@@ -0,0 +1,145 @@
+{
+  "timeSeries": [
+    {
+      "metric": {
+        "labels": {
+          "instance_name": "collector-asia-east-1"
+        },
+        "type": "compute.googleapis.com\/instance\/cpu\/usage_time"
+      },
+      "resource": {
+        "type": "gce_instance",
+        "labels": {
+          "instance_id": "1119268429530133111",
+          "zone": "asia-east1-a",
+          "project_id": "grafana-prod"
+        }
+      },
+      "metricKind": "DELTA",
+      "valueType": "DOUBLE",
+      "points": [
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:30:00Z",
+            "endTime": "2018-09-11T12:31:00Z"
+          },
+          "value": {
+            "doubleValue": 9.7730520330369
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:29:00Z",
+            "endTime": "2018-09-11T12:30:00Z"
+          },
+          "value": {
+            "doubleValue": 9.7323568146676
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:28:00Z",
+            "endTime": "2018-09-11T12:29:00Z"
+          },
+          "value": {
+            "doubleValue": 9.8566497180145
+          }
+        }
+      ]
+    },
+    {
+      "metric": {
+        "labels": {
+          "instance_name": "collector-europe-west-1"
+        },
+        "type": "compute.googleapis.com\/instance\/cpu\/usage_time"
+      },
+      "resource": {
+        "type": "gce_instance",
+        "labels": {
+          "instance_id": "22241654114540837222",
+          "zone": "europe-west1-b",
+          "project_id": "grafana-prod"
+        }
+      },
+      "metricKind": "DELTA",
+      "valueType": "DOUBLE",
+      "points": [
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:30:00Z",
+            "endTime": "2018-09-11T12:31:00Z"
+          },
+          "value": {
+            "doubleValue": 8.8210971239023
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:29:00Z",
+            "endTime": "2018-09-11T12:30:00Z"
+          },
+          "value": {
+            "doubleValue": 8.9689492364414
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:28:00Z",
+            "endTime": "2018-09-11T12:29:00Z"
+          },
+          "value": {
+            "doubleValue": 9.0238475054502
+          }
+        }
+      ]
+    },
+    {
+      "metric": {
+        "labels": {
+          "instance_name": "collector-us-east-1"
+        },
+        "type": "compute.googleapis.com\/instance\/cpu\/usage_time"
+      },
+      "resource": {
+        "type": "gce_instance",
+        "labels": {
+          "instance_id": "3332264424035095333",
+          "zone": "us-east1-b",
+          "project_id": "grafana-prod"
+        }
+      },
+      "metricKind": "DELTA",
+      "valueType": "DOUBLE",
+      "points": [
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:30:00Z",
+            "endTime": "2018-09-11T12:31:00Z"
+          },
+          "value": {
+            "doubleValue": 30.807846801355
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:29:00Z",
+            "endTime": "2018-09-11T12:30:00Z"
+          },
+          "value": {
+            "doubleValue": 30.903974115849
+          }
+        },
+        {
+          "interval": {
+            "startTime": "2018-09-11T12:28:00Z",
+            "endTime": "2018-09-11T12:29:00Z"
+          },
+          "value": {
+            "doubleValue": 30.829426143318
+          }
+        }
+      ]
+    }
+  ]
+}

+ 43 - 0
pkg/tsdb/stackdriver/types.go

@@ -0,0 +1,43 @@
+package stackdriver
+
+import (
+	"net/url"
+	"time"
+)
+
+// StackdriverQuery is the query that Grafana sends from the frontend
+type StackdriverQuery struct {
+	Target   string
+	Params   url.Values
+	RefID    string
+	GroupBys []string
+	AliasBy  string
+}
+
+// StackdriverResponse is the data returned from the external Google Stackdriver API
+type StackdriverResponse struct {
+	TimeSeries []struct {
+		Metric struct {
+			Labels map[string]string `json:"labels"`
+			Type   string            `json:"type"`
+		} `json:"metric"`
+		Resource struct {
+			Type   string            `json:"type"`
+			Labels map[string]string `json:"labels"`
+		} `json:"resource"`
+		MetricKind string `json:"metricKind"`
+		ValueType  string `json:"valueType"`
+		Points     []struct {
+			Interval struct {
+				StartTime time.Time `json:"startTime"`
+				EndTime   time.Time `json:"endTime"`
+			} `json:"interval"`
+			Value struct {
+				DoubleValue float64 `json:"doubleValue"`
+				StringValue string  `json:"stringValue"`
+				BoolValue   bool    `json:"boolValue"`
+				IntValue    string  `json:"int64Value"`
+			} `json:"value"`
+		} `json:"points"`
+	} `json:"timeSeries"`
+}

+ 10 - 5
public/app/features/dashboard/upload.ts

@@ -1,10 +1,12 @@
 import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import angular from 'angular';
 
 const template = `
-<input type="file" id="dashupload" name="dashupload" class="hide"/>
+<input type="file" id="dashupload" name="dashupload" class="hide" onchange="angular.element(this).scope().file_selected"/>
 <label class="btn btn-success" for="dashupload">
   <i class="fa fa-upload"></i>
-  Upload .json File
+  {{btnText}}
 </label>
 `;
 
@@ -15,8 +17,11 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
     template: template,
     scope: {
       onUpload: '&',
+      btnText: '@?',
     },
-    link: scope => {
+    link: (scope, elem) => {
+      scope.btnText = angular.isDefined(scope.btnText) ? scope.btnText : 'Upload .json File';
+
       function file_selected(evt) {
         const files = evt.target.files; // FileList object
         const readerOnload = () => {
@@ -26,7 +31,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
               dash = JSON.parse(e.target.result);
             } catch (err) {
               console.log(err);
-              scope.appEvent('alert-error', ['Import failed', 'JSON -> JS Serialization failed: ' + err.message]);
+              appEvents.emit('alert-error', ['Import failed', 'JSON -> JS Serialization failed: ' + err.message]);
               return;
             }
 
@@ -52,7 +57,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
       // Check for the various File API support.
       if (wnd.File && wnd.FileReader && wnd.FileList && wnd.Blob) {
         // Something
-        document.getElementById('dashupload').addEventListener('change', file_selected, false);
+        elem[0].addEventListener('change', file_selected, false);
       } else {
         alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error');
       }

+ 2 - 0
public/app/features/plugins/built_in_plugins.ts

@@ -11,6 +11,7 @@ import * as postgresPlugin from 'app/plugins/datasource/postgres/module';
 import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module';
 import * as mssqlPlugin from 'app/plugins/datasource/mssql/module';
 import * as testDataDSPlugin from 'app/plugins/datasource/testdata/module';
+import * as stackdriverPlugin from 'app/plugins/datasource/stackdriver/module';
 
 import * as textPanel from 'app/plugins/panel/text/module';
 import * as graphPanel from 'app/plugins/panel/graph/module';
@@ -36,6 +37,7 @@ const builtInPlugins = {
   'app/plugins/datasource/mssql/module': mssqlPlugin,
   'app/plugins/datasource/prometheus/module': prometheusPlugin,
   'app/plugins/datasource/testdata/module': testDataDSPlugin,
+  'app/plugins/datasource/stackdriver/module': stackdriverPlugin,
 
   'app/plugins/panel/text/module': textPanel,
   'app/plugins/panel/graph/module': graphPanel,

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

@@ -31,11 +31,16 @@
       </div>
     </div>
 
-    <div class="alert alert-info gf-form-group" ng-if="ctrl.datasourceMeta.state === 'alpha'">
+    <div class="grafana-info-box" ng-if="ctrl.datasourceMeta.state === 'alpha'">
       This plugin is marked as being in alpha state, which means it is in early development phase and
       updates will include breaking changes.
     </div>
 
+		<div class="grafana-info-box" ng-if="ctrl.datasourceMeta.state === 'beta'">
+      This plugin is marked as being in a beta development state. This means it is in currently in active development and could be
+      missing important features.
+    </div>
+
     <rebuild-on-change property="ctrl.datasourceMeta.id">
       <plugin-component type="datasource-config-ctrl">
       </plugin-component>

+ 7 - 0
public/app/plugins/datasource/stackdriver/README.md

@@ -0,0 +1,7 @@
+# Stackdriver Datasource - Native Plugin
+
+Grafana ships with built-in support for Google Stackdriver. You just have to add it as a datasource and you will be ready to build dashboards for your Stackdriver metrics.
+
+Read more about it here:
+
+[http://docs.grafana.org/datasources/stackdriver/](http://docs.grafana.org/datasources/stackdriver/)

+ 31 - 0
public/app/plugins/datasource/stackdriver/annotations_query_ctrl.ts

@@ -0,0 +1,31 @@
+import _ from 'lodash';
+import './query_filter_ctrl';
+
+export class StackdriverAnnotationsQueryCtrl {
+  static templateUrl = 'partials/annotations.editor.html';
+  annotation: any;
+  datasource: any;
+
+  defaultDropdownValue = 'Select Metric';
+  defaultServiceValue = 'All Services';
+
+  defaults = {
+    project: {
+      id: 'default',
+      name: 'loading project...',
+    },
+    metricType: this.defaultDropdownValue,
+    service: this.defaultServiceValue,
+    metric: '',
+    filters: [],
+    metricKind: '',
+    valueType: '',
+  };
+
+  /** @ngInject */
+  constructor() {
+    this.annotation.target = this.annotation.target || {};
+    this.annotation.target.refId = 'annotationQuery';
+    _.defaultsDeep(this.annotation.target, this.defaults);
+  }
+}

+ 74 - 0
public/app/plugins/datasource/stackdriver/config_ctrl.ts

@@ -0,0 +1,74 @@
+export class StackdriverConfigCtrl {
+  static templateUrl = 'public/app/plugins/datasource/stackdriver/partials/config.html';
+  datasourceSrv: any;
+  current: any;
+  jsonText: string;
+  validationErrors: string[] = [];
+  inputDataValid: boolean;
+
+  /** @ngInject */
+  constructor(datasourceSrv) {
+    this.datasourceSrv = datasourceSrv;
+    this.current.jsonData = this.current.jsonData || {};
+    this.current.secureJsonData = this.current.secureJsonData || {};
+    this.current.secureJsonFields = this.current.secureJsonFields || {};
+  }
+
+  save(jwt) {
+    this.current.secureJsonData.privateKey = jwt.private_key;
+    this.current.jsonData.tokenUri = jwt.token_uri;
+    this.current.jsonData.clientEmail = jwt.client_email;
+    this.current.jsonData.defaultProject = jwt.project_id;
+  }
+
+  validateJwt(jwt) {
+    this.resetValidationMessages();
+    if (!jwt.private_key || jwt.private_key.length === 0) {
+      this.validationErrors.push('Private key field missing in JWT file.');
+    }
+
+    if (!jwt.token_uri || jwt.token_uri.length === 0) {
+      this.validationErrors.push('Token URI field missing in JWT file.');
+    }
+
+    if (!jwt.client_email || jwt.client_email.length === 0) {
+      this.validationErrors.push('Client Email field missing in JWT file.');
+    }
+
+    if (this.validationErrors.length === 0) {
+      this.inputDataValid = true;
+      return true;
+    }
+
+    return false;
+  }
+
+  onUpload(json) {
+    this.jsonText = '';
+    if (this.validateJwt(json)) {
+      this.save(json);
+    }
+  }
+
+  onPasteJwt(e) {
+    try {
+      const json = JSON.parse(e.originalEvent.clipboardData.getData('text/plain') || this.jsonText);
+      if (this.validateJwt(json)) {
+        this.save(json);
+      }
+    } catch (error) {
+      this.resetValidationMessages();
+      this.validationErrors.push(`Invalid json: ${error.message}`);
+    }
+  }
+
+  resetValidationMessages() {
+    this.validationErrors = [];
+    this.inputDataValid = false;
+    this.jsonText = '';
+
+    this.current.jsonData = {};
+    this.current.secureJsonData = {};
+    this.current.secureJsonFields = {};
+  }
+}

+ 258 - 0
public/app/plugins/datasource/stackdriver/constants.ts

@@ -0,0 +1,258 @@
+export enum MetricKind {
+  METRIC_KIND_UNSPECIFIED = 'METRIC_KIND_UNSPECIFIED',
+  GAUGE = 'GAUGE',
+  DELTA = 'DELTA',
+  CUMULATIVE = 'CUMULATIVE',
+}
+
+export enum ValueTypes {
+  VALUE_TYPE_UNSPECIFIED = 'VALUE_TYPE_UNSPECIFIED',
+  BOOL = 'BOOL',
+  INT64 = 'INT64',
+  DOUBLE = 'DOUBLE',
+  STRING = 'STRING',
+  DISTRIBUTION = 'DISTRIBUTION',
+  MONEY = 'MONEY',
+}
+
+export const alignOptions = [
+  {
+    text: 'delta',
+    value: 'ALIGN_DELTA',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.CUMULATIVE, MetricKind.DELTA],
+  },
+  {
+    text: 'rate',
+    value: 'ALIGN_RATE',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.CUMULATIVE, MetricKind.DELTA],
+  },
+  {
+    text: 'interpolate',
+    value: 'ALIGN_INTERPOLATE',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE],
+  },
+  {
+    text: 'next older',
+    value: 'ALIGN_NEXT_OLDER',
+    valueTypes: [
+      ValueTypes.INT64,
+      ValueTypes.DOUBLE,
+      ValueTypes.MONEY,
+      ValueTypes.DISTRIBUTION,
+      ValueTypes.STRING,
+      ValueTypes.VALUE_TYPE_UNSPECIFIED,
+      ValueTypes.BOOL,
+    ],
+    metricKinds: [MetricKind.GAUGE],
+  },
+  {
+    text: 'min',
+    value: 'ALIGN_MIN',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'max',
+    value: 'ALIGN_MAX',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'mean',
+    value: 'ALIGN_MEAN',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'count',
+    value: 'ALIGN_COUNT',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.BOOL],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'sum',
+    value: 'ALIGN_SUM',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'stddev',
+    value: 'ALIGN_STDDEV',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'count true',
+    value: 'ALIGN_COUNT_TRUE',
+    valueTypes: [ValueTypes.BOOL],
+    metricKinds: [MetricKind.GAUGE],
+  },
+  {
+    text: 'count false',
+    value: 'ALIGN_COUNT_FALSE',
+    valueTypes: [ValueTypes.BOOL],
+    metricKinds: [MetricKind.GAUGE],
+  },
+  {
+    text: 'fraction true',
+    value: 'ALIGN_FRACTION_TRUE',
+    valueTypes: [ValueTypes.BOOL],
+    metricKinds: [MetricKind.GAUGE],
+  },
+  {
+    text: 'percentile 99',
+    value: 'ALIGN_PERCENTILE_99',
+    valueTypes: [ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'percentile 95',
+    value: 'ALIGN_PERCENTILE_95',
+    valueTypes: [ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'percentile 50',
+    value: 'ALIGN_PERCENTILE_50',
+    valueTypes: [ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'percentile 05',
+    value: 'ALIGN_PERCENTILE_05',
+    valueTypes: [ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'percent change',
+    value: 'ALIGN_PERCENT_CHANGE',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+];
+
+export const aggOptions = [
+  {
+    text: 'none',
+    value: 'REDUCE_NONE',
+    valueTypes: [
+      ValueTypes.INT64,
+      ValueTypes.DOUBLE,
+      ValueTypes.MONEY,
+      ValueTypes.DISTRIBUTION,
+      ValueTypes.BOOL,
+      ValueTypes.STRING,
+    ],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA, MetricKind.CUMULATIVE, MetricKind.METRIC_KIND_UNSPECIFIED],
+  },
+  {
+    text: 'mean',
+    value: 'REDUCE_MEAN',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'min',
+    value: 'REDUCE_MIN',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'max',
+    value: 'REDUCE_MAX',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'sum',
+    value: 'REDUCE_SUM',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'std. dev.',
+    value: 'REDUCE_STDDEV',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'count',
+    value: 'REDUCE_COUNT',
+    valueTypes: [
+      ValueTypes.INT64,
+      ValueTypes.DOUBLE,
+      ValueTypes.MONEY,
+      ValueTypes.DISTRIBUTION,
+      ValueTypes.BOOL,
+      ValueTypes.STRING,
+    ],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'count true',
+    value: 'REDUCE_COUNT_TRUE',
+    valueTypes: [ValueTypes.BOOL],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: 'count false',
+    value: 'REDUCE_COUNT_FALSE',
+    valueTypes: [ValueTypes.BOOL],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: '99th percentile',
+    value: 'REDUCE_PERCENTILE_99',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: '95th percentile',
+    value: 'REDUCE_PERCENTILE_95',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: '50th percentile',
+    value: 'REDUCE_PERCENTILE_50',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+  {
+    text: '5th percentile',
+    value: 'REDUCE_PERCENTILE_05',
+    valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+    metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+  },
+];
+
+export const alignmentPeriods = [
+  { text: 'grafana auto', value: 'grafana-auto' },
+  { text: 'stackdriver auto', value: 'stackdriver-auto' },
+  { text: '1m', value: '+60s' },
+  { text: '5m', value: '+300s' },
+  { text: '30m', value: '+1800s' },
+  { text: '1h', value: '+3600s' },
+  { text: '6h', value: '+21600s' },
+  { text: '1d', value: '+86400s' },
+  { text: '1w', value: '+604800s' },
+];
+
+export const stackdriverUnitMappings = {
+  bit: 'bits',
+  By: 'bytes',
+  s: 's',
+  min: 'm',
+  h: 'h',
+  d: 'd',
+  us: 'µs',
+  ms: 'ms',
+  ns: 'ns',
+  percent: 'percent',
+  MiBy: 'mbytes',
+  'By/s': 'Bps',
+  GBy: 'decgbytes',
+};

+ 264 - 0
public/app/plugins/datasource/stackdriver/datasource.ts

@@ -0,0 +1,264 @@
+import { stackdriverUnitMappings } from './constants';
+import appEvents from 'app/core/app_events';
+
+export default class StackdriverDatasource {
+  id: number;
+  url: string;
+  baseUrl: string;
+  projectName: string;
+
+  /** @ngInject */
+  constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) {
+    this.baseUrl = `/stackdriver/`;
+    this.url = instanceSettings.url;
+    this.doRequest = this.doRequest;
+    this.id = instanceSettings.id;
+    this.projectName = instanceSettings.jsonData.defaultProject || '';
+  }
+
+  async getTimeSeries(options) {
+    const queries = options.targets
+      .filter(target => {
+        return !target.hide && target.metricType;
+      })
+      .map(t => {
+        if (!t.hasOwnProperty('aggregation')) {
+          t.aggregation = {
+            crossSeriesReducer: 'REDUCE_MEAN',
+            groupBys: [],
+          };
+        }
+        return {
+          refId: t.refId,
+          intervalMs: options.intervalMs,
+          datasourceId: this.id,
+          metricType: this.templateSrv.replace(t.metricType, options.scopedVars || {}),
+          primaryAggregation: this.templateSrv.replace(t.aggregation.crossSeriesReducer, options.scopedVars || {}),
+          perSeriesAligner: this.templateSrv.replace(t.aggregation.perSeriesAligner, options.scopedVars || {}),
+          alignmentPeriod: this.templateSrv.replace(t.aggregation.alignmentPeriod, options.scopedVars || {}),
+          groupBys: this.interpolateGroupBys(t.aggregation.groupBys, options.scopedVars),
+          view: t.view || 'FULL',
+          filters: (t.filters || []).map(f => {
+            return this.templateSrv.replace(f, options.scopedVars || {});
+          }),
+          aliasBy: this.templateSrv.replace(t.aliasBy, options.scopedVars || {}),
+          type: 'timeSeriesQuery',
+        };
+      });
+
+    const { data } = await this.backendSrv.datasourceRequest({
+      url: '/api/tsdb/query',
+      method: 'POST',
+      data: {
+        from: options.range.from.valueOf().toString(),
+        to: options.range.to.valueOf().toString(),
+        queries,
+      },
+    });
+    return data;
+  }
+
+  async getLabels(metricType, refId) {
+    return await this.getTimeSeries({
+      targets: [
+        {
+          refId: refId,
+          datasourceId: this.id,
+          metricType: this.templateSrv.replace(metricType),
+          aggregation: {
+            crossSeriesReducer: 'REDUCE_NONE',
+          },
+          view: 'HEADERS',
+        },
+      ],
+      range: this.timeSrv.timeRange(),
+    });
+  }
+
+  interpolateGroupBys(groupBys: string[], scopedVars): string[] {
+    let interpolatedGroupBys = [];
+    (groupBys || []).forEach(gb => {
+      const interpolated = this.templateSrv.replace(gb, scopedVars || {}, 'csv').split(',');
+      if (Array.isArray(interpolated)) {
+        interpolatedGroupBys = interpolatedGroupBys.concat(interpolated);
+      } else {
+        interpolatedGroupBys.push(interpolated);
+      }
+    });
+    return interpolatedGroupBys;
+  }
+
+  resolvePanelUnitFromTargets(targets: any[]) {
+    let unit = 'none';
+    if (targets.length > 0 && targets.every(t => t.unit === targets[0].unit)) {
+      if (stackdriverUnitMappings.hasOwnProperty(targets[0].unit)) {
+        unit = stackdriverUnitMappings[targets[0].unit];
+      }
+    }
+    return unit;
+  }
+
+  async query(options) {
+    const result = [];
+    const data = await this.getTimeSeries(options);
+    if (data.results) {
+      Object['values'](data.results).forEach(queryRes => {
+        if (!queryRes.series) {
+          return;
+        }
+
+        const unit = this.resolvePanelUnitFromTargets(options.targets);
+        queryRes.series.forEach(series => {
+          result.push({
+            target: series.name,
+            datapoints: series.points,
+            refId: queryRes.refId,
+            meta: queryRes.meta,
+            unit,
+          });
+        });
+      });
+    }
+
+    return { data: result };
+  }
+
+  async annotationQuery(options) {
+    const annotation = options.annotation;
+    const queries = [
+      {
+        refId: 'annotationQuery',
+        datasourceId: this.id,
+        metricType: this.templateSrv.replace(annotation.target.metricType, options.scopedVars || {}),
+        primaryAggregation: 'REDUCE_NONE',
+        perSeriesAligner: 'ALIGN_NONE',
+        title: this.templateSrv.replace(annotation.target.title, options.scopedVars || {}),
+        text: this.templateSrv.replace(annotation.target.text, options.scopedVars || {}),
+        tags: this.templateSrv.replace(annotation.target.tags, options.scopedVars || {}),
+        view: 'FULL',
+        filters: (annotation.target.filters || []).map(f => {
+          return this.templateSrv.replace(f, options.scopedVars || {});
+        }),
+        type: 'annotationQuery',
+      },
+    ];
+
+    const { data } = await this.backendSrv.datasourceRequest({
+      url: '/api/tsdb/query',
+      method: 'POST',
+      data: {
+        from: options.range.from.valueOf().toString(),
+        to: options.range.to.valueOf().toString(),
+        queries,
+      },
+    });
+
+    const results = data.results['annotationQuery'].tables[0].rows.map(v => {
+      return {
+        annotation: annotation,
+        time: Date.parse(v[0]),
+        title: v[1],
+        tags: [],
+        text: v[3],
+      };
+    });
+
+    return results;
+  }
+
+  metricFindQuery(query) {
+    throw new Error('Template variables support is not yet imlemented');
+  }
+
+  testDatasource() {
+    const path = `v3/projects/${this.projectName}/metricDescriptors`;
+    return this.doRequest(`${this.baseUrl}${path}`)
+      .then(response => {
+        if (response.status === 200) {
+          return {
+            status: 'success',
+            message: 'Successfully queried the Stackdriver API.',
+            title: 'Success',
+          };
+        }
+
+        return {
+          status: 'error',
+          message: 'Returned http status code ' + response.status,
+        };
+      })
+      .catch(error => {
+        let message = 'Stackdriver: ';
+        message += error.statusText ? error.statusText + ': ' : '';
+
+        if (error.data && error.data.error && error.data.error.code) {
+          // 400, 401
+          message += error.data.error.code + '. ' + error.data.error.message;
+        } else {
+          message += 'Cannot connect to Stackdriver API';
+        }
+        return {
+          status: 'error',
+          message: message,
+        };
+      });
+  }
+
+  async getProjects() {
+    const response = await this.doRequest(`/cloudresourcemanager/v1/projects`);
+    return response.data.projects.map(p => ({ id: p.projectId, name: p.name }));
+  }
+
+  async getDefaultProject() {
+    try {
+      const projects = await this.getProjects();
+      if (projects && projects.length > 0) {
+        const test = projects.filter(p => p.id === this.projectName)[0];
+        return test;
+      } else {
+        throw new Error('No projects found');
+      }
+    } catch (error) {
+      let message = 'Projects cannot be fetched: ';
+      message += error.statusText ? error.statusText + ': ' : '';
+      if (error && error.data && error.data.error && error.data.error.message) {
+        if (error.data.error.code === 403) {
+          message += `
+            A list of projects could not be fetched from the Google Cloud Resource Manager API.
+            You might need to enable it first:
+            https://console.developers.google.com/apis/library/cloudresourcemanager.googleapis.com`;
+        } else {
+          message += error.data.error.code + '. ' + error.data.error.message;
+        }
+      } else {
+        message += 'Cannot connect to Stackdriver API';
+      }
+      appEvents.emit('ds-request-error', message);
+    }
+  }
+
+  async getMetricTypes(projectId: string) {
+    try {
+      const metricsApiPath = `v3/projects/${projectId}/metricDescriptors`;
+      const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`);
+      return data.metricDescriptors;
+    } catch (error) {
+      console.log(error);
+    }
+  }
+
+  async doRequest(url, maxRetries = 1) {
+    return this.backendSrv
+      .datasourceRequest({
+        url: this.url + url,
+        method: 'GET',
+      })
+      .catch(error => {
+        if (maxRetries > 0) {
+          return this.doRequest(url, maxRetries - 1);
+        }
+
+        throw error;
+      });
+  }
+}

+ 116 - 0
public/app/plugins/datasource/stackdriver/filter_segments.ts

@@ -0,0 +1,116 @@
+export const DefaultRemoveFilterValue = '-- remove filter --';
+export const DefaultFilterValue = 'select value';
+
+export class FilterSegments {
+  filterSegments: any[];
+  removeSegment: any;
+
+  constructor(private uiSegmentSrv, private target, private getFilterKeysFunc, private getFilterValuesFunc) {}
+
+  buildSegmentModel() {
+    this.removeSegment = this.uiSegmentSrv.newSegment({ fake: true, value: DefaultRemoveFilterValue });
+
+    this.filterSegments = [];
+    this.target.filters.forEach((f, index) => {
+      switch (index % 4) {
+        case 0:
+          this.filterSegments.push(this.uiSegmentSrv.newKey(f));
+          break;
+        case 1:
+          this.filterSegments.push(this.uiSegmentSrv.newOperator(f));
+          break;
+        case 2:
+          this.filterSegments.push(this.uiSegmentSrv.newKeyValue(f));
+          break;
+        case 3:
+          this.filterSegments.push(this.uiSegmentSrv.newCondition(f));
+          break;
+      }
+    });
+    this.ensurePlusButton(this.filterSegments);
+  }
+
+  async getFilters(segment, index, hasNoFilterKeys) {
+    if (segment.type === 'condition') {
+      return [this.uiSegmentSrv.newSegment('AND')];
+    }
+
+    if (segment.type === 'operator') {
+      return this.uiSegmentSrv.newOperators(['=', '!=', '=~', '!=~']);
+    }
+
+    if (segment.type === 'key' || segment.type === 'plus-button') {
+      if (hasNoFilterKeys && segment.value && segment.value !== DefaultRemoveFilterValue) {
+        this.removeSegment.value = DefaultRemoveFilterValue;
+        return Promise.resolve([this.removeSegment]);
+      } else {
+        return this.getFilterKeysFunc();
+      }
+    }
+
+    if (segment.type === 'value') {
+      const filterValues = this.getFilterValuesFunc(index);
+
+      if (filterValues.length > 0) {
+        return this.getValuesForFilterKey(filterValues);
+      }
+    }
+
+    return [];
+  }
+
+  getValuesForFilterKey(labels: any[]) {
+    const filterValues = labels.map(l => {
+      return this.uiSegmentSrv.newSegment({
+        value: `${l}`,
+        expandable: false,
+      });
+    });
+
+    return filterValues;
+  }
+
+  addNewFilterSegments(segment, index) {
+    if (index > 2) {
+      this.filterSegments.splice(index, 0, this.uiSegmentSrv.newCondition('AND'));
+    }
+    segment.type = 'key';
+    this.filterSegments.push(this.uiSegmentSrv.newOperator('='));
+    this.filterSegments.push(this.uiSegmentSrv.newFake(DefaultFilterValue, 'value', 'query-segment-value'));
+  }
+
+  removeFilterSegment(index) {
+    this.filterSegments.splice(index, 3);
+    // remove trailing condition
+    if (index > 2 && this.filterSegments[index - 1].type === 'condition') {
+      this.filterSegments.splice(index - 1, 1);
+    }
+
+    // remove condition if it is first segment
+    if (index === 0 && this.filterSegments[0].type === 'condition') {
+      this.filterSegments.splice(0, 1);
+    }
+  }
+
+  ensurePlusButton(segments) {
+    const count = segments.length;
+    const lastSegment = segments[Math.max(count - 1, 0)];
+
+    if (!lastSegment || lastSegment.type !== 'plus-button') {
+      segments.push(this.uiSegmentSrv.newPlusButton());
+    }
+  }
+
+  filterSegmentUpdated(segment, index) {
+    if (segment.type === 'plus-button') {
+      this.addNewFilterSegments(segment, index);
+    } else if (segment.type === 'key' && segment.value === DefaultRemoveFilterValue) {
+      this.removeFilterSegment(index);
+      this.ensurePlusButton(this.filterSegments);
+    } else if (segment.type === 'value' && segment.value !== DefaultFilterValue) {
+      this.ensurePlusButton(this.filterSegments);
+    }
+
+    return this.filterSegments.filter(s => s.type !== 'plus-button').map(seg => seg.value);
+  }
+}

BIN
public/app/plugins/datasource/stackdriver/img/stackdriver_logo.png


+ 11 - 0
public/app/plugins/datasource/stackdriver/module.ts

@@ -0,0 +1,11 @@
+import StackdriverDatasource from './datasource';
+import { StackdriverQueryCtrl } from './query_ctrl';
+import { StackdriverConfigCtrl } from './config_ctrl';
+import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl';
+
+export {
+  StackdriverDatasource as Datasource,
+  StackdriverQueryCtrl as QueryCtrl,
+  StackdriverConfigCtrl as ConfigCtrl,
+  StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl,
+};

+ 37 - 0
public/app/plugins/datasource/stackdriver/partials/annotations.editor.html

@@ -0,0 +1,37 @@
+<stackdriver-filter target="ctrl.annotation.target" refresh="ctrl.refresh()" datasource="ctrl.datasource"
+  default-dropdown-value="ctrl.defaultDropdownValue" default-service-value="ctrl.defaultServiceValue" hide-group-bys="true"></stackdriver-filter>
+
+<div class="gf-form gf-form-inline">
+  <div class="gf-form">
+    <span class="gf-form-label query-keyword width-9">Title</span>
+    <input type="text" class="gf-form-input width-20" ng-model="ctrl.annotation.target.title" />
+  </div>
+  <div class="gf-form">
+    <span class="gf-form-label query-keyword width-9">Text</span>
+    <input type="text" class="gf-form-input width-20" ng-model="ctrl.annotation.target.text" />
+  </div>
+  <div class="gf-form gf-form--grow">
+    <div class="gf-form-label gf-form-label--grow"></div>
+  </div>
+</div>
+
+<div class="gf-form grafana-info-box" style="padding: 0">
+  <pre class="gf-form-pre alert alert-info" style="margin-right: 0"><h5>Annotation Query Format</h5>
+An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is important to limit the number of rows returned.
+
+The Title and Text fields support templating and can use data returned from the query. For example, the Title field could have the following text:
+
+<code ng-non-bindable>{{metric.type}} has value: {{metric.value}}</code>
+
+Example Result: <code ng-non-bindable>monitoring.googleapis.com/uptime_check/http_status has this value: 502</code>
+
+<label>Patterns:</label>
+<code ng-non-bindable>{{metric.value}}</code> = value of the metric/point
+<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
+<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
+<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
+
+<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g. metric.label.instance_name
+<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
+</pre>
+</div>

+ 84 - 0
public/app/plugins/datasource/stackdriver/partials/config.html

@@ -0,0 +1,84 @@
+<div class="gf-form-group">
+  <div class="grafana-info-box">
+    <h5>GCP Service Account</h5>
+    <p>
+      To authenticate with the Stackdriver API, you need to create a Google Cloud Platform (GCP) Service Account for
+      the Project you want to show data for. A Grafana datasource integrates with one GCP Project. If you want to
+      visualize data from multiple GCP Projects then you need to create one datasource per GCP Project.
+    </p>
+    <p>
+      The <strong>Monitoring Viewer</strong> role provides all the permissions that Grafana needs.
+    </p>
+    <p>
+      The following APIs need to be enabled on GCP for the datasource to work:
+      <ul>
+        <li><a class="external-link" target="_blank" href="https://console.cloud.google.com/apis/library/monitoring.googleapis.com">Monitoring
+            API</a></li>
+        <li><a class="external-link" target="_blank" href="https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com">Resource
+            Manager API</a></li>
+      </ul>
+    </p>
+    <p>Detailed instructions on how to create a Service Account can be found <a class="external-link" target="_blank"
+        href="http://docs.grafana.org/datasources/stackdriver/">in
+        the documentation.</a></p>
+  </div>
+</div>
+
+<div class="gf-form-group">
+  <div class="gf-form">
+    <h3>Service Account Authentication</h3>
+    <info-popover mode="header">Upload your Service Account key file or paste in the contents of the file. The file
+      contents will be encrypted and saved in the Grafana database.</info-popover>
+  </div>
+
+  <div ng-if="!ctrl.current.jsonData.clientEmail && !ctrl.inputDataValid">
+    <div class="gf-form-group" ng-if="!ctrl.inputDataValid">
+      <div class="gf-form">
+        <form>
+          <dash-upload on-upload="ctrl.onUpload(dash)" btn-text="Upload Service Account key file"></dash-upload>
+        </form>
+      </div>
+    </div>
+    <div class="gf-form-group">
+      <h5 class="section-heading" ng-if="!ctrl.inputDataValid">Or paste Service Account key JSON</h5>
+      <div class="gf-form" ng-if="!ctrl.inputDataValid">
+        <textarea rows="10" data-share-panel-url="" class="gf-form-input" ng-model="ctrl.jsonText" ng-paste="ctrl.onPasteJwt($event)"></textarea>
+      </div>
+      <div ng-repeat="valError in ctrl.validationErrors" class="text-error p-l-1">
+        <i class="fa fa-warning"></i>
+        {{valError}}
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="gf-form-group" ng-if="ctrl.inputDataValid || ctrl.current.jsonData.clientEmail">
+  <h6>Uploaded Key Details</h6>
+
+  <div class="gf-form">
+    <span class="gf-form-label width-9">Project</span>
+    <input class="gf-form-input width-40" disabled type="text" ng-model="ctrl.current.jsonData.defaultProject" />
+  </div>
+  <div class="gf-form">
+      <span class="gf-form-label width-9">Client Email</span>
+      <input class="gf-form-input width-40" disabled type="text" ng-model="ctrl.current.jsonData.clientEmail" />
+    </div>
+  <div class="gf-form">
+    <span class="gf-form-label width-9">Token URI</span>
+    <input class="gf-form-input width-40" disabled type="text" ng-model='ctrl.current.jsonData.tokenUri' />
+  </div>
+  <div class="gf-form" ng-if="ctrl.current.secureJsonFields.privateKey">
+    <span class="gf-form-label width-9">Private Key</span>
+    <input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
+  </div>
+
+  <div class="gf-form width-18">
+    <a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.resetValidationMessages()">Reset Service
+      Account Key </a>
+    <info-popover mode="right-normal">
+      Reset to clear the uploaded key and upload a new file.
+    </info-popover>
+  </div>
+</div>
+
+<p class="gf-form-label" ng-hide="ctrl.current.secureJsonFields.privateKey"><i class="fa fa-save"></i> Do not forget to save your changes after uploading a file.</p>

+ 46 - 0
public/app/plugins/datasource/stackdriver/partials/query.aggregation.html

@@ -0,0 +1,46 @@
+<div class="gf-form-inline">
+  <div class="gf-form">
+    <label class="gf-form-label query-keyword width-9">Aggregation</label>
+    <div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
+      <select class="gf-form-input width-12" ng-model="ctrl.target.aggregation.crossSeriesReducer" ng-options="f.value as f.text for f in ctrl.aggOptions"
+        ng-change="refresh()"></select>
+    </div>
+  </div>
+  <div class="gf-form gf-form--grow">
+    <label class="gf-form-label gf-form-label--grow">
+      <a ng-click="ctrl.target.showAggregationOptions = !ctrl.target.showAggregationOptions">
+        <i class="fa fa-caret-down" ng-show="ctrl.target.showAggregationOptions"></i>
+        <i class="fa fa-caret-right" ng-hide="ctrl.target.showAggregationOptions"></i>
+        Advanced Options
+      </a>
+    </label>
+  </div>
+</div>
+<div class="gf-form-group" ng-if="ctrl.target.showAggregationOptions">
+  <div class="gf-form offset-width-9">
+    <label class="gf-form-label query-keyword width-12">Aligner</label>
+    <div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
+      <select class="gf-form-input width-14" ng-model="ctrl.target.aggregation.perSeriesAligner" ng-options="f.value as f.text for f in ctrl.alignOptions"
+        ng-change="refresh()"></select>
+    </div>
+
+    <div class="gf-form gf-form--grow">
+      <div class="gf-form-label gf-form-label--grow"></div>
+    </div>
+  </div>
+</div>
+<div class="gf-form-inline">
+  <div class="gf-form">
+    <label class="gf-form-label query-keyword width-9">Alignment Period</label>
+    <div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
+      <select class="gf-form-input width-12" ng-model="ctrl.target.aggregation.alignmentPeriod" ng-options="f.value as f.text for f in ctrl.alignmentPeriods"
+        ng-change="refresh()"></select>
+    </div>
+  </div>
+
+  <div class="gf-form gf-form--grow">
+    <label ng-if="alignmentPeriod" class="gf-form-label gf-form-label--grow">
+      {{ctrl.formatAlignmentText()}}
+    </label>
+  </div>
+</div>

+ 62 - 0
public/app/plugins/datasource/stackdriver/partials/query.editor.html

@@ -0,0 +1,62 @@
+<query-editor-row query-ctrl="ctrl" has-text-edit-mode="false">
+  <stackdriver-filter target="ctrl.target" refresh="ctrl.refresh()" datasource="ctrl.datasource" default-dropdown-value="ctrl.defaultDropdownValue"
+    default-service-value="ctrl.defaultServiceValue"></stackdriver-filter>
+  <stackdriver-aggregation target="ctrl.target" alignment-period="ctrl.lastQueryMeta.alignmentPeriod" refresh="ctrl.refresh()"></stackdriver-aggregation>
+  <div class="gf-form-inline">
+    <div class="gf-form">
+      <span class="gf-form-label query-keyword width-9">Alias By</span>
+      <input type="text" class="gf-form-input width-30" ng-model="ctrl.target.aliasBy" ng-change="ctrl.refresh()"
+        ng-model-options="{ debounce: 500 }" />
+    </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">
+      <span class="gf-form-label width-9">Project</span>
+      <input class="gf-form-input" disabled type="text" ng-model='ctrl.target.project.name' get-options="ctrl.getProjects()"
+        css-class="min-width-12" />
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
+        Show Help
+        <i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
+        <i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
+      </label>
+    </div>
+    <div class="gf-form" ng-show="ctrl.lastQueryMeta">
+      <label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuery = !ctrl.showLastQuery">
+        Raw Query
+        <i class="fa fa-caret-down" ng-show="ctrl.showLastQuery"></i>
+        <i class="fa fa-caret-right" ng-hide="ctrl.showLastQuery"></i>
+      </label>
+    </div>
+    <div class="gf-form gf-form--grow">
+      <div class="gf-form-label gf-form-label--grow"></div>
+    </div>
+  </div>
+
+  <div class="gf-form" ng-show="ctrl.showLastQuery">
+    <pre class="gf-form-pre">{{ctrl.lastQueryMeta.rawQueryString}}</pre>
+  </div>
+  <div class="gf-form grafana-info-box" style="padding: 0" ng-show="ctrl.showHelp">
+<pre class="gf-form-pre alert alert-info"  style="margin-right: 0"><h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns.
+
+<label>Example: </label><code ng-non-bindable>{{metric.name}} - {{metric.label.instance_name}}</code>
+
+<label>Result: </label><code ng-non-bindable>cpu/usage_time - server1-europe-west-1</code>
+
+<label>Patterns:</label>
+<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
+<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
+<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
+
+<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g. metric.label.instance_name
+<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
+</pre>
+  </div>
+  <div class="gf-form" ng-show="ctrl.lastQueryError">
+    <pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
+  </div>
+</query-editor-row>

+ 37 - 0
public/app/plugins/datasource/stackdriver/partials/query.filter.html

@@ -0,0 +1,37 @@
+<div class="gf-form-inline">
+  <div class="gf-form">
+    <span class="gf-form-label width-9">Service</span>
+    <gf-form-dropdown model="ctrl.service" get-options="ctrl.services" class="min-width-20" disabled type="text"
+      allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onServiceChange(ctrl.service)"></gf-form-dropdown>
+  </div>
+  <div class="gf-form">
+    <span class="gf-form-label width-9">Metric</span>
+    <gf-form-dropdown model="ctrl.metricType" get-options="ctrl.metrics" class="min-width-20" disabled type="text"
+      allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onMetricTypeChange()"></gf-form-dropdown>
+  </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">
+    <span class="gf-form-label query-keyword width-9">Filter</span>
+    <div class="gf-form" ng-repeat="segment in ctrl.filterSegments.filterSegments">
+      <metric-segment segment="segment" get-options="ctrl.getFilters(segment, $index)" on-change="ctrl.filterSegmentUpdated(segment, $index)"></metric-segment>
+    </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" ng-hide="ctrl.$scope.hideGroupBys">
+  <div class="gf-form">
+    <span class="gf-form-label query-keyword width-9">Group By</span>
+    <div class="gf-form" ng-repeat="segment in ctrl.groupBySegments">
+      <metric-segment segment="segment" get-options="ctrl.getGroupBys(segment, $index)" on-change="ctrl.groupByChanged(segment, $index)"></metric-segment>
+    </div>
+  </div>
+  <div class="gf-form gf-form--grow">
+    <div class="gf-form-label gf-form-label--grow"></div>
+  </div>
+</div>

+ 56 - 0
public/app/plugins/datasource/stackdriver/plugin.json

@@ -0,0 +1,56 @@
+{
+  "name": "Stackdriver",
+  "type": "datasource",
+  "id": "stackdriver",
+  "metrics": true,
+  "alerting": true,
+  "annotations": true,
+  "state": "beta",
+  "queryOptions": {
+    "maxDataPoints": true,
+    "cacheTimeout": true
+  },
+  "info": {
+    "description": "Google Stackdriver Datasource for Grafana",
+    "version": "1.0.0",
+    "logos": {
+      "small": "img/stackdriver_logo.png",
+      "large": "img/stackdriver_logo.png"
+    },
+    "author": {
+      "name": "Grafana Project",
+      "url": "https://grafana.com"
+    }
+  },
+  "routes": [
+    {
+      "path": "stackdriver",
+      "method": "GET",
+      "url": "https://content-monitoring.googleapis.com",
+      "jwtTokenAuth": {
+        "scopes": [
+          "https://www.googleapis.com/auth/monitoring.read",
+          "https://www.googleapis.com/auth/cloudplatformprojects.readonly"
+        ],
+        "params": {
+          "token_uri": "{{.JsonData.tokenUri}}",
+          "client_email": "{{.JsonData.clientEmail}}",
+          "private_key": "{{.SecureJsonData.privateKey}}"
+        }
+      }
+    },
+    {
+      "path": "cloudresourcemanager",
+      "method": "GET",
+      "url": "https://cloudresourcemanager.googleapis.com",
+      "jwtTokenAuth": {
+        "scopes": ["https://www.googleapis.com/auth/cloudplatformprojects.readonly"],
+        "params": {
+          "token_uri": "{{.JsonData.tokenUri}}",
+          "client_email": "{{.JsonData.clientEmail}}",
+          "private_key": "{{.SecureJsonData.privateKey}}"
+        }
+      }
+    }
+  ]
+}

+ 86 - 0
public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts

@@ -0,0 +1,86 @@
+import angular from 'angular';
+import _ from 'lodash';
+import * as options from './constants';
+import kbn from 'app/core/utils/kbn';
+
+export class StackdriverAggregation {
+  constructor() {
+    return {
+      templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.aggregation.html',
+      controller: 'StackdriverAggregationCtrl',
+      restrict: 'E',
+      scope: {
+        target: '=',
+        alignmentPeriod: '<',
+        refresh: '&',
+      },
+    };
+  }
+}
+
+export class StackdriverAggregationCtrl {
+  alignmentPeriods: any[];
+  aggOptions: any[];
+  alignOptions: any[];
+  target: any;
+
+  constructor(private $scope) {
+    this.$scope.ctrl = this;
+    this.target = $scope.target;
+    this.alignmentPeriods = options.alignmentPeriods;
+    this.aggOptions = options.aggOptions;
+    this.alignOptions = options.alignOptions;
+    this.setAggOptions();
+    this.setAlignOptions();
+    const self = this;
+    $scope.$on('metricTypeChanged', () => {
+      self.setAggOptions();
+      self.setAlignOptions();
+    });
+  }
+
+  setAlignOptions() {
+    this.alignOptions = !this.target.valueType
+      ? []
+      : options.alignOptions.filter(i => {
+          return (
+            i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
+          );
+        });
+    if (!this.alignOptions.find(o => o.value === this.target.aggregation.perSeriesAligner)) {
+      this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : '';
+    }
+  }
+
+  setAggOptions() {
+    this.aggOptions = !this.target.metricKind
+      ? []
+      : options.aggOptions.filter(i => {
+          return (
+            i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
+          );
+        });
+
+    if (!this.aggOptions.find(o => o.value === this.target.aggregation.crossSeriesReducer)) {
+      this.deselectAggregationOption('REDUCE_NONE');
+    }
+
+    if (this.target.aggregation.groupBys.length > 0) {
+      this.aggOptions = this.aggOptions.filter(o => o.value !== 'REDUCE_NONE');
+      this.deselectAggregationOption('REDUCE_NONE');
+    }
+  }
+
+  formatAlignmentText() {
+    const selectedAlignment = this.alignOptions.find(ap => ap.value === this.target.aggregation.perSeriesAligner);
+    return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${selectedAlignment.text})`;
+  }
+
+  deselectAggregationOption(notValidOptionValue: string) {
+    const newValue = this.aggOptions.find(o => o.value !== notValidOptionValue);
+    this.target.aggregation.crossSeriesReducer = newValue ? newValue.value : '';
+  }
+}
+
+angular.module('grafana.controllers').directive('stackdriverAggregation', StackdriverAggregation);
+angular.module('grafana.controllers').controller('StackdriverAggregationCtrl', StackdriverAggregationCtrl);

+ 106 - 0
public/app/plugins/datasource/stackdriver/query_ctrl.ts

@@ -0,0 +1,106 @@
+import _ from 'lodash';
+import { QueryCtrl } from 'app/plugins/sdk';
+import './query_aggregation_ctrl';
+import './query_filter_ctrl';
+
+export interface QueryMeta {
+  alignmentPeriod: string;
+  rawQuery: string;
+  rawQueryString: string;
+  metricLabels: { [key: string]: string[] };
+  resourceLabels: { [key: string]: string[] };
+}
+
+export class StackdriverQueryCtrl extends QueryCtrl {
+  static templateUrl = 'partials/query.editor.html';
+  target: {
+    project: {
+      id: string;
+      name: string;
+    };
+    unit: string;
+    metricType: string;
+    service: string;
+    refId: string;
+    aggregation: {
+      crossSeriesReducer: string;
+      alignmentPeriod: string;
+      perSeriesAligner: string;
+      groupBys: string[];
+    };
+    filters: string[];
+    aliasBy: string;
+    metricKind: any;
+    valueType: any;
+  };
+
+  defaultDropdownValue = 'Select Metric';
+  defaultServiceValue = 'All Services';
+
+  defaults = {
+    project: {
+      id: 'default',
+      name: 'loading project...',
+    },
+    metricType: this.defaultDropdownValue,
+    service: this.defaultServiceValue,
+    metric: '',
+    unit: '',
+    aggregation: {
+      crossSeriesReducer: 'REDUCE_MEAN',
+      alignmentPeriod: 'stackdriver-auto',
+      perSeriesAligner: 'ALIGN_MEAN',
+      groupBys: [],
+    },
+    filters: [],
+    showAggregationOptions: false,
+    aliasBy: '',
+    metricKind: '',
+    valueType: '',
+  };
+
+  showHelp: boolean;
+  showLastQuery: boolean;
+  lastQueryMeta: QueryMeta;
+  lastQueryError?: string;
+
+  /** @ngInject */
+  constructor($scope, $injector) {
+    super($scope, $injector);
+    _.defaultsDeep(this.target, this.defaults);
+
+    this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
+    this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
+  }
+
+  onDataReceived(dataList) {
+    this.lastQueryError = null;
+    this.lastQueryMeta = null;
+
+    const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId });
+    if (anySeriesFromQuery) {
+      this.lastQueryMeta = anySeriesFromQuery.meta;
+      this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
+    }
+  }
+
+  onDataError(err) {
+    if (err.data && err.data.results) {
+      const queryRes = err.data.results[this.target.refId];
+      if (queryRes && queryRes.error) {
+        this.lastQueryMeta = queryRes.meta;
+        this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
+
+        let jsonBody;
+        try {
+          jsonBody = JSON.parse(queryRes.error);
+        } catch {
+          this.lastQueryError = queryRes.error;
+        }
+
+        this.lastQueryError = jsonBody.error.message;
+      }
+    }
+    console.error(err);
+  }
+}

+ 288 - 0
public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts

@@ -0,0 +1,288 @@
+import angular from 'angular';
+import _ from 'lodash';
+import { FilterSegments, DefaultRemoveFilterValue } from './filter_segments';
+import appEvents from 'app/core/app_events';
+
+export class StackdriverFilter {
+  constructor() {
+    return {
+      templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
+      controller: 'StackdriverFilterCtrl',
+      controllerAs: 'ctrl',
+      restrict: 'E',
+      scope: {
+        target: '=',
+        datasource: '=',
+        refresh: '&',
+        defaultDropdownValue: '<',
+        defaultServiceValue: '<',
+        hideGroupBys: '<',
+      },
+    };
+  }
+}
+
+export class StackdriverFilterCtrl {
+  metricLabels: { [key: string]: string[] };
+  resourceLabels: { [key: string]: string[] };
+
+  defaultRemoveGroupByValue = '-- remove group by --';
+  loadLabelsPromise: Promise<any>;
+
+  service: string;
+  metricType: string;
+  metricDescriptors: any[];
+  metrics: any[];
+  services: any[];
+  groupBySegments: any[];
+  filterSegments: FilterSegments;
+  removeSegment: any;
+  target: any;
+  datasource: any;
+
+  /** @ngInject */
+  constructor(private $scope, private uiSegmentSrv, private templateSrv, private $rootScope) {
+    this.datasource = $scope.datasource;
+    this.target = $scope.target;
+    this.metricType = $scope.defaultDropdownValue;
+    this.service = $scope.defaultServiceValue;
+
+    this.metricDescriptors = [];
+    this.metrics = [];
+    this.services = [];
+
+    this.getCurrentProject()
+      .then(this.loadMetricDescriptors.bind(this))
+      .then(this.getLabels.bind(this));
+
+    this.initSegments($scope.hideGroupBys);
+  }
+
+  initSegments(hideGroupBys: boolean) {
+    if (!hideGroupBys) {
+      this.groupBySegments = this.target.aggregation.groupBys.map(groupBy => {
+        return this.uiSegmentSrv.getSegmentForValue(groupBy);
+      });
+      this.ensurePlusButton(this.groupBySegments);
+    }
+
+    this.removeSegment = this.uiSegmentSrv.newSegment({ fake: true, value: '-- remove group by --' });
+
+    this.filterSegments = new FilterSegments(
+      this.uiSegmentSrv,
+      this.target,
+      this.getGroupBys.bind(this, null, null, DefaultRemoveFilterValue, false),
+      this.getFilterValues.bind(this)
+    );
+    this.filterSegments.buildSegmentModel();
+  }
+
+  async getCurrentProject() {
+    this.target.project = await this.datasource.getDefaultProject();
+  }
+
+  async loadMetricDescriptors() {
+    if (this.target.project.id !== 'default') {
+      this.metricDescriptors = await this.datasource.getMetricTypes(this.target.project.id);
+      this.services = this.getServicesList();
+      this.metrics = this.getMetricsList();
+      return this.metricDescriptors;
+    } else {
+      return [];
+    }
+  }
+
+  getServicesList() {
+    const defaultValue = { value: this.$scope.defaultServiceValue, text: this.$scope.defaultServiceValue };
+    const services = this.metricDescriptors.map(m => {
+      const [service] = m.type.split('/');
+      const [serviceShortName] = service.split('.');
+      return {
+        value: service,
+        text: serviceShortName,
+      };
+    });
+
+    if (services.find(m => m.value === this.target.service)) {
+      this.service = this.target.service;
+    }
+
+    return services.length > 0 ? [defaultValue, ..._.uniqBy(services, 'value')] : [];
+  }
+
+  getMetricsList() {
+    const metrics = this.metricDescriptors.map(m => {
+      const [service] = m.type.split('/');
+      const [serviceShortName] = service.split('.');
+      return {
+        service,
+        value: m.type,
+        serviceShortName,
+        text: m.displayName,
+        title: m.description,
+      };
+    });
+
+    let result;
+    if (this.target.service === this.$scope.defaultServiceValue) {
+      result = metrics.map(m => ({ ...m, text: `${m.service} - ${m.text}` }));
+    } else {
+      result = metrics.filter(m => m.service === this.target.service);
+    }
+
+    if (result.find(m => m.value === this.target.metricType)) {
+      this.metricType = this.target.metricType;
+    } else if (result.length > 0) {
+      this.metricType = this.target.metricType = result[0].value;
+    }
+    return result;
+  }
+
+  async getLabels() {
+    this.loadLabelsPromise = new Promise(async resolve => {
+      try {
+        const data = await this.datasource.getLabels(this.target.metricType, this.target.refId);
+        this.metricLabels = data.results[this.target.refId].meta.metricLabels;
+        this.resourceLabels = data.results[this.target.refId].meta.resourceLabels;
+        resolve();
+      } catch (error) {
+        if (error.data && error.data.message) {
+          console.log(error.data.message);
+        } else {
+          console.log(error);
+        }
+        appEvents.emit('alert-error', ['Error', 'Error loading metric labels for ' + this.target.metricType]);
+        resolve();
+      }
+    });
+  }
+
+  onServiceChange() {
+    this.target.service = this.service;
+    this.metrics = this.getMetricsList();
+    this.setMetricType();
+    this.getLabels();
+    if (!this.metrics.find(m => m.value === this.target.metricType)) {
+      this.target.metricType = this.$scope.defaultDropdownValue;
+    } else {
+      this.$scope.refresh();
+    }
+  }
+
+  async onMetricTypeChange() {
+    this.setMetricType();
+    this.$scope.refresh();
+    this.getLabels();
+  }
+
+  setMetricType() {
+    this.target.metricType = this.metricType;
+    const { valueType, metricKind, unit } = this.metricDescriptors.find(m => m.type === this.target.metricType);
+    this.target.unit = unit;
+    this.target.valueType = valueType;
+    this.target.metricKind = metricKind;
+    this.$rootScope.$broadcast('metricTypeChanged');
+  }
+
+  async getGroupBys(segment, index, removeText?: string, removeUsed = true) {
+    await this.loadLabelsPromise;
+
+    const metricLabels = Object.keys(this.metricLabels || {})
+      .filter(ml => {
+        if (!removeUsed) {
+          return true;
+        }
+        return this.target.aggregation.groupBys.indexOf('metric.label.' + ml) === -1;
+      })
+      .map(l => {
+        return this.uiSegmentSrv.newSegment({
+          value: `metric.label.${l}`,
+          expandable: false,
+        });
+      });
+
+    const resourceLabels = Object.keys(this.resourceLabels || {})
+      .filter(ml => {
+        if (!removeUsed) {
+          return true;
+        }
+
+        return this.target.aggregation.groupBys.indexOf('resource.label.' + ml) === -1;
+      })
+      .map(l => {
+        return this.uiSegmentSrv.newSegment({
+          value: `resource.label.${l}`,
+          expandable: false,
+        });
+      });
+
+    const noValueOrPlusButton = !segment || segment.type === 'plus-button';
+    if (noValueOrPlusButton && metricLabels.length === 0 && resourceLabels.length === 0) {
+      return Promise.resolve([]);
+    }
+
+    this.removeSegment.value = removeText || this.defaultRemoveGroupByValue;
+    return Promise.resolve([...metricLabels, ...resourceLabels, this.removeSegment]);
+  }
+
+  groupByChanged(segment, index) {
+    if (segment.value === this.removeSegment.value) {
+      this.groupBySegments.splice(index, 1);
+    } else {
+      segment.type = 'value';
+    }
+
+    const reducer = (memo, seg) => {
+      if (!seg.fake) {
+        memo.push(seg.value);
+      }
+      return memo;
+    };
+
+    this.target.aggregation.groupBys = this.groupBySegments.reduce(reducer, []);
+    this.ensurePlusButton(this.groupBySegments);
+    this.$rootScope.$broadcast('metricTypeChanged');
+    this.$scope.refresh();
+  }
+
+  async getFilters(segment, index) {
+    const hasNoFilterKeys = this.metricLabels && Object.keys(this.metricLabels).length === 0;
+    return this.filterSegments.getFilters(segment, index, hasNoFilterKeys);
+  }
+
+  getFilterValues(index) {
+    const filterKey = this.templateSrv.replace(this.filterSegments.filterSegments[index - 2].value);
+    if (!filterKey || !this.metricLabels || Object.keys(this.metricLabels).length === 0) {
+      return [];
+    }
+
+    const shortKey = filterKey.substring(filterKey.indexOf('.label.') + 7);
+
+    if (filterKey.startsWith('metric.label.') && this.metricLabels.hasOwnProperty(shortKey)) {
+      return this.metricLabels[shortKey];
+    }
+
+    if (filterKey.startsWith('resource.label.') && this.resourceLabels.hasOwnProperty(shortKey)) {
+      return this.resourceLabels[shortKey];
+    }
+
+    return [];
+  }
+
+  filterSegmentUpdated(segment, index) {
+    this.target.filters = this.filterSegments.filterSegmentUpdated(segment, index);
+    this.$scope.refresh();
+  }
+
+  ensurePlusButton(segments) {
+    const count = segments.length;
+    const lastSegment = segments[Math.max(count - 1, 0)];
+
+    if (!lastSegment || lastSegment.type !== 'plus-button') {
+      segments.push(this.uiSegmentSrv.newPlusButton());
+    }
+  }
+}
+
+angular.module('grafana.controllers').directive('stackdriverFilter', StackdriverFilter);
+angular.module('grafana.controllers').controller('StackdriverFilterCtrl', StackdriverFilterCtrl);

+ 274 - 0
public/app/plugins/datasource/stackdriver/specs/datasource.test.ts

@@ -0,0 +1,274 @@
+import StackdriverDataSource from '../datasource';
+import { metricDescriptors } from './testData';
+import moment from 'moment';
+import { TemplateSrvStub } from 'test/specs/helpers';
+
+describe('StackdriverDataSource', () => {
+  const instanceSettings = {
+    jsonData: {
+      projectName: 'testproject',
+    },
+  };
+  const templateSrv = new TemplateSrvStub();
+  const timeSrv = {};
+
+  describe('when performing testDataSource', () => {
+    describe('and call to stackdriver api succeeds', () => {
+      let ds;
+      let result;
+      beforeEach(async () => {
+        const backendSrv = {
+          async datasourceRequest() {
+            return Promise.resolve({ status: 200 });
+          },
+        };
+        ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+        result = await ds.testDatasource();
+      });
+      it('should return successfully', () => {
+        expect(result.status).toBe('success');
+      });
+    });
+
+    describe('and a list of metricDescriptors are returned', () => {
+      let ds;
+      let result;
+      beforeEach(async () => {
+        const backendSrv = {
+          datasourceRequest: async () => Promise.resolve({ status: 200, data: metricDescriptors }),
+        };
+        ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+        result = await ds.testDatasource();
+      });
+      it('should return status success', () => {
+        expect(result.status).toBe('success');
+      });
+    });
+
+    describe('and call to stackdriver api fails with 400 error', () => {
+      let ds;
+      let result;
+      beforeEach(async () => {
+        const backendSrv = {
+          datasourceRequest: async () =>
+            Promise.reject({
+              statusText: 'Bad Request',
+              data: { error: { code: 400, message: 'Field interval.endTime had an invalid value' } },
+            }),
+        };
+        ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+        result = await ds.testDatasource();
+      });
+
+      it('should return error status and a detailed error message', () => {
+        expect(result.status).toEqual('error');
+        expect(result.message).toBe('Stackdriver: Bad Request: 400. Field interval.endTime had an invalid value');
+      });
+    });
+  });
+
+  describe('when performing getProjects', () => {
+    describe('and call to resource manager api succeeds', () => {
+      let ds;
+      let result;
+      beforeEach(async () => {
+        const response = {
+          projects: [
+            {
+              projectNumber: '853996325002',
+              projectId: 'test-project',
+              lifecycleState: 'ACTIVE',
+              name: 'Test Project',
+              createTime: '2015-06-02T14:16:08.520Z',
+              parent: {
+                type: 'organization',
+                id: '853996325002',
+              },
+            },
+          ],
+        };
+        const backendSrv = {
+          async datasourceRequest() {
+            return Promise.resolve({ status: 200, data: response });
+          },
+        };
+        ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+        result = await ds.getProjects();
+      });
+
+      it('should return successfully', () => {
+        expect(result.length).toBe(1);
+        expect(result[0].id).toBe('test-project');
+        expect(result[0].name).toBe('Test Project');
+      });
+    });
+  });
+
+  describe('When performing query', () => {
+    const options = {
+      range: {
+        from: moment.utc('2017-08-22T20:00:00Z'),
+        to: moment.utc('2017-08-22T23:59:00Z'),
+      },
+      rangeRaw: {
+        from: 'now-4h',
+        to: 'now',
+      },
+      targets: [
+        {
+          refId: 'A',
+          aggregation: {},
+        },
+      ],
+    };
+
+    describe('and no time series data is returned', () => {
+      let ds;
+      const response = {
+        results: {
+          A: {
+            refId: 'A',
+            meta: {
+              rawQuery: 'arawquerystring',
+            },
+            series: null,
+            tables: null,
+          },
+        },
+      };
+
+      beforeEach(() => {
+        const backendSrv = {
+          datasourceRequest: async () => Promise.resolve({ status: 200, data: response }),
+        };
+        ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+      });
+
+      it('should return a list of datapoints', () => {
+        return ds.query(options).then(results => {
+          expect(results.data.length).toBe(0);
+        });
+      });
+    });
+  });
+
+  describe('when performing getMetricTypes', () => {
+    describe('and call to stackdriver api succeeds', () => {});
+    let ds;
+    let result;
+    beforeEach(async () => {
+      const backendSrv = {
+        async datasourceRequest() {
+          return Promise.resolve({
+            data: {
+              metricDescriptors: [
+                {
+                  displayName: 'test metric name 1',
+                  type: 'test metric type 1',
+                },
+                {
+                  displayName: 'test metric name 2',
+                  type: 'test metric type 2',
+                },
+              ],
+            },
+          });
+        },
+      };
+      ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+      result = await ds.getMetricTypes();
+    });
+    it('should return successfully', () => {
+      expect(result.length).toBe(2);
+      expect(result[0].type).toBe('test metric type 1');
+      expect(result[0].displayName).toBe('test metric name 1');
+    });
+  });
+
+  describe('when interpolating a template variable for group bys', () => {
+    let interpolated;
+
+    describe('and is single value variable', () => {
+      beforeEach(() => {
+        templateSrv.data = {
+          test: 'groupby1',
+        };
+        const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+        interpolated = ds.interpolateGroupBys(['[[test]]'], {});
+      });
+
+      it('should replace the variable with the value', () => {
+        expect(interpolated.length).toBe(1);
+        expect(interpolated[0]).toBe('groupby1');
+      });
+    });
+
+    describe('and is multi value variable', () => {
+      beforeEach(() => {
+        templateSrv.data = {
+          test: 'groupby1,groupby2',
+        };
+        const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+        interpolated = ds.interpolateGroupBys(['[[test]]'], {});
+      });
+
+      it('should replace the variable with an array of group bys', () => {
+        expect(interpolated.length).toBe(2);
+        expect(interpolated[0]).toBe('groupby1');
+        expect(interpolated[1]).toBe('groupby2');
+      });
+    });
+  });
+
+  describe('unit parsing', () => {
+    let ds, res;
+    beforeEach(() => {
+      ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+    });
+    describe('when theres only one target', () => {
+      describe('and the stackdriver unit doesnt have a corresponding grafana unit', () => {
+        beforeEach(() => {
+          res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }]);
+        });
+        it('should return none', () => {
+          expect(res).toEqual('none');
+        });
+      });
+      describe('and the stackdriver unit has a corresponding grafana unit', () => {
+        beforeEach(() => {
+          res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }]);
+        });
+        it('should return bits', () => {
+          expect(res).toEqual('bits');
+        });
+      });
+    });
+
+    describe('when theres more than one target', () => {
+      describe('and all target units are the same', () => {
+        beforeEach(() => {
+          res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'bit' }]);
+        });
+        it('should return bits', () => {
+          expect(res).toEqual('bits');
+        });
+      });
+      describe('and all target units are the same but doesnt have grafana mappings', () => {
+        beforeEach(() => {
+          res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }, { unit: 'megaseconds' }]);
+        });
+        it('should return the default value - none', () => {
+          expect(res).toEqual('none');
+        });
+      });
+      describe('and all target units are not the same', () => {
+        beforeEach(() => {
+          res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'min' }]);
+        });
+        it('should return the default value - none', () => {
+          expect(res).toEqual('none');
+        });
+      });
+    });
+  });
+});

+ 60 - 0
public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts

@@ -0,0 +1,60 @@
+import { StackdriverAggregationCtrl } from '../query_aggregation_ctrl';
+
+describe('StackdriverAggregationCtrl', () => {
+  let ctrl;
+  describe('aggregation and alignment options', () => {
+    describe('when new query result is returned from the server', () => {
+      describe('and result is double and gauge and no group by is used', () => {
+        beforeEach(async () => {
+          ctrl = new StackdriverAggregationCtrl({
+            $on: () => {},
+            target: { valueType: 'DOUBLE', metricKind: 'GAUGE', aggregation: { crossSeriesReducer: '', groupBys: [] } },
+          });
+        });
+
+        it('should populate all aggregate options except two', () => {
+          ctrl.setAggOptions();
+          expect(ctrl.aggOptions.length).toBe(11);
+          expect(ctrl.aggOptions.map(o => o.value)).toEqual(
+            expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
+          );
+        });
+
+        it('should populate all alignment options except two', () => {
+          ctrl.setAlignOptions();
+          expect(ctrl.alignOptions.length).toBe(9);
+          expect(ctrl.alignOptions.map(o => o.value)).toEqual(
+            expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
+          );
+        });
+      });
+
+      describe('and result is double and gauge and a group by is used', () => {
+        beforeEach(async () => {
+          ctrl = new StackdriverAggregationCtrl({
+            $on: () => {},
+            target: {
+              valueType: 'DOUBLE',
+              metricKind: 'GAUGE',
+              aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] },
+            },
+          });
+        });
+
+        it('should populate all aggregate options except three', () => {
+          ctrl.setAggOptions();
+          expect(ctrl.aggOptions.length).toBe(10);
+          expect(ctrl.aggOptions.map(o => o.value)).toEqual(
+            expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE', 'REDUCE_NONE'])
+          );
+        });
+
+        it('should select some other reducer than REDUCE_NONE', () => {
+          ctrl.setAggOptions();
+          expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('');
+          expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('REDUCE_NONE');
+        });
+      });
+    });
+  });
+});

+ 442 - 0
public/app/plugins/datasource/stackdriver/specs/query_filter_ctrl.test.ts

@@ -0,0 +1,442 @@
+import { StackdriverFilterCtrl } from '../query_filter_ctrl';
+import { TemplateSrvStub } from 'test/specs/helpers';
+import { DefaultRemoveFilterValue, DefaultFilterValue } from '../filter_segments';
+
+describe('StackdriverQueryFilterCtrl', () => {
+  let ctrl;
+  let result;
+
+  describe('when initializing query editor', () => {
+    beforeEach(() => {
+      const existingFilters = ['key1', '=', 'val1', 'AND', 'key2', '=', 'val2'];
+      ctrl = createCtrlWithFakes(existingFilters);
+    });
+
+    it('should initialize filter segments using the target filter values', () => {
+      expect(ctrl.filterSegments.filterSegments.length).toBe(8);
+      expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+      expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+      expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+      expect(ctrl.filterSegments.filterSegments[3].type).toBe('condition');
+      expect(ctrl.filterSegments.filterSegments[4].type).toBe('key');
+      expect(ctrl.filterSegments.filterSegments[5].type).toBe('operator');
+      expect(ctrl.filterSegments.filterSegments[6].type).toBe('value');
+      expect(ctrl.filterSegments.filterSegments[7].type).toBe('plus-button');
+    });
+  });
+
+  describe('group bys', () => {
+    beforeEach(() => {
+      ctrl = createCtrlWithFakes();
+    });
+
+    describe('when labels are fetched', () => {
+      beforeEach(async () => {
+        ctrl.metricLabels = { 'metric-key-1': ['metric-value-1'] };
+        ctrl.resourceLabels = { 'resource-key-1': ['resource-value-1'] };
+
+        result = await ctrl.getGroupBys();
+      });
+
+      it('should populate group bys segments', () => {
+        expect(result.length).toBe(3);
+        expect(result[0].value).toBe('metric.label.metric-key-1');
+        expect(result[1].value).toBe('resource.label.resource-key-1');
+        expect(result[2].value).toBe('-- remove group by --');
+      });
+    });
+
+    describe('when a group by label is selected', () => {
+      beforeEach(async () => {
+        ctrl.metricLabels = {
+          'metric-key-1': ['metric-value-1'],
+          'metric-key-2': ['metric-value-2'],
+        };
+        ctrl.resourceLabels = {
+          'resource-key-1': ['resource-value-1'],
+          'resource-key-2': ['resource-value-2'],
+        };
+        ctrl.target.aggregation.groupBys = ['metric.label.metric-key-1', 'resource.label.resource-key-1'];
+
+        result = await ctrl.getGroupBys();
+      });
+
+      it('should not be used to populate group bys segments', () => {
+        expect(result.length).toBe(3);
+        expect(result[0].value).toBe('metric.label.metric-key-2');
+        expect(result[1].value).toBe('resource.label.resource-key-2');
+        expect(result[2].value).toBe('-- remove group by --');
+      });
+    });
+
+    describe('when a group by is selected', () => {
+      beforeEach(() => {
+        const removeSegment = { fake: true, value: '-- remove group by --' };
+        const segment = { value: 'groupby1' };
+        ctrl.groupBySegments = [segment, removeSegment];
+        ctrl.groupByChanged(segment);
+      });
+
+      it('should be added to group bys list', () => {
+        expect(ctrl.target.aggregation.groupBys.length).toBe(1);
+      });
+    });
+
+    describe('when a selected group by is removed', () => {
+      beforeEach(() => {
+        const removeSegment = { fake: true, value: '-- remove group by --' };
+        const segment = { value: 'groupby1' };
+        ctrl.groupBySegments = [segment, removeSegment];
+        ctrl.groupByChanged(removeSegment);
+      });
+
+      it('should be added to group bys list', () => {
+        expect(ctrl.target.aggregation.groupBys.length).toBe(0);
+      });
+    });
+  });
+
+  describe('filters', () => {
+    beforeEach(() => {
+      ctrl = createCtrlWithFakes();
+    });
+
+    describe('when values for a condition filter part are fetched', () => {
+      beforeEach(async () => {
+        const segment = { type: 'condition' };
+        result = await ctrl.getFilters(segment, 0);
+      });
+
+      it('should populate condition segments', () => {
+        expect(result.length).toBe(1);
+        expect(result[0].value).toBe('AND');
+      });
+    });
+
+    describe('when values for a operator filter part are fetched', () => {
+      beforeEach(async () => {
+        const segment = { type: 'operator' };
+        result = await ctrl.getFilters(segment, 0);
+      });
+
+      it('should populate group bys segments', () => {
+        expect(result.length).toBe(4);
+        expect(result[0].value).toBe('=');
+        expect(result[1].value).toBe('!=');
+        expect(result[2].value).toBe('=~');
+        expect(result[3].value).toBe('!=~');
+      });
+    });
+
+    describe('when values for a key filter part are fetched', () => {
+      beforeEach(async () => {
+        ctrl.metricLabels = {
+          'metric-key-1': ['metric-value-1'],
+          'metric-key-2': ['metric-value-2'],
+        };
+        ctrl.resourceLabels = {
+          'resource-key-1': ['resource-value-1'],
+          'resource-key-2': ['resource-value-2'],
+        };
+
+        const segment = { type: 'key' };
+        result = await ctrl.getFilters(segment, 0);
+      });
+
+      it('should populate filter key segments', () => {
+        expect(result.length).toBe(5);
+        expect(result[0].value).toBe('metric.label.metric-key-1');
+        expect(result[1].value).toBe('metric.label.metric-key-2');
+        expect(result[2].value).toBe('resource.label.resource-key-1');
+        expect(result[3].value).toBe('resource.label.resource-key-2');
+        expect(result[4].value).toBe('-- remove filter --');
+      });
+    });
+
+    describe('when values for a value filter part are fetched', () => {
+      beforeEach(async () => {
+        ctrl.metricLabels = {
+          'metric-key-1': ['metric-value-1'],
+          'metric-key-2': ['metric-value-2'],
+        };
+        ctrl.resourceLabels = {
+          'resource-key-1': ['resource-value-1'],
+          'resource-key-2': ['resource-value-2'],
+        };
+
+        ctrl.filterSegments.filterSegments = [
+          { type: 'key', value: 'metric.label.metric-key-1' },
+          { type: 'operator', value: '=' },
+        ];
+
+        const segment = { type: 'value' };
+        result = await ctrl.getFilters(segment, 2);
+      });
+
+      it('should populate filter value segments', () => {
+        expect(result.length).toBe(1);
+        expect(result[0].value).toBe('metric-value-1');
+      });
+    });
+
+    describe('when a filter is created by clicking on plus button', () => {
+      describe('and there are no other filters', () => {
+        beforeEach(() => {
+          const segment = { value: 'filterkey1', type: 'plus-button' };
+          ctrl.filterSegments.filterSegments = [segment];
+          ctrl.filterSegmentUpdated(segment, 0);
+        });
+
+        it('should transform the plus button segment to a key segment', () => {
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+        });
+
+        it('should add an operator, value segment and plus button segment', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(3);
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+        });
+      });
+    });
+    describe('when has one existing filter', () => {
+      describe('and user clicks on key segment', () => {
+        beforeEach(() => {
+          const existingKeySegment = { value: 'filterkey1', type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: 'filtervalue', type: 'value' };
+          const plusSegment = { value: '', type: 'plus-button' };
+          ctrl.filterSegments.filterSegments = [
+            existingKeySegment,
+            existingOperatorSegment,
+            existingValueSegment,
+            plusSegment,
+          ];
+          ctrl.filterSegmentUpdated(existingKeySegment, 0);
+        });
+
+        it('should not add any new segments', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+        });
+      });
+      describe('and user clicks on value segment and value not equal to fake value', () => {
+        beforeEach(() => {
+          const existingKeySegment = { value: 'filterkey1', type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: 'filtervalue', type: 'value' };
+          ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
+          ctrl.filterSegmentUpdated(existingValueSegment, 2);
+        });
+
+        it('should ensure that plus segment exists', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+          expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
+        });
+      });
+
+      describe('and user clicks on value segment and value is equal to fake value', () => {
+        beforeEach(() => {
+          const existingKeySegment = { value: 'filterkey1', type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: DefaultFilterValue, type: 'value' };
+          ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
+          ctrl.filterSegmentUpdated(existingValueSegment, 2);
+        });
+
+        it('should not add plus segment', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(3);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+        });
+      });
+      describe('and user removes key segment', () => {
+        beforeEach(() => {
+          const existingKeySegment = { value: DefaultRemoveFilterValue, type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: 'filtervalue', type: 'value' };
+          const plusSegment = { value: '', type: 'plus-button' };
+          ctrl.filterSegments.filterSegments = [
+            existingKeySegment,
+            existingOperatorSegment,
+            existingValueSegment,
+            plusSegment,
+          ];
+          ctrl.filterSegmentUpdated(existingKeySegment, 0);
+        });
+
+        it('should remove filter segments', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(1);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('plus-button');
+        });
+      });
+
+      describe('and user removes key segment and there is a previous filter', () => {
+        beforeEach(() => {
+          const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
+          const existingKeySegment2 = { value: DefaultRemoveFilterValue, type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: 'filtervalue', type: 'value' };
+          const conditionSegment = { value: 'AND', type: 'condition' };
+          const plusSegment = { value: '', type: 'plus-button' };
+          ctrl.filterSegments.filterSegments = [
+            existingKeySegment1,
+            existingOperatorSegment,
+            existingValueSegment,
+            conditionSegment,
+            existingKeySegment2,
+            Object.assign({}, existingOperatorSegment),
+            Object.assign({}, existingValueSegment),
+            plusSegment,
+          ];
+          ctrl.filterSegmentUpdated(existingKeySegment2, 4);
+        });
+
+        it('should remove filter segments and the condition segment', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+          expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
+        });
+      });
+
+      describe('and user removes key segment and there is a filter after it', () => {
+        beforeEach(() => {
+          const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
+          const existingKeySegment2 = { value: DefaultRemoveFilterValue, type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: 'filtervalue', type: 'value' };
+          const conditionSegment = { value: 'AND', type: 'condition' };
+          const plusSegment = { value: '', type: 'plus-button' };
+          ctrl.filterSegments.filterSegments = [
+            existingKeySegment1,
+            existingOperatorSegment,
+            existingValueSegment,
+            conditionSegment,
+            existingKeySegment2,
+            Object.assign({}, existingOperatorSegment),
+            Object.assign({}, existingValueSegment),
+            plusSegment,
+          ];
+          ctrl.filterSegmentUpdated(existingKeySegment1, 0);
+        });
+
+        it('should remove filter segments and the condition segment', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+          expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
+        });
+      });
+
+      describe('and user clicks on plus button', () => {
+        beforeEach(() => {
+          const existingKeySegment = { value: 'filterkey1', type: 'key' };
+          const existingOperatorSegment = { value: '=', type: 'operator' };
+          const existingValueSegment = { value: 'filtervalue', type: 'value' };
+          const plusSegment = { value: 'filterkey2', type: 'plus-button' };
+          ctrl.filterSegments.filterSegments = [
+            existingKeySegment,
+            existingOperatorSegment,
+            existingValueSegment,
+            plusSegment,
+          ];
+          ctrl.filterSegmentUpdated(plusSegment, 3);
+        });
+
+        it('should condition segment and new filter segments', () => {
+          expect(ctrl.filterSegments.filterSegments.length).toBe(7);
+          expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+          expect(ctrl.filterSegments.filterSegments[3].type).toBe('condition');
+          expect(ctrl.filterSegments.filterSegments[4].type).toBe('key');
+          expect(ctrl.filterSegments.filterSegments[5].type).toBe('operator');
+          expect(ctrl.filterSegments.filterSegments[6].type).toBe('value');
+        });
+      });
+    });
+  });
+});
+
+function createCtrlWithFakes(existingFilters?: string[]) {
+  StackdriverFilterCtrl.prototype.loadMetricDescriptors = () => {
+    return Promise.resolve([]);
+  };
+  StackdriverFilterCtrl.prototype.getLabels = () => {
+    return Promise.resolve();
+  };
+
+  const fakeSegmentServer = {
+    newKey: val => {
+      return { value: val, type: 'key' };
+    },
+    newKeyValue: val => {
+      return { value: val, type: 'value' };
+    },
+    newSegment: obj => {
+      return { value: obj.value ? obj.value : obj };
+    },
+    newOperators: ops => {
+      return ops.map(o => {
+        return { type: 'operator', value: o };
+      });
+    },
+    newFake: (value, type, cssClass) => {
+      return { value, type, cssClass };
+    },
+    newOperator: op => {
+      return { value: op, type: 'operator' };
+    },
+    newPlusButton: () => {
+      return { type: 'plus-button' };
+    },
+    newCondition: val => {
+      return { type: 'condition', value: val };
+    },
+  };
+  const scope = {
+    target: createTarget(existingFilters),
+    datasource: {
+      getDefaultProject: () => {
+        return 'project';
+      },
+    },
+    defaultDropdownValue: 'Select Metric',
+    defaultServiceValue: 'All Services',
+    refresh: () => {},
+  };
+
+  return new StackdriverFilterCtrl(scope, fakeSegmentServer, new TemplateSrvStub(), { $broadcast: param => {} });
+}
+
+function createTarget(existingFilters?: string[]) {
+  return {
+    project: {
+      id: '',
+      name: '',
+    },
+    unit: '',
+    metricType: 'ametric',
+    service: '',
+    refId: 'A',
+    aggregation: {
+      crossSeriesReducer: '',
+      alignmentPeriod: '',
+      perSeriesAligner: '',
+      groupBys: [],
+    },
+    filters: existingFilters || [],
+    aliasBy: '',
+    metricService: '',
+    metricKind: '',
+    valueType: '',
+  };
+}

+ 42 - 0
public/app/plugins/datasource/stackdriver/specs/testData.ts

@@ -0,0 +1,42 @@
+export const metricDescriptors = [
+  {
+    name: 'projects/grafana-prod/metricDescriptors/agent.googleapis.com/agent/api_request_count',
+    labels: [
+      {
+        key: 'state',
+        description: 'Request state',
+      },
+    ],
+    metricKind: 'CUMULATIVE',
+    valueType: 'INT64',
+    unit: '1',
+    description: 'API request count',
+    displayName: 'API Request Count',
+    type: 'agent.googleapis.com/agent/api_request_count',
+    metadata: {
+      launchStage: 'GA',
+      samplePeriod: '60s',
+      ingestDelay: '0s',
+    },
+  },
+  {
+    name: 'projects/grafana-prod/metricDescriptors/agent.googleapis.com/agent/log_entry_count',
+    labels: [
+      {
+        key: 'response_code',
+        description: 'HTTP response code',
+      },
+    ],
+    metricKind: 'CUMULATIVE',
+    valueType: 'INT64',
+    unit: '1',
+    description: 'Count of log entry writes',
+    displayName: 'Log Entry Count',
+    type: 'agent.googleapis.com/agent/log_entry_count',
+    metadata: {
+      launchStage: 'GA',
+      samplePeriod: '60s',
+      ingestDelay: '0s',
+    },
+  },
+];

+ 9 - 0
public/sass/components/_infobox.scss

@@ -19,6 +19,15 @@
     padding-left: $spacer * 1.5;
   }
 
+  code {
+    @include font-family-monospace();
+    font-size: $font-size-base - 2;
+    background-color: $code-tag-bg;
+    color: $text-color;
+    border: 1px solid $code-tag-border;
+    border-radius: 4px;
+  }
+
   a {
     @extend .external-link;
   }

+ 3 - 3
yarn.lock

@@ -228,9 +228,9 @@
   version "7946.0.4"
   resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.4.tgz#4e049756383c3f055dd8f3d24e63fb543e98eb07"
 
-"@types/jest@^21.1.4":
-  version "21.1.10"
-  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-21.1.10.tgz#dcacb5217ddf997a090cc822bba219b4b2fd7984"
+"@types/jest@^23.3.2":
+  version "23.3.2"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.2.tgz#07b90f6adf75d42c34230c026a2529e56c249dbb"
 
 "@types/node@*":
   version "10.9.4"