ソースを参照

Merge branch 'master' into data-source-settings-to-react

Peter Holmberg 7 年 前
コミット
e25b2d0ab6
79 ファイル変更1063 行追加366 行削除
  1. 4 1
      .circleci/config.yml
  2. 1 0
      .gitignore
  3. 6 0
      CHANGELOG.md
  4. 20 8
      build.go
  5. 108 2
      devenv/dev-dashboards/panel_tests_table.json
  6. 2 1
      docs/sources/features/datasources/cloudwatch.md
  7. 3 3
      docs/sources/guides/whats-new-in-v5-3.md
  8. 1 1
      docs/sources/http_api/alerting.md
  9. 4 0
      pkg/api/alerting.go
  10. 5 45
      pkg/api/dataproxy.go
  11. 10 8
      pkg/api/http_server.go
  12. 4 1
      pkg/api/metrics.go
  13. 13 8
      pkg/cmd/grafana-server/server.go
  14. 1 0
      pkg/login/auth.go
  15. 14 0
      pkg/middleware/headers.go
  16. 1 0
      pkg/middleware/middleware.go
  17. 1 0
      pkg/middleware/middleware_test.go
  18. 1 1
      pkg/middleware/recovery.go
  19. 4 0
      pkg/middleware/recovery_test.go
  20. 2 1
      pkg/models/alert.go
  21. 2 1
      pkg/models/context.go
  22. 0 5
      pkg/models/datasource.go
  23. 1 0
      pkg/models/user.go
  24. 34 3
      pkg/registry/registry.go
  25. 3 3
      pkg/services/alerting/commands.go
  26. 18 0
      pkg/services/alerting/conditions/reducer_test.go
  27. 18 1
      pkg/services/alerting/extractor.go
  28. 8 8
      pkg/services/alerting/extractor_test.go
  29. 2 1
      pkg/services/alerting/test_rule.go
  30. 17 0
      pkg/services/cache/cache.go
  31. 2 1
      pkg/services/dashboards/dashboard_service.go
  32. 53 0
      pkg/services/datasources/cache.go
  33. 18 4
      pkg/services/sqlstore/dashboard.go
  34. 8 3
      pkg/services/sqlstore/sqlstore.go
  35. 30 3
      pkg/services/sqlstore/user.go
  36. 4 0
      pkg/setting/setting.go
  37. 2 0
      pkg/tsdb/cloudwatch/metric_find_query.go
  38. 1 1
      public/app/core/components/form_dropdown/form_dropdown.ts
  39. 2 2
      public/app/features/alerting/AlertTabCtrl.ts
  40. 23 2
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  41. 19 10
      public/app/features/dashboard/dashgrid/PanelEditor.tsx
  42. 15 4
      public/app/features/dashboard/panel_model.ts
  43. 1 1
      public/app/features/dashboard/settings/settings.ts
  44. 1 1
      public/app/partials/reset_password.html
  45. 4 4
      public/app/plugins/datasource/logging/components/LoggingQueryField.tsx
  46. 3 5
      public/app/plugins/datasource/logging/language_provider.ts
  47. 3 3
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  48. 30 18
      public/app/plugins/datasource/prometheus/language_provider.ts
  49. 43 0
      public/app/plugins/datasource/prometheus/specs/language_provider.test.ts
  50. 39 15
      public/app/plugins/panel/graph2/module.tsx
  51. 7 6
      public/app/plugins/panel/table/renderer.ts
  52. 2 1
      public/app/types/explore.ts
  53. 2 1
      public/app/types/index.ts
  54. 8 1
      public/app/types/panel.ts
  55. 8 3
      public/app/types/plugins.ts
  56. 77 59
      public/app/viz/Graph.tsx
  57. 1 1
      public/views/error-template.html
  58. 1 1
      public/views/index-template.html
  59. 2 2
      scripts/build/build-all.sh
  60. 2 2
      scripts/build/build.sh
  61. 0 14
      scripts/build/deploy.sh
  62. 1 1
      scripts/build/publish.sh
  63. 62 0
      scripts/build/release_publisher/externalrelease.go
  64. 91 0
      scripts/build/release_publisher/localrelease.go
  65. 56 6
      scripts/build/release_publisher/main.go
  66. 35 80
      scripts/build/release_publisher/publisher.go
  67. 79 3
      scripts/build/release_publisher/publisher_test.go
  68. 0 0
      scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz
  69. 1 0
      scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz.sha256
  70. 0 0
      scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip
  71. 1 0
      scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip.sha256
  72. 0 0
      scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm
  73. 1 0
      scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm.sha256
  74. 0 0
      scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb
  75. 1 0
      scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb.sha256
  76. 1 1
      scripts/webpack/webpack.common.js
  77. 7 2
      scripts/webpack/webpack.dev.js
  78. 1 1
      scripts/webpack/webpack.hot.js
  79. 7 2
      scripts/webpack/webpack.prod.js

+ 4 - 1
.circleci/config.yml

@@ -335,6 +335,9 @@ jobs:
       - run:
           name: deploy to gcp
           command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/master'
+      - run:
+          name: Deploy to grafana.com
+          command: 'cd enterprise-dist && ../scripts/build/release_publisher/release_publisher -apikey ${GRAFANA_COM_API_KEY} -enterprise -from-local'
 
 
   deploy-enterprise-release:
@@ -403,7 +406,7 @@ jobs:
           command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
       - run:
           name: deploy to gcp
-          command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://R/oss/release'
+          command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://$GCP_BUCKET_NAME/oss/release'
       - run:
           name: Deploy to Grafana.com
           command: './scripts/build/publish.sh'

+ 1 - 0
.gitignore

@@ -8,6 +8,7 @@ awsconfig
 /dist
 /public/build
 /public/views/index.html
+/public/views/error.html
 /emails/dist
 /public_gen
 /public/vendor/npm

+ 6 - 0
CHANGELOG.md

@@ -12,11 +12,14 @@
 ### Minor
 
 * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
+* **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy)
 * **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm)
 * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
 * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
 * **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
 * **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
+* **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945)
+* **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme)
 
 ### Breaking changes
 
@@ -24,7 +27,10 @@
 
 # 5.3.3 (unreleased)
 
+* **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322)
 * **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769)
+* **Dashboard**: Fix datasource selection in panel by enter key [#13932](https://github.com/grafana/grafana/issues/13932)
+* **Graph**: Fix table legend height when positioned below graph and using Internet Explorer 11 [#13903](https://github.com/grafana/grafana/issues/13903)
 
 # 5.3.2 (2018-10-24)
 

+ 20 - 8
build.go

@@ -41,8 +41,8 @@ var (
 	race                  bool
 	phjsToRelease         string
 	workingDir            string
-	includeBuildNumber    bool     = true
-	buildNumber           int      = 0
+	includeBuildId        bool     = true
+	buildId               string   = "0"
 	binaries              []string = []string{"grafana-server", "grafana-cli"}
 	isDev                 bool     = false
 	enterprise            bool     = false
@@ -54,6 +54,8 @@ func main() {
 
 	ensureGoPath()
 
+	var buildIdRaw string
+
 	flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH")
 	flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS")
 	flag.StringVar(&gocc, "cc", "", "CC")
@@ -61,12 +63,14 @@ func main() {
 	flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH")
 	flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary")
 	flag.BoolVar(&race, "race", race, "Use race detector")
-	flag.BoolVar(&includeBuildNumber, "includeBuildNumber", includeBuildNumber, "IncludeBuildNumber in package name")
+	flag.BoolVar(&includeBuildId, "includeBuildId", includeBuildId, "IncludeBuildId in package name")
 	flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
-	flag.IntVar(&buildNumber, "buildNumber", 0, "Build number from CI system")
+	flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
 	flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
 	flag.Parse()
 
+	buildId = shortenBuildId(buildIdRaw)
+
 	readVersionFromPackageJson()
 
 	if pkgArch == "" {
@@ -197,9 +201,9 @@ func readVersionFromPackageJson() {
 	}
 
 	// add timestamp to iteration
-	if includeBuildNumber {
-		if buildNumber != 0 {
-			linuxPackageIteration = fmt.Sprintf("%d%s", buildNumber, linuxPackageIteration)
+	if includeBuildId {
+		if buildId != "0" {
+			linuxPackageIteration = fmt.Sprintf("%s%s", buildId, linuxPackageIteration)
 		} else {
 			linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration)
 		}
@@ -392,7 +396,7 @@ func grunt(params ...string) {
 
 func gruntBuildArg(task string) []string {
 	args := []string{task}
-	if includeBuildNumber {
+	if includeBuildId {
 		args = append(args, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration))
 	} else {
 		args = append(args, fmt.Sprintf("--pkgVer=%v", version))
@@ -632,3 +636,11 @@ func shaFile(file string) error {
 
 	return out.Close()
 }
+
+func shortenBuildId(buildId string) string {
+	buildId = strings.Replace(buildId, "-", "", -1)
+	if (len(buildId) < 9) {
+		return buildId
+	}
+	return buildId[0:8]
+}

+ 108 - 2
devenv/dev-dashboards/panel_tests_table.json

@@ -404,6 +404,112 @@
       "title": "Column style thresholds & units",
       "transform": "timeseries_to_columns",
       "type": "table"
+    },
+    {
+      "columns": [],
+      "datasource": "gdev-testdata",
+      "fontSize": "100%",
+      "gridPos": {
+        "h": 10,
+        "w": 24,
+        "x": 0,
+        "y": 26
+      },
+      "id": 6,
+      "links": [],
+      "pageSize": 20,
+      "scroll": true,
+      "showHeader": true,
+      "sort": {
+        "col": 0,
+        "desc": true
+      },
+      "styles": [
+        {
+          "alias": "Time",
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "pattern": "Time",
+          "type": "date"
+        },
+        {
+          "alias": "",
+          "colorMode": "cell",
+          "colors": [
+            "rgba(245, 54, 54, 0.5)",
+            "rgba(237, 129, 40, 0.5)",
+            "rgba(50, 172, 45, 0.5)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "link": true,
+          "linkTargetBlank": true,
+          "linkTooltip": "",
+          "linkUrl": "http://www.grafana.com",
+          "mappingType": 1,
+          "pattern": "ColorCell",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "currencyUSD"
+        },
+        {
+          "alias": "",
+          "colorMode": "value",
+          "colors": [
+            "rgba(245, 54, 54, 0.5)",
+            "rgba(237, 129, 40, 0.5)",
+            "rgba(50, 172, 45, 0.5)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "link": true,
+          "linkUrl": "http://www.grafana.com",
+          "mappingType": 1,
+          "pattern": "ColorValue",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "Bps"
+        },
+        {
+          "alias": "",
+          "colorMode": null,
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "decimals": 2,
+          "pattern": "/.*/",
+          "thresholds": [],
+          "type": "number",
+          "unit": "short"
+        }
+      ],
+      "targets": [
+        {
+          "alias": "ColorValue",
+          "expr": "",
+          "format": "table",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "null,1,20,90,30,5,0,20,10"
+        },
+        {
+          "alias": "ColorCell",
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "null,5,1,2,3,4,5,10,20"
+        }
+      ],
+      "title": "Column style thresholds and links",
+      "transform": "timeseries_to_columns",
+      "type": "table"
     }
   ],
   "refresh": false,
@@ -449,5 +555,5 @@
   "timezone": "browser",
   "title": "Panel Tests - Table",
   "uid": "pttable",
-  "version": 1
-}
+  "version": 2
+}

+ 2 - 1
docs/sources/features/datasources/cloudwatch.md

@@ -60,7 +60,8 @@ Here is a minimal policy example:
             "Effect": "Allow",
             "Action": [
                 "cloudwatch:ListMetrics",
-                "cloudwatch:GetMetricStatistics"
+                "cloudwatch:GetMetricStatistics",
+                "cloudwatch:GetMetricData"
             ],
             "Resource": "*"
         },

+ 3 - 3
docs/sources/guides/whats-new-in-v5-3.md

@@ -18,7 +18,7 @@ Grafana v5.3 brings new features, many enhancements and bug fixes. This article
 - [TV mode]({{< relref "#tv-and-kiosk-mode" >}}) is improved and more accessible
 - [Alerting]({{< relref "#notification-reminders" >}}) with notification reminders
 - [Postgres]({{< relref "#postgres-query-builder" >}}) gets a new query builder!
-- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for Gitlab is improved
+- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for GitLab is improved
 - [Annotations]({{< relref "#annotations" >}}) with template variable filtering
 - [Variables]({{< relref "#variables" >}}) with free text support
 
@@ -69,9 +69,9 @@ Grafana 5.3 comes with a new graphical query builder for Postgres. This brings P
 
 {{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}}
 
-## Improved OAuth Support for Gitlab
+## Improved OAuth Support for GitLab
 
-Grafana 5.3 comes with a new OAuth integration for Gitlab that enables configuration to only allow users that are a member of certain Gitlab groups to authenticate. This makes it possible to use Gitlab OAuth with Grafana in a shared environment without giving everyone access to Grafana.
+Grafana 5.3 comes with a new OAuth integration for GitLab that enables configuration to only allow users that are a member of certain GitLab groups to authenticate. This makes it possible to use GitLab OAuth with Grafana in a shared environment without giving everyone access to Grafana.
 Learn how to enable and configure it in the [documentation](/auth/gitlab/).
 
 ## Annotations

+ 1 - 1
docs/sources/http_api/alerting.md

@@ -290,7 +290,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
   "sendReminder": true,
   "frequency": "15m",
   "settings": {
-    "addresses: "carl@grafana.com;dev@grafana.com"
+    "addresses": "carl@grafana.com;dev@grafana.com"
   }
 }
 ```

+ 4 - 0
pkg/api/alerting.go

@@ -134,12 +134,16 @@ func AlertTest(c *m.ReqContext, dto dtos.AlertTestCommand) Response {
 		OrgId:     c.OrgId,
 		Dashboard: dto.Dashboard,
 		PanelId:   dto.PanelId,
+		User:      c.SignedInUser,
 	}
 
 	if err := bus.Dispatch(&backendCmd); err != nil {
 		if validationErr, ok := err.(alerting.ValidationError); ok {
 			return Error(422, validationErr.Error(), nil)
 		}
+		if err == m.ErrDataSourceAccessDenied {
+			return Error(403, "Access denied to datasource", err)
+		}
 		return Error(500, "Failed to test rule", err)
 	}
 

+ 5 - 45
pkg/api/dataproxy.go

@@ -1,62 +1,22 @@
 package api
 
 import (
-	"fmt"
-	"github.com/pkg/errors"
-	"time"
-
 	"github.com/grafana/grafana/pkg/api/pluginproxy"
-	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 )
 
-const HeaderNameNoBackendCache = "X-Grafana-NoCache"
-
-func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) {
-	userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{
-		User: c.SignedInUser,
-	}
-	if err := bus.Dispatch(&userPermissionsQuery); err != nil {
-		if err != bus.ErrHandlerNotFound {
-			return nil, err
-		}
-	} else {
-		permissionType, exists := userPermissionsQuery.Result[id]
-		if exists && permissionType != m.DsPermissionQuery {
-			return nil, errors.New("User not allowed to access datasource")
-		}
-	}
-
-	nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true"
-	cacheKey := fmt.Sprintf("ds-%d", id)
-
-	if !nocache {
-		if cached, found := hs.cache.Get(cacheKey); found {
-			ds := cached.(*m.DataSource)
-			if ds.OrgId == c.OrgId {
-				return ds, nil
-			}
-		}
-	}
-
-	query := m.GetDataSourceByIdQuery{Id: id, OrgId: c.OrgId}
-	if err := bus.Dispatch(&query); err != nil {
-		return nil, err
-	}
-
-	hs.cache.Set(cacheKey, query.Result, time.Second*5)
-	return query.Result, nil
-}
-
 func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
 	c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
 
 	dsId := c.ParamsInt64(":id")
-	ds, err := hs.getDatasourceFromCache(dsId, c)
-
+	ds, err := hs.DatasourceCache.GetDatasource(dsId, c.SignedInUser, c.SkipCache)
 	if err != nil {
+		if err == m.ErrDataSourceAccessDenied {
+			c.JsonApiErr(403, "Access denied to datasource", err)
+			return
+		}
 		c.JsonApiErr(500, "Unable to load datasource meta data", err)
 		return
 	}

+ 10 - 8
pkg/api/http_server.go

@@ -16,7 +16,6 @@ import (
 
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 
-	gocache "github.com/patrickmn/go-cache"
 	macaron "gopkg.in/macaron.v1"
 
 	"github.com/grafana/grafana/pkg/api/live"
@@ -28,6 +27,8 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/cache"
+	"github.com/grafana/grafana/pkg/services/datasources"
 	"github.com/grafana/grafana/pkg/services/hooks"
 	"github.com/grafana/grafana/pkg/services/rendering"
 	"github.com/grafana/grafana/pkg/setting"
@@ -46,19 +47,19 @@ type HTTPServer struct {
 	macaron       *macaron.Macaron
 	context       context.Context
 	streamManager *live.StreamManager
-	cache         *gocache.Cache
 	httpSrv       *http.Server
 
-	RouteRegister routing.RouteRegister `inject:""`
-	Bus           bus.Bus               `inject:""`
-	RenderService rendering.Service     `inject:""`
-	Cfg           *setting.Cfg          `inject:""`
-	HooksService  *hooks.HooksService   `inject:""`
+	RouteRegister   routing.RouteRegister    `inject:""`
+	Bus             bus.Bus                  `inject:""`
+	RenderService   rendering.Service        `inject:""`
+	Cfg             *setting.Cfg             `inject:""`
+	HooksService    *hooks.HooksService      `inject:""`
+	CacheService    *cache.CacheService      `inject:""`
+	DatasourceCache datasources.CacheService `inject:""`
 }
 
 func (hs *HTTPServer) Init() error {
 	hs.log = log.New("http.server")
-	hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
 
 	hs.streamManager = live.NewStreamManager()
 	hs.macaron = hs.newMacaron()
@@ -231,6 +232,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
 		m.Use(middleware.ValidateHostHeader(setting.Domain))
 	}
 
+	m.Use(middleware.HandleNoCacheHeader())
 	m.Use(middleware.AddDefaultResponseHeaders())
 }
 

+ 4 - 1
pkg/api/metrics.go

@@ -25,8 +25,11 @@ func (hs *HTTPServer) QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) R
 		return Error(400, "Query missing datasourceId", nil)
 	}
 
-	ds, err := hs.getDatasourceFromCache(datasourceId, c)
+	ds, err := hs.DatasourceCache.GetDatasource(datasourceId, c.SignedInUser, c.SkipCache)
 	if err != nil {
+		if err == m.ErrDataSourceAccessDenied {
+			return Error(403, "Access denied to datasource", err)
+		}
 		return Error(500, "Unable to load datasource meta data", err)
 	}
 

+ 13 - 8
pkg/cmd/grafana-server/server.go

@@ -15,13 +15,21 @@ import (
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/api/routing"
 	"github.com/grafana/grafana/pkg/bus"
-	_ "github.com/grafana/grafana/pkg/extensions"
-	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
-	_ "github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/middleware"
-	_ "github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/social"
+
+	"golang.org/x/sync/errgroup"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/services/cache"
+	"github.com/grafana/grafana/pkg/setting"
+
+	// self registering services
+	_ "github.com/grafana/grafana/pkg/extensions"
+	_ "github.com/grafana/grafana/pkg/metrics"
+	_ "github.com/grafana/grafana/pkg/plugins"
 	_ "github.com/grafana/grafana/pkg/services/alerting"
 	_ "github.com/grafana/grafana/pkg/services/cleanup"
 	_ "github.com/grafana/grafana/pkg/services/notifications"
@@ -29,10 +37,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/services/rendering"
 	_ "github.com/grafana/grafana/pkg/services/search"
 	_ "github.com/grafana/grafana/pkg/services/sqlstore"
-	"github.com/grafana/grafana/pkg/setting"
-	"github.com/grafana/grafana/pkg/social" // self registering services
 	_ "github.com/grafana/grafana/pkg/tracing"
-	"golang.org/x/sync/errgroup"
 )
 
 func NewGrafanaServer() *GrafanaServerImpl {
@@ -72,6 +77,7 @@ func (g *GrafanaServerImpl) Run() error {
 	serviceGraph.Provide(&inject.Object{Value: bus.GetBus()})
 	serviceGraph.Provide(&inject.Object{Value: g.cfg})
 	serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
+	serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)})
 
 	// self registered services
 	services := registry.GetServices()
@@ -138,7 +144,6 @@ func (g *GrafanaServerImpl) Run() error {
 	}
 
 	sendSystemdNotification("READY=1")
-
 	return g.childRoutines.Wait()
 }
 

+ 1 - 0
pkg/login/auth.go

@@ -2,6 +2,7 @@ package login
 
 import (
 	"errors"
+
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 )

+ 14 - 0
pkg/middleware/headers.go

@@ -0,0 +1,14 @@
+package middleware
+
+import (
+	m "github.com/grafana/grafana/pkg/models"
+	macaron "gopkg.in/macaron.v1"
+)
+
+const HeaderNameNoBackendCache = "X-Grafana-NoCache"
+
+func HandleNoCacheHeader() macaron.Handler {
+	return func(ctx *m.ReqContext) {
+		ctx.SkipCache = ctx.Req.Header.Get(HeaderNameNoBackendCache) == "true"
+	}
+}

+ 1 - 0
pkg/middleware/middleware.go

@@ -29,6 +29,7 @@ func GetContextHandler() macaron.Handler {
 			Session:        session.GetSession(),
 			IsSignedIn:     false,
 			AllowAnonymous: false,
+			SkipCache:      false,
 			Logger:         log.New("context"),
 		}
 

+ 1 - 0
pkg/middleware/middleware_test.go

@@ -18,6 +18,7 @@ import (
 )
 
 func TestMiddlewareContext(t *testing.T) {
+	setting.ERR_TEMPLATE_NAME = "error-template"
 
 	Convey("Given the grafana middleware", t, func() {
 		middlewareScenario("middleware should add context to injector", func(sc *scenarioContext) {

+ 1 - 1
pkg/middleware/recovery.go

@@ -138,7 +138,7 @@ func Recovery() macaron.Handler {
 
 					c.JSON(500, resp)
 				} else {
-					c.HTML(500, "error")
+					c.HTML(500, setting.ERR_TEMPLATE_NAME)
 				}
 			}
 		}()

+ 4 - 0
pkg/middleware/recovery_test.go

@@ -8,11 +8,14 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/session"
+	"github.com/grafana/grafana/pkg/setting"
 	. "github.com/smartystreets/goconvey/convey"
 	"gopkg.in/macaron.v1"
 )
 
 func TestRecoveryMiddleware(t *testing.T) {
+	setting.ERR_TEMPLATE_NAME = "error-template"
+
 	Convey("Given an api route that panics", t, func() {
 		apiURL := "/api/whatever"
 		recoveryScenario("recovery middleware should return json", apiURL, func(sc *scenarioContext) {
@@ -50,6 +53,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
 		sc := &scenarioContext{
 			url: url,
 		}
+
 		viewsPath, _ := filepath.Abs("../../public/views")
 
 		sc.m = macaron.New()

+ 2 - 1
pkg/models/alert.go

@@ -215,13 +215,14 @@ type AlertStateInfoDTO struct {
 // "Internal" commands
 
 type UpdateDashboardAlertsCommand struct {
-	UserId    int64
 	OrgId     int64
 	Dashboard *Dashboard
+	User      *SignedInUser
 }
 
 type ValidateDashboardAlertsCommand struct {
 	UserId    int64
 	OrgId     int64
 	Dashboard *Dashboard
+	User      *SignedInUser
 }

+ 2 - 1
pkg/models/context.go

@@ -20,6 +20,7 @@ type ReqContext struct {
 	IsSignedIn     bool
 	IsRenderCall   bool
 	AllowAnonymous bool
+	SkipCache      bool
 	Logger         log.Logger
 }
 
@@ -36,7 +37,7 @@ func (ctx *ReqContext) Handle(status int, title string, err error) {
 	ctx.Data["AppSubUrl"] = setting.AppSubUrl
 	ctx.Data["Theme"] = "dark"
 
-	ctx.HTML(status, "error")
+	ctx.HTML(status, setting.ERR_TEMPLATE_NAME)
 }
 
 func (ctx *ReqContext) JsonOK(message string) {

+ 0 - 5
pkg/models/datasource.go

@@ -207,11 +207,6 @@ func (p DsPermissionType) String() string {
 	return names[int(p)]
 }
 
-type GetDataSourcePermissionsForUserQuery struct {
-	User   *SignedInUser
-	Result map[int64]DsPermissionType
-}
-
 type DatasourcesPermissionFilterQuery struct {
 	User        *SignedInUser
 	Datasources []*DataSource

+ 1 - 0
pkg/models/user.go

@@ -165,6 +165,7 @@ type SignedInUser struct {
 	IsAnonymous    bool
 	HelpFlags1     HelpFlags1
 	LastSeenAt     time.Time
+	Teams          []int64
 }
 
 func (u *SignedInUser) ShouldUpdateLastSeenAt() bool {

+ 34 - 3
pkg/registry/registry.go

@@ -29,11 +29,42 @@ func Register(descriptor *Descriptor) {
 }
 
 func GetServices() []*Descriptor {
-	sort.Slice(services, func(i, j int) bool {
-		return services[i].InitPriority > services[j].InitPriority
+	slice := getServicesWithOverrides()
+
+	sort.Slice(slice, func(i, j int) bool {
+		return slice[i].InitPriority > slice[j].InitPriority
 	})
 
-	return services
+	return slice
+}
+
+type OverrideServiceFunc func(descriptor Descriptor) (*Descriptor, bool)
+
+var overrides []OverrideServiceFunc
+
+func RegisterOverride(fn OverrideServiceFunc) {
+	overrides = append(overrides, fn)
+}
+
+func getServicesWithOverrides() []*Descriptor {
+	slice := []*Descriptor{}
+	for _, s := range services {
+		var descriptor *Descriptor
+		for _, fn := range overrides {
+			if newDescriptor, override := fn(*s); override {
+				descriptor = newDescriptor
+				break
+			}
+		}
+
+		if descriptor != nil {
+			slice = append(slice, descriptor)
+		} else {
+			slice = append(slice, s)
+		}
+	}
+
+	return slice
 }
 
 // Service interface is the lowest common shape that services

+ 3 - 3
pkg/services/alerting/commands.go

@@ -11,7 +11,7 @@ func init() {
 }
 
 func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error {
-	extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId)
+	extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId, cmd.User)
 
 	return extractor.ValidateAlerts()
 }
@@ -19,11 +19,11 @@ func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error {
 func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error {
 	saveAlerts := m.SaveAlertsCommand{
 		OrgId:       cmd.OrgId,
-		UserId:      cmd.UserId,
+		UserId:      cmd.User.UserId,
 		DashboardId: cmd.Dashboard.Id,
 	}
 
-	extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId)
+	extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId, cmd.User)
 
 	alerts, err := extractor.GetAlerts()
 	if err != nil {

+ 18 - 0
pkg/services/alerting/conditions/reducer_test.go

@@ -52,6 +52,24 @@ func TestSimpleReducer(t *testing.T) {
 			So(result, ShouldEqual, float64(1))
 		})
 
+		Convey("median should ignore null values", func() {
+			reducer := NewSimpleReducer("median")
+			series := &tsdb.TimeSeries{
+				Name: "test time serie",
+			}
+
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1))
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2))
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 3))
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(1)), 4))
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(2)), 5))
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(3)), 6))
+
+			result := reducer.Reduce(series)
+			So(result.Valid, ShouldEqual, true)
+			So(result.Float64, ShouldEqual, float64(2))
+		})
+
 		Convey("avg", func() {
 			result := testReducer("avg", 1, 2, 3)
 			So(result, ShouldEqual, float64(2))

+ 18 - 1
pkg/services/alerting/extractor.go

@@ -13,14 +13,16 @@ import (
 
 // DashAlertExtractor extracts alerts from the dashboard json
 type DashAlertExtractor struct {
+	User  *m.SignedInUser
 	Dash  *m.Dashboard
 	OrgID int64
 	log   log.Logger
 }
 
 // NewDashAlertExtractor returns a new DashAlertExtractor
-func NewDashAlertExtractor(dash *m.Dashboard, orgID int64) *DashAlertExtractor {
+func NewDashAlertExtractor(dash *m.Dashboard, orgID int64, user *m.SignedInUser) *DashAlertExtractor {
 	return &DashAlertExtractor{
+		User:  user,
 		Dash:  dash,
 		OrgID: orgID,
 		log:   log.New("alerting.extractor"),
@@ -149,6 +151,21 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
 				return nil, ValidationError{Reason: fmt.Sprintf("Data source used by alert rule not found, alertName=%v, datasource=%s", alert.Name, dsName)}
 			}
 
+			dsFilterQuery := m.DatasourcesPermissionFilterQuery{
+				User:        e.User,
+				Datasources: []*m.DataSource{datasource},
+			}
+
+			if err := bus.Dispatch(&dsFilterQuery); err != nil {
+				if err != bus.ErrHandlerNotFound {
+					return nil, err
+				}
+			} else {
+				if len(dsFilterQuery.Result) == 0 {
+					return nil, m.ErrDataSourceAccessDenied
+				}
+			}
+
 			jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
 
 			if interval, err := panel.Get("interval").String(); err == nil {

+ 8 - 8
pkg/services/alerting/extractor_test.go

@@ -69,7 +69,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 				So(getTarget(dashJson), ShouldEqual, "")
 			})
 
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 			_, _ = extractor.GetAlerts()
 
 			Convey("Dashboard json should not be updated after extracting rules", func() {
@@ -83,7 +83,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			So(err, ShouldBeNil)
 
 			dash := m.NewDashboardFromJson(dashJson)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			alerts, err := extractor.GetAlerts()
 
@@ -146,7 +146,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			dashJson, err := simplejson.NewJson(panelWithoutId)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJson)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			_, err = extractor.GetAlerts()
 
@@ -162,7 +162,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			dashJson, err := simplejson.NewJson(panelWithIdZero)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJson)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			_, err = extractor.GetAlerts()
 
@@ -178,7 +178,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			dashJson, err := simplejson.NewJson(json)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJson)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			alerts, err := extractor.GetAlerts()
 
@@ -198,7 +198,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			dashJson, err := simplejson.NewJson(json)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJson)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			alerts, err := extractor.GetAlerts()
 
@@ -228,7 +228,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			So(err, ShouldBeNil)
 
 			dash := m.NewDashboardFromJson(dashJson)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			alerts, err := extractor.GetAlerts()
 
@@ -248,7 +248,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			dashJSON, err := simplejson.NewJson(json)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJSON)
-			extractor := NewDashAlertExtractor(dash, 1)
+			extractor := NewDashAlertExtractor(dash, 1, nil)
 
 			err = extractor.ValidateAlerts()
 

+ 2 - 1
pkg/services/alerting/test_rule.go

@@ -13,6 +13,7 @@ type AlertTestCommand struct {
 	Dashboard *simplejson.Json
 	PanelId   int64
 	OrgId     int64
+	User      *m.SignedInUser
 
 	Result *EvalContext
 }
@@ -25,7 +26,7 @@ func handleAlertTestCommand(cmd *AlertTestCommand) error {
 
 	dash := m.NewDashboardFromJson(cmd.Dashboard)
 
-	extractor := NewDashAlertExtractor(dash, cmd.OrgId)
+	extractor := NewDashAlertExtractor(dash, cmd.OrgId, cmd.User)
 	alerts, err := extractor.GetAlerts()
 	if err != nil {
 		return err

+ 17 - 0
pkg/services/cache/cache.go

@@ -0,0 +1,17 @@
+package cache
+
+import (
+	"time"
+
+	gocache "github.com/patrickmn/go-cache"
+)
+
+type CacheService struct {
+	*gocache.Cache
+}
+
+func New(defaultExpiration, cleanupInterval time.Duration) *CacheService {
+	return &CacheService{
+		Cache: gocache.New(defaultExpiration, cleanupInterval),
+	}
+}

+ 2 - 1
pkg/services/dashboards/dashboard_service.go

@@ -90,6 +90,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
 		validateAlertsCmd := models.ValidateDashboardAlertsCommand{
 			OrgId:     dto.OrgId,
 			Dashboard: dash,
+			User:      dto.User,
 		}
 
 		if err := bus.Dispatch(&validateAlertsCmd); err != nil {
@@ -159,8 +160,8 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
 func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error {
 	alertCmd := models.UpdateDashboardAlertsCommand{
 		OrgId:     dto.OrgId,
-		UserId:    dto.User.UserId,
 		Dashboard: cmd.Result,
+		User:      dto.User,
 	}
 
 	if err := bus.Dispatch(&alertCmd); err != nil {

+ 53 - 0
pkg/services/datasources/cache.go

@@ -0,0 +1,53 @@
+package datasources
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/cache"
+)
+
+type CacheService interface {
+	GetDatasource(datasourceID int64, user *m.SignedInUser, skipCache bool) (*m.DataSource, error)
+}
+
+type CacheServiceImpl struct {
+	Bus          bus.Bus             `inject:""`
+	CacheService *cache.CacheService `inject:""`
+}
+
+func init() {
+	registry.Register(&registry.Descriptor{
+		Name:         "DatasourceCacheService",
+		Instance:     &CacheServiceImpl{},
+		InitPriority: registry.Low,
+	})
+}
+
+func (dc *CacheServiceImpl) Init() error {
+	return nil
+}
+
+func (dc *CacheServiceImpl) GetDatasource(datasourceID int64, user *m.SignedInUser, skipCache bool) (*m.DataSource, error) {
+	cacheKey := fmt.Sprintf("ds-%d", datasourceID)
+
+	if !skipCache {
+		if cached, found := dc.CacheService.Get(cacheKey); found {
+			ds := cached.(*m.DataSource)
+			if ds.OrgId == user.OrgId {
+				return ds, nil
+			}
+		}
+	}
+
+	query := m.GetDataSourceByIdQuery{Id: datasourceID, OrgId: user.OrgId}
+	if err := dc.Bus.Dispatch(&query); err != nil {
+		return nil, err
+	}
+
+	dc.CacheService.Set(cacheKey, query.Result, time.Second*5)
+	return query.Result, nil
+}

+ 18 - 4
pkg/services/sqlstore/dashboard.go

@@ -327,20 +327,34 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 		if dashboard.IsFolder {
 			deletes = append(deletes, "DELETE FROM dashboard_provisioning WHERE dashboard_id in (select id from dashboard where folder_id = ?)")
 			deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?")
-		}
-
-		for _, sql := range deletes {
-			_, err := sess.Exec(sql, dashboard.Id)
 
+			dashIds := []struct {
+				Id int64
+			}{}
+			err := sess.SQL("select id from dashboard where folder_id = ?", dashboard.Id).Find(&dashIds)
 			if err != nil {
 				return err
 			}
+
+			for _, id := range dashIds {
+				if err := deleteAlertDefinition(id.Id, sess); err != nil {
+					return nil
+				}
+			}
 		}
 
 		if err := deleteAlertDefinition(dashboard.Id, sess); err != nil {
 			return nil
 		}
 
+		for _, sql := range deletes {
+			_, err := sess.Exec(sql, dashboard.Id)
+
+			if err != nil {
+				return err
+			}
+		}
+
 		return nil
 	})
 }

+ 8 - 3
pkg/services/sqlstore/sqlstore.go

@@ -16,6 +16,7 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/annotations"
+	"github.com/grafana/grafana/pkg/services/cache"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
 	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 	"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
@@ -47,8 +48,9 @@ func init() {
 }
 
 type SqlStore struct {
-	Cfg *setting.Cfg `inject:""`
-	Bus bus.Bus      `inject:""`
+	Cfg          *setting.Cfg        `inject:""`
+	Bus          bus.Bus             `inject:""`
+	CacheService *cache.CacheService `inject:""`
 
 	dbCfg           DatabaseConfig
 	engine          *xorm.Engine
@@ -148,9 +150,11 @@ func (ss *SqlStore) Init() error {
 
 	// Init repo instances
 	annotations.SetRepository(&SqlAnnotationRepo{})
-
 	ss.Bus.SetTransactionManager(ss)
 
+	// Register handlers
+	ss.addUserQueryAndCommandHandlers()
+
 	// ensure admin user
 	if ss.skipEnsureAdmin {
 		return nil
@@ -322,6 +326,7 @@ func InitTestDB(t *testing.T) *SqlStore {
 	sqlstore := &SqlStore{}
 	sqlstore.skipEnsureAdmin = true
 	sqlstore.Bus = bus.New()
+	sqlstore.CacheService = cache.New(5*time.Minute, 10*time.Minute)
 
 	dbType := migrator.SQLITE
 

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

@@ -15,8 +15,9 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-func init() {
-	//bus.AddHandler("sql", CreateUser)
+func (ss *SqlStore) addUserQueryAndCommandHandlers() {
+	ss.Bus.AddHandler(ss.GetSignedInUserWithCache)
+
 	bus.AddHandler("sql", GetUserById)
 	bus.AddHandler("sql", UpdateUser)
 	bus.AddHandler("sql", ChangeUserPassword)
@@ -25,7 +26,6 @@ func init() {
 	bus.AddHandler("sql", SetUsingOrg)
 	bus.AddHandler("sql", UpdateUserLastSeenAt)
 	bus.AddHandler("sql", GetUserProfile)
-	bus.AddHandler("sql", GetSignedInUser)
 	bus.AddHandler("sql", SearchUsers)
 	bus.AddHandler("sql", GetUserOrgList)
 	bus.AddHandler("sql", DeleteUser)
@@ -345,6 +345,22 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error {
 	return err
 }
 
+func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error {
+	cacheKey := fmt.Sprintf("signed-in-user-%d-%d", query.UserId, query.OrgId)
+	if cached, found := ss.CacheService.Get(cacheKey); found {
+		query.Result = cached.(*m.SignedInUser)
+		return nil
+	}
+
+	err := GetSignedInUser(query)
+	if err != nil {
+		return err
+	}
+
+	ss.CacheService.Set(cacheKey, query.Result, time.Second*5)
+	return nil
+}
+
 func GetSignedInUser(query *m.GetSignedInUserQuery) error {
 	orgId := "u.org_id"
 	if query.OrgId > 0 {
@@ -389,6 +405,17 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error {
 		user.OrgName = "Org missing"
 	}
 
+	getTeamsByUserQuery := &m.GetTeamsByUserQuery{OrgId: user.OrgId, UserId: user.UserId}
+	err = GetTeamsByUser(getTeamsByUserQuery)
+	if err != nil {
+		return err
+	}
+
+	user.Teams = make([]int64, len(getTeamsByUserQuery.Result))
+	for i, t := range getTeamsByUserQuery.Result {
+		user.Teams[i] = t.Id
+	}
+
 	query.Result = &user
 	return err
 }

+ 4 - 0
pkg/setting/setting.go

@@ -38,6 +38,10 @@ const (
 	APP_NAME_ENTERPRISE = "Grafana Enterprise"
 )
 
+var (
+	ERR_TEMPLATE_NAME = "error"
+)
+
 var (
 	// App settings.
 	Env          = DEV

+ 2 - 0
pkg/tsdb/cloudwatch/metric_find_query.go

@@ -46,6 +46,7 @@ func init() {
 		"AWS/Billing":        {"EstimatedCharges"},
 		"AWS/CloudFront":     {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"},
 		"AWS/CloudSearch":    {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"},
+		"AWS/Connect":        {"CallsBreachingConcurrencyQuota", "CallBackNotDialableNumber", "CallRecordingUploadError", "CallsPerInterval", "ConcurrentCalls", "ConcurrentCallsPercentage", "ContactFlowErrors", "ContactFlowFatalErrors", "LongestQueueWaitTime", "MissedCalls", "MisconfiguredPhoneNumbers", "PublicSigningKeyUsage", "QueueCapacityExceededError", "QueueSize", "ThrottledCalls", "ToInstancePacketLossRate"},
 		"AWS/DMS":            {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"},
 		"AWS/DX":             {"ConnectionState", "ConnectionBpsEgress", "ConnectionBpsIngress", "ConnectionPpsEgress", "ConnectionPpsIngress", "ConnectionCRCErrorCount", "ConnectionLightLevelTx", "ConnectionLightLevelRx"},
 		"AWS/DynamoDB":       {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
@@ -120,6 +121,7 @@ func init() {
 		"AWS/Billing":          {"ServiceName", "LinkedAccount", "Currency"},
 		"AWS/CloudFront":       {"DistributionId", "Region"},
 		"AWS/CloudSearch":      {},
+		"AWS/Connect":          {"InstanceId", "MetricGroup", "Participant", "QueueName", "Stream Type", "Type of Connection"},
 		"AWS/DMS":              {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"},
 		"AWS/DX":               {"ConnectionId"},
 		"AWS/DynamoDB":         {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},

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

@@ -88,7 +88,7 @@ export class FormDropdownCtrl {
       if (evt.keyCode === 13) {
         setTimeout(() => {
           this.inputElement.blur();
-        }, 100);
+        }, 300);
       }
     });
 

+ 2 - 2
public/app/features/alerting/AlertTabCtrl.ts

@@ -166,7 +166,7 @@ export class AlertTabCtrl {
 
     alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues;
     alert.executionErrorState = alert.executionErrorState || config.alertingErrorOrTimeout;
-    alert.frequency = alert.frequency || '60s';
+    alert.frequency = alert.frequency || '1m';
     alert.handler = alert.handler || 1;
     alert.notifications = alert.notifications || [];
 
@@ -217,7 +217,7 @@ export class AlertTabCtrl {
   buildDefaultCondition() {
     return {
       type: 'query',
-      query: { params: ['A', '5m', 'now'] },
+      query: { params: ['A', '15m', 'now'] },
       reducer: { type: 'avg', params: [] },
       evaluator: { type: 'gt', params: [null] },
       operator: { type: 'and' },

+ 23 - 2
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -21,6 +21,7 @@ export interface Props {
 
 export interface State {
   refreshCounter: number;
+  renderCounter: number;
   timeRange?: TimeRange;
 }
 
@@ -30,11 +31,13 @@ export class PanelChrome extends PureComponent<Props, State> {
 
     this.state = {
       refreshCounter: 0,
+      renderCounter: 0,
     };
   }
 
   componentDidMount() {
     this.props.panel.events.on('refresh', this.onRefresh);
+    this.props.panel.events.on('render', this.onRender);
     this.props.dashboard.panelInitialized(this.props.panel);
   }
 
@@ -52,6 +55,13 @@ export class PanelChrome extends PureComponent<Props, State> {
     });
   };
 
+  onRender = () => {
+    console.log('onRender');
+    this.setState({
+      renderCounter: this.state.renderCounter + 1,
+    });
+  };
+
   get isVisible() {
     return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
   }
@@ -59,9 +69,11 @@ export class PanelChrome extends PureComponent<Props, State> {
   render() {
     const { panel, dashboard } = this.props;
     const { datasource, targets } = panel;
-    const { refreshCounter, timeRange } = this.state;
+    const { timeRange, renderCounter, refreshCounter } = this.state;
     const PanelComponent = this.props.component;
 
+    console.log('Panel chrome render');
+
     return (
       <div className="panel-container">
         <PanelHeader panel={panel} dashboard={dashboard} />
@@ -74,7 +86,16 @@ export class PanelChrome extends PureComponent<Props, State> {
             refreshCounter={refreshCounter}
           >
             {({ loading, timeSeries }) => {
-              return <PanelComponent loading={loading} timeSeries={timeSeries} timeRange={timeRange} />;
+              console.log('panelcrome inner render');
+              return (
+                <PanelComponent
+                  loading={loading}
+                  timeSeries={timeSeries}
+                  timeRange={timeRange}
+                  options={panel.getOptions()}
+                  renderCounter={renderCounter}
+                />
+              );
             }}
           </DataPanel>
         </div>

+ 19 - 10
public/app/features/dashboard/dashgrid/PanelEditor.tsx

@@ -1,13 +1,16 @@
-import React from 'react';
+import React, { PureComponent } from 'react';
 import classNames from 'classnames';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
-import { store } from 'app/store/store';
+
 import { QueriesTab } from './QueriesTab';
-import { PanelPlugin, PluginExports } from 'app/types/plugins';
 import { VizTypePicker } from './VizTypePicker';
+
+import { store } from 'app/store/store';
 import { updateLocation } from 'app/core/actions';
 
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { PanelPlugin, PluginExports } from 'app/types/plugins';
+
 interface PanelEditorProps {
   panel: PanelModel;
   dashboard: DashboardModel;
@@ -22,7 +25,7 @@ interface PanelEditorTab {
   icon: string;
 }
 
-export class PanelEditor extends React.Component<PanelEditorProps, any> {
+export class PanelEditor extends PureComponent<PanelEditorProps> {
   tabs: PanelEditorTab[];
 
   constructor(props) {
@@ -39,16 +42,21 @@ export class PanelEditor extends React.Component<PanelEditorProps, any> {
   }
 
   renderPanelOptions() {
-    const { pluginExports } = this.props;
+    const { pluginExports, panel } = this.props;
 
-    if (pluginExports.PanelOptions) {
-      const PanelOptions = pluginExports.PanelOptions;
-      return <PanelOptions />;
+    if (pluginExports.PanelOptionsComponent) {
+      const OptionsComponent = pluginExports.PanelOptionsComponent;
+      return <OptionsComponent options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
     } else {
       return <p>Visualization has no options</p>;
     }
   }
 
+  onPanelOptionsChanged = (options: any) => {
+    this.props.panel.updateOptions(options);
+    this.forceUpdate();
+  };
+
   renderVizTab() {
     return (
       <div className="viz-editor">
@@ -70,6 +78,7 @@ export class PanelEditor extends React.Component<PanelEditorProps, any> {
         partial: true,
       })
     );
+    this.forceUpdate();
   };
 
   render() {

+ 15 - 4
public/app/features/dashboard/panel_model.ts

@@ -60,6 +60,21 @@ export class PanelModel {
     _.defaultsDeep(this, _.cloneDeep(defaults));
   }
 
+  getOptions() {
+    return this[this.getOptionsKey()] || {};
+  }
+
+  updateOptions(options: object) {
+    const update: any = {};
+    update[this.getOptionsKey()] = options;
+    Object.assign(this, update);
+    this.render();
+  }
+
+  private getOptionsKey() {
+    return this.type + 'Options';
+  }
+
   getSaveModel() {
     const model: any = {};
     for (const property in this) {
@@ -121,10 +136,6 @@ export class PanelModel {
     this.events.emit('panel-initialized');
   }
 
-  initEditMode() {
-    this.events.emit('panel-init-edit-mode');
-  }
-
   changeType(pluginId: string) {
     this.type = pluginId;
 

+ 1 - 1
public/app/features/dashboard/settings/settings.ts

@@ -32,9 +32,9 @@ export class SettingsCtrl {
 
     this.$scope.$on('$destroy', () => {
       this.dashboard.updateSubmenuVisibility();
-      this.dashboard.startRefresh();
       setTimeout(() => {
         this.$rootScope.appEvent('dash-scroll', { restore: true });
+        this.dashboard.startRefresh();
       });
     });
 

+ 1 - 1
public/app/partials/reset_password.html

@@ -19,7 +19,7 @@
 			</div>
 		</form>
 		<div ng-show="mode === 'email-sent'">
-			An email with a reset link as been sent to the email address. <br>
+			An email with a reset link has been sent to the email address. <br>
 			You should receive it shortly.
 			<div class="p-t-1">
 				<a href="login" class="btn btn-success p-t-1">

+ 4 - 4
public/app/plugins/datasource/logging/components/LoggingQueryField.tsx

@@ -95,9 +95,9 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
       this.languageProvider
         .start()
         .then(remaining => {
-          remaining.map(task => task.then(this.onReceiveMetrics).catch(() => {}));
+          remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {}));
         })
-        .then(() => this.onReceiveMetrics());
+        .then(() => this.onUpdateLanguage());
     }
   }
 
@@ -119,7 +119,7 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
 
     this.languageProvider
       .fetchLabelValues(targetOption.value)
-      .then(this.onReceiveMetrics)
+      .then(this.onUpdateLanguage)
       .catch(() => {});
   };
 
@@ -147,7 +147,7 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
     }
   };
 
-  onReceiveMetrics = () => {
+  onUpdateLanguage = () => {
     Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
     const { logLabelOptions } = this.languageProvider;
     this.setState({

+ 3 - 5
public/app/plugins/datasource/logging/language_provider.ts

@@ -47,7 +47,6 @@ export default class LoggingLanguageProvider extends LanguageProvider {
     this.datasource = datasource;
     this.labelKeys = {};
     this.labelValues = {};
-    this.started = false;
 
     Object.assign(this, initialValues);
   }
@@ -63,11 +62,10 @@ export default class LoggingLanguageProvider extends LanguageProvider {
   };
 
   start = () => {
-    if (!this.started) {
-      this.started = true;
-      return this.fetchLogLabels();
+    if (!this.startTask) {
+      this.startTask = this.fetchLogLabels();
     }
-    return Promise.resolve([]);
+    return this.startTask;
   };
 
   // Keep this DOM-free for testing

+ 3 - 3
public/app/plugins/datasource/prometheus/components/PromQueryField.tsx

@@ -134,9 +134,9 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
       this.languageProvider
         .start()
         .then(remaining => {
-          remaining.map(task => task.then(this.onReceiveMetrics).catch(() => {}));
+          remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {}));
         })
-        .then(() => this.onReceiveMetrics());
+        .then(() => this.onUpdateLanguage());
     }
   }
 
@@ -176,7 +176,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     }
   };
 
-  onReceiveMetrics = () => {
+  onUpdateLanguage = () => {
     const { histogramMetrics, metrics } = this.languageProvider;
     if (!metrics) {
       return;

+ 30 - 18
public/app/plugins/datasource/prometheus/language_provider.ts

@@ -46,7 +46,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
   labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
   labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   metrics?: string[];
-  started: boolean;
+  startTask: Promise<any>;
 
   constructor(datasource: any, initialValues?: any) {
     super();
@@ -56,7 +56,6 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     this.labelKeys = {};
     this.labelValues = {};
     this.metrics = [];
-    this.started = false;
 
     Object.assign(this, initialValues);
   }
@@ -72,11 +71,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
   };
 
   start = () => {
-    if (!this.started) {
-      this.started = true;
-      return this.fetchMetricNames().then(() => [this.fetchHistogramMetrics()]);
+    if (!this.startTask) {
+      this.startTask = this.fetchMetricNames().then(() => [this.fetchHistogramMetrics()]);
     }
-    return Promise.resolve([]);
+    return this.startTask;
   };
 
   // Keep this DOM-free for testing
@@ -156,7 +154,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
   }
 
   getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
-    let refresher: Promise<any> = null;
+    const refresher: Promise<any> = null;
     const suggestions: CompletionItemGroup[] = [];
 
     // Stitch all query lines together to support multi-line queries
@@ -172,12 +170,30 @@ export default class PromQlLanguageProvider extends LanguageProvider {
       return text;
     }, '');
 
-    const leftSide = queryText.slice(0, queryOffset);
-    const openParensAggregationIndex = leftSide.lastIndexOf('(');
-    const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
-    const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
+    // Try search for selector part on the left-hand side, such as `sum (m) by (l)`
+    const openParensAggregationIndex = queryText.lastIndexOf('(', queryOffset);
+    let openParensSelectorIndex = queryText.lastIndexOf('(', openParensAggregationIndex - 1);
+    let closeParensSelectorIndex = queryText.indexOf(')', openParensSelectorIndex);
+
+    // Try search for selector part of an alternate aggregation clause, such as `sum by (l) (m)`
+    if (openParensSelectorIndex === -1) {
+      const closeParensAggregationIndex = queryText.indexOf(')', queryOffset);
+      closeParensSelectorIndex = queryText.indexOf(')', closeParensAggregationIndex + 1);
+      openParensSelectorIndex = queryText.lastIndexOf('(', closeParensSelectorIndex);
+    }
 
-    let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
+    const result = {
+      refresher,
+      suggestions,
+      context: 'context-aggregation',
+    };
+
+    // Suggestions are useless for alternative aggregation clauses without a selector in context
+    if (openParensSelectorIndex === -1) {
+      return result;
+    }
+
+    let selectorString = queryText.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
 
     // Range vector syntax not accounted for by subsequent parse so discard it if present
     selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
@@ -188,14 +204,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     if (labelKeys) {
       suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
     } else {
-      refresher = this.fetchSeriesLabels(selector);
+      result.refresher = this.fetchSeriesLabels(selector);
     }
 
-    return {
-      refresher,
-      suggestions,
-      context: 'context-aggregation',
-    };
+    return result;
   }
 
   getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {

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

@@ -269,5 +269,48 @@ describe('Language completion provider', () => {
         },
       ]);
     });
+
+    it('returns no suggestions inside an unclear aggregation context using alternate syntax', () => {
+      const instance = new LanguageProvider(datasource, {
+        labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
+      });
+      const value = Plain.deserialize('sum by ()');
+      const range = value.selection.merge({
+        anchorOffset: 8,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-aggregation'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-aggregation');
+      expect(result.suggestions).toEqual([]);
+    });
+
+    it('returns label suggestions inside an aggregation context using alternate syntax', () => {
+      const instance = new LanguageProvider(datasource, {
+        labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
+      });
+      const value = Plain.deserialize('sum by () (metric)');
+      const range = value.selection.merge({
+        anchorOffset: 8,
+      });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.provideCompletionItems({
+        text: '',
+        prefix: '',
+        wrapperClasses: ['context-aggregation'],
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-aggregation');
+      expect(result.suggestions).toEqual([
+        {
+          items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
+          label: 'Labels',
+        },
+      ]);
+    });
   });
 });

+ 39 - 15
public/app/plugins/panel/graph2/module.tsx

@@ -1,23 +1,22 @@
-// Libraries
 import _ from 'lodash';
 import React, { PureComponent } from 'react';
 
-// Components
 import Graph from 'app/viz/Graph';
-import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
 import { Switch } from 'app/core/components/Switch/Switch';
 
-// Types
-import { PanelProps, NullValueMode } from 'app/types';
+import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
+import { PanelProps, PanelOptionsProps, NullValueMode } from 'app/types';
 
 interface Options {
   showBars: boolean;
-}
+  showLines: boolean;
+  showPoints: boolean;
 
-interface Props extends PanelProps {
-  options: Options;
+  onChange: (options: Options) => void;
 }
 
+interface Props extends PanelProps<Options> {}
+
 export class Graph2 extends PureComponent<Props> {
   constructor(props) {
     super(props);
@@ -25,27 +24,52 @@ export class Graph2 extends PureComponent<Props> {
 
   render() {
     const { timeSeries, timeRange } = this.props;
+    const { showLines, showBars, showPoints } = this.props.options;
 
     const vmSeries = getTimeSeriesVMs({
       timeSeries: timeSeries,
       nullValueMode: NullValueMode.Ignore,
     });
 
-    return <Graph timeSeries={vmSeries} timeRange={timeRange} />;
+    return (
+      <Graph
+        timeSeries={vmSeries}
+        timeRange={timeRange}
+        showLines={showLines}
+        showPoints={showPoints}
+        showBars={showBars}
+      />
+    );
   }
 }
 
-export class TextOptions extends PureComponent<any> {
-  onChange = () => {};
+export class GraphOptions extends PureComponent<PanelOptionsProps<Options>> {
+  onToggleLines = () => {
+    this.props.onChange({ ...this.props.options, showLines: !this.props.options.showLines });
+  };
+
+  onToggleBars = () => {
+    this.props.onChange({ ...this.props.options, showBars: !this.props.options.showBars });
+  };
+
+  onTogglePoints = () => {
+    this.props.onChange({ ...this.props.options, showPoints: !this.props.options.showPoints });
+  };
 
   render() {
+    const { showBars, showPoints, showLines } = this.props.options;
+
     return (
-      <div className="section gf-form-group">
-        <h5 className="section-heading">Draw Modes</h5>
-        <Switch label="Lines" checked={true} onChange={this.onChange} />
+      <div>
+        <div className="section gf-form-group">
+          <h5 className="page-heading">Draw Modes</h5>
+          <Switch label="Lines" labelClass="width-5" checked={showLines} onChange={this.onToggleLines} />
+          <Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
+          <Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
+        </div>
       </div>
     );
   }
 }
 
-export { Graph2 as PanelComponent, TextOptions as PanelOptions };
+export { Graph2 as PanelComponent, GraphOptions as PanelOptionsComponent };

+ 7 - 6
public/app/plugins/panel/table/renderer.ts

@@ -211,16 +211,17 @@ export class TableRenderer {
     value = this.formatColumnValue(columnIndex, value);
 
     const column = this.table.columns[columnIndex];
-    let style = '';
+    let cellStyle = '';
+    let textStyle = '';
     const cellClasses = [];
     let cellClass = '';
 
     if (this.colorState.cell) {
-      style = ' style="background-color:' + this.colorState.cell + '"';
+      cellStyle = ' style="background-color:' + this.colorState.cell + '"';
       cellClasses.push('table-panel-color-cell');
       this.colorState.cell = null;
     } else if (this.colorState.value) {
-      style = ' style="color:' + this.colorState.value + '"';
+      textStyle = ' style="color:' + this.colorState.value + '"';
       this.colorState.value = null;
     }
     // because of the fixed table headers css only solution
@@ -232,7 +233,7 @@ export class TableRenderer {
     }
 
     if (value === undefined) {
-      style = ' style="display:none;"';
+      cellStyle = ' style="display:none;"';
       column.hidden = true;
     } else {
       column.hidden = false;
@@ -258,7 +259,7 @@ export class TableRenderer {
       cellClasses.push('table-panel-cell-link');
 
       columnHtml += `
-        <a href="${cellLink}" target="${cellTarget}" data-link-tooltip data-original-title="${cellLinkTooltip}" data-placement="right"${style}>
+        <a href="${cellLink}" target="${cellTarget}" data-link-tooltip data-original-title="${cellLinkTooltip}" data-placement="right"${textStyle}>
           ${value}
         </a>
       `;
@@ -283,7 +284,7 @@ export class TableRenderer {
       cellClass = ' class="' + cellClasses.join(' ') + '"';
     }
 
-    columnHtml = '<td' + cellClass + style + '>' + columnHtml + '</td>';
+    columnHtml = '<td' + cellClass + cellStyle + textStyle + '>' + columnHtml + '</td>';
     return columnHtml;
   }
 

+ 2 - 1
public/app/types/explore.ts

@@ -86,10 +86,11 @@ export abstract class LanguageProvider {
   datasource: any;
   request: (url) => Promise<any>;
   /**
-   * Returns a promise that resolves with a task list when main syntax is loaded.
+   * Returns startTask that resolves with a task list when main syntax is loaded.
    * Task list consists of secondary promises that load more detailed language features.
    */
   start: () => Promise<any[]>;
+  startTask?: Promise<any[]>;
 }
 
 export interface TypeaheadInput {

+ 2 - 1
public/app/types/index.ts

@@ -20,7 +20,7 @@ import {
   DataQueryResponse,
   DataQueryOptions,
 } from './series';
-import { PanelProps } from './panel';
+import { PanelProps, PanelOptionsProps } from './panel';
 import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
 import { Organization, OrganizationPreferences, OrganizationState } from './organization';
 import {
@@ -69,6 +69,7 @@ export {
   TimeRange,
   LoadingState,
   PanelProps,
+  PanelOptionsProps,
   TimeSeries,
   TimeSeriesVM,
   TimeSeriesVMs,

+ 8 - 1
public/app/types/panel.ts

@@ -1,7 +1,14 @@
 import { LoadingState, TimeSeries, TimeRange } from './series';
 
-export interface PanelProps {
+export interface PanelProps<T = any> {
   timeSeries: TimeSeries[];
   timeRange: TimeRange;
   loading: LoadingState;
+  options: T;
+  renderCounter: number;
+}
+
+export interface PanelOptionsProps<T = any> {
+  options: T;
+  onChange: (options: T) => void;
 }

+ 8 - 3
public/app/types/plugins.ts

@@ -1,13 +1,18 @@
+import { ComponentClass } from 'react';
+import { PanelProps, PanelOptionsProps } from './panel';
+
 export interface PluginExports {
-  PanelCtrl?;
-  PanelComponent?: any;
   Datasource?: any;
   QueryCtrl?: any;
   ConfigCtrl?: any;
   AnnotationsQueryCtrl?: any;
-  PanelOptions?: any;
   ExploreQueryField?: any;
   ExploreStartPage?: any;
+
+  // Panel plugin
+  PanelCtrl?;
+  PanelComponent?: ComponentClass<PanelProps>;
+  PanelOptionsComponent: ComponentClass<PanelOptionsProps>;
 }
 
 export interface PanelPlugin {

+ 77 - 59
public/app/viz/Graph.tsx

@@ -8,63 +8,22 @@ import 'vendor/flot/jquery.flot.time';
 // Types
 import { TimeRange, TimeSeriesVMs } from 'app/types';
 
-// Copied from graph.ts
-function time_format(ticks, min, max) {
-  if (min && max && ticks) {
-    const range = max - min;
-    const secPerTick = range / ticks / 1000;
-    const oneDay = 86400000;
-    const oneYear = 31536000000;
-
-    if (secPerTick <= 45) {
-      return '%H:%M:%S';
-    }
-    if (secPerTick <= 7200 || range <= oneDay) {
-      return '%H:%M';
-    }
-    if (secPerTick <= 80000) {
-      return '%m/%d %H:%M';
-    }
-    if (secPerTick <= 2419200 || range <= oneYear) {
-      return '%m/%d';
-    }
-    return '%Y-%m';
-  }
-
-  return '%H:%M';
-}
-
-const FLOT_OPTIONS = {
-  legend: {
-    show: false,
-  },
-  series: {
-    lines: {
-      linewidth: 1,
-      zero: false,
-    },
-    shadowSize: 0,
-  },
-  grid: {
-    minBorderMargin: 0,
-    markings: [],
-    backgroundColor: null,
-    borderWidth: 0,
-    // hoverable: true,
-    clickable: true,
-    color: '#a1a1a1',
-    margin: { left: 0, right: 0 },
-    labelMarginX: 0,
-  },
-};
-
 interface GraphProps {
   timeSeries: TimeSeriesVMs;
   timeRange: TimeRange;
+  showLines?: boolean;
+  showPoints?: boolean;
+  showBars?: boolean;
   size?: { width: number; height: number };
 }
 
 export class Graph extends PureComponent<GraphProps> {
+  static defaultProps = {
+    showLines: true,
+    showPoints: false,
+    showBars: false,
+  };
+
   element: any;
 
   componentDidUpdate(prevProps: GraphProps) {
@@ -82,7 +41,7 @@ export class Graph extends PureComponent<GraphProps> {
   }
 
   draw() {
-    const { size, timeSeries, timeRange } = this.props;
+    const { size, timeSeries, timeRange, showLines, showBars, showPoints } = this.props;
 
     if (!size) {
       return;
@@ -92,7 +51,31 @@ export class Graph extends PureComponent<GraphProps> {
     const min = timeRange.from.valueOf();
     const max = timeRange.to.valueOf();
 
-    const dynamicOptions = {
+    const flotOptions = {
+      legend: {
+        show: false,
+      },
+      series: {
+        lines: {
+          show: showLines,
+          linewidth: 1,
+          zero: false,
+        },
+        points: {
+          show: showPoints,
+          fill: 1,
+          fillColor: false,
+          radius: 2,
+        },
+        bars: {
+          show: showBars,
+          fill: 1,
+          barWidth: 1,
+          zero: false,
+          lineWidth: 0,
+        },
+        shadowSize: 0,
+      },
       xaxis: {
         mode: 'time',
         min: min,
@@ -101,15 +84,24 @@ export class Graph extends PureComponent<GraphProps> {
         ticks: ticks,
         timeformat: time_format(ticks, min, max),
       },
+      grid: {
+        minBorderMargin: 0,
+        markings: [],
+        backgroundColor: null,
+        borderWidth: 0,
+        // hoverable: true,
+        clickable: true,
+        color: '#a1a1a1',
+        margin: { left: 0, right: 0 },
+        labelMarginX: 0,
+      },
     };
 
-    const options = {
-      ...FLOT_OPTIONS,
-      ...dynamicOptions,
-    };
-
-    console.log('plot', timeSeries, options);
-    $.plot(this.element, timeSeries, options);
+    try {
+      $.plot(this.element, timeSeries, flotOptions);
+    } catch (err) {
+      console.log('Graph rendering error', err, flotOptions, timeSeries);
+    }
   }
 
   render() {
@@ -121,4 +113,30 @@ export class Graph extends PureComponent<GraphProps> {
   }
 }
 
+// Copied from graph.ts
+function time_format(ticks, min, max) {
+  if (min && max && ticks) {
+    const range = max - min;
+    const secPerTick = range / ticks / 1000;
+    const oneDay = 86400000;
+    const oneYear = 31536000000;
+
+    if (secPerTick <= 45) {
+      return '%H:%M:%S';
+    }
+    if (secPerTick <= 7200 || range <= oneDay) {
+      return '%H:%M';
+    }
+    if (secPerTick <= 80000) {
+      return '%m/%d %H:%M';
+    }
+    if (secPerTick <= 2419200 || range <= oneYear) {
+      return '%m/%d';
+    }
+    return '%Y-%m';
+  }
+
+  return '%H:%M';
+}
+
 export default withSize()(Graph);

+ 1 - 1
public/views/error.html → public/views/error-template.html

@@ -10,7 +10,7 @@
 
     <base href="[[.AppSubUrl]]/" />
 
-    <link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]">
+    <link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].<%= webpack.hash %>.css">
 
     <link rel="icon" type="image/png" href="public/img/fav32.png">
     <link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">

+ 1 - 1
public/views/index.template.html → public/views/index-template.html

@@ -15,7 +15,7 @@
   <link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
   <link rel="apple-touch-icon" sizes="180x180" href="public/img/apple-touch-icon.png">
 
-  <link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]">
+  <link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].<%= webpack.hash %>.css">
 
   <meta name="apple-mobile-web-app-capable" content="yes">
   <meta name="apple-mobile-web-app-status-bar-style" content="black">

+ 2 - 2
scripts/build/build-all.sh

@@ -22,10 +22,10 @@ echo "current dir: $(pwd)"
 
 if [ "$CIRCLE_TAG" != "" ]; then
   echo "Building releases from tag $CIRCLE_TAG"
-  OPT="-includeBuildNumber=false ${EXTRA_OPTS}"
+  OPT="-includeBuildId=false ${EXTRA_OPTS}"
 else
   echo "Building incremental build for $CIRCLE_BRANCH"
-  OPT="-buildNumber=${CIRCLE_BUILD_NUM} ${EXTRA_OPTS}"
+  OPT="-buildId=${CIRCLE_WORKFLOW_ID} ${EXTRA_OPTS}"
 fi
 
 echo "Build arguments: $OPT"

+ 2 - 2
scripts/build/build.sh

@@ -18,10 +18,10 @@ echo "current dir: $(pwd)"
 
 if [ "$CIRCLE_TAG" != "" ]; then
   echo "Building releases from tag $CIRCLE_TAG"
-  OPT="-includeBuildNumber=false ${EXTRA_OPTS}"
+  OPT="-includeBuildId=false ${EXTRA_OPTS}"
 else
   echo "Building incremental build for $CIRCLE_BRANCH"
-  OPT="-buildNumber=${CIRCLE_BUILD_NUM} ${EXTRA_OPTS}"
+  OPT="-buildId=${CIRCLE_WORKFLOW_ID} ${EXTRA_OPTS}"
 fi
 
 echo "Build arguments: $OPT"

+ 0 - 14
scripts/build/deploy.sh

@@ -1,14 +0,0 @@
-#!/bin/bash
-
-mkdir -p dist
-
-echo "Circle branch: ${CIRCLE_BRANCH}"
-echo "Circle tag: ${CIRCLE_TAG}"
-docker run -i -t --name gfbuild \
-  -v $(pwd):/go/src/github.com/grafana/grafana \
-  -e "CIRCLE_BRANCH=${CIRCLE_BRANCH}" \
-  -e "CIRCLE_TAG=${CIRCLE_TAG}" \
-  -e "CIRCLE_BUILD_NUM=${CIRCLE_BUILD_NUM}" \
-  grafana/buildcontainer
-
-sudo chown -R ${USER:=$(/usr/bin/id -run)}:$USER dist

+ 1 - 1
scripts/build/publish.sh

@@ -1,4 +1,4 @@
-#/bin/sh
+#!/bin/sh
 
 # no relation to publish.go
 

+ 62 - 0
scripts/build/release_publisher/externalrelease.go

@@ -0,0 +1,62 @@
+package main
+
+import (
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type releaseFromExternalContent struct {
+	getter     urlGetter
+	rawVersion string
+	artifactConfigurations []buildArtifact
+}
+
+func (re releaseFromExternalContent) prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) {
+	version := re.rawVersion[1:]
+	isBeta := strings.Contains(version, "beta")
+
+	builds := []build{}
+	for _, ba := range re.artifactConfigurations {
+		sha256, err := re.getter.getContents(fmt.Sprintf("%s.sha256", ba.getUrl(baseArchiveUrl, version, isBeta)))
+		if err != nil {
+			return nil, err
+		}
+		builds = append(builds, newBuild(baseArchiveUrl, ba, version, isBeta, sha256))
+	}
+
+	r := release{
+		Version:         version,
+		ReleaseDate:     time.Now().UTC(),
+		Stable:          !isBeta && !nightly,
+		Beta:            isBeta,
+		Nightly:         nightly,
+		WhatsNewUrl:     whatsNewUrl,
+		ReleaseNotesUrl: releaseNotesUrl,
+		Builds:          builds,
+	}
+	return &r, nil
+}
+
+type urlGetter interface {
+	getContents(url string) (string, error)
+}
+
+type getHttpContents struct{}
+
+func (getHttpContents) getContents(url string) (string, error) {
+	response, err := http.Get(url)
+	if err != nil {
+		return "", err
+	}
+
+	defer response.Body.Close()
+	all, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	return string(all), nil
+}

+ 91 - 0
scripts/build/release_publisher/localrelease.go

@@ -0,0 +1,91 @@
+package main
+
+import (
+	"fmt"
+	"github.com/pkg/errors"
+	"io/ioutil"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+)
+
+type releaseLocalSources struct {
+	path string
+	artifactConfigurations []buildArtifact
+}
+
+func (r releaseLocalSources) prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) {
+	buildData := r.findBuilds(baseArchiveUrl)
+
+	rel := release{
+		Version:         buildData.version,
+		ReleaseDate:     time.Now().UTC(),
+		Stable:          false,
+		Beta:            false,
+		Nightly:         nightly,
+		WhatsNewUrl:     whatsNewUrl,
+		ReleaseNotesUrl: releaseNotesUrl,
+		Builds:          buildData.builds,
+	}
+
+	return &rel, nil
+}
+
+type buildData struct {
+	version string
+	builds []build
+}
+
+func (r releaseLocalSources) findBuilds(baseArchiveUrl string) buildData {
+	data := buildData{}
+	filepath.Walk(r.path, createBuildWalker(r.path, &data, r.artifactConfigurations, baseArchiveUrl))
+	return data
+}
+
+func createBuildWalker(path string, data *buildData, archiveTypes []buildArtifact, baseArchiveUrl string) func(path string, f os.FileInfo, err error) error {
+	return func(path string, f os.FileInfo, err error) error {
+		if err != nil {
+			log.Printf("error: %v", err)
+		}
+
+		if f.Name() == path || strings.HasSuffix(f.Name(), ".sha256") {
+			return nil
+		}
+
+		for _, archive := range archiveTypes {
+			if strings.HasSuffix(f.Name(), archive.urlPostfix) {
+				shaBytes, err := ioutil.ReadFile(path + ".sha256")
+				if err != nil {
+					log.Fatalf("Failed to read sha256 file %v", err)
+				}
+
+				version, err := grabVersion(f.Name(), archive.urlPostfix)
+				if err != nil {
+					log.Println(err)
+					continue
+				}
+				data.version = version
+				data.builds = append(data.builds, build{
+					Os:     archive.os,
+					Url:    archive.getUrl(baseArchiveUrl, version, false),
+					Sha256: string(shaBytes),
+					Arch:   archive.arch,
+				})
+				return nil
+			}
+		}
+		return nil
+	}
+
+}
+func grabVersion(name string, suffix string) (string, error) {
+	match := regexp.MustCompile(fmt.Sprintf(`grafana(-enterprise)?[-_](.*)%s`, suffix)).FindSubmatch([]byte(name))
+	if len(match) > 0 {
+		return string(match[2]), nil
+	}
+
+	return "", errors.New("No version found.")
+}

+ 56 - 6
scripts/build/release_publisher/main.go

@@ -7,13 +7,14 @@ import (
 	"os"
 )
 
-var baseUri string = "https://grafana.com/api"
-
 func main() {
 	var version string
 	var whatsNewUrl string
 	var releaseNotesUrl string
 	var dryRun bool
+	var enterprise bool
+	var fromLocal bool
+	var nightly bool
 	var apiKey string
 
 	flag.StringVar(&version, "version", "", "Grafana version (ex: --version v5.2.0-beta1)")
@@ -21,20 +22,69 @@ func main() {
 	flag.StringVar(&releaseNotesUrl, "rn", "", "Grafana version (ex: --rn https://community.grafana.com/t/release-notes-v5-2-x/7894)")
 	flag.StringVar(&apiKey, "apikey", "", "Grafana.com API key (ex: --apikey ABCDEF)")
 	flag.BoolVar(&dryRun, "dry-run", false, "--dry-run")
+	flag.BoolVar(&enterprise, "enterprise", false, "--enterprise")
+	flag.BoolVar(&fromLocal, "from-local", false, "--from-local (builds will be tagged as nightly)")
 	flag.Parse()
 
+	nightly = fromLocal
+
 	if len(os.Args) == 1 {
-		fmt.Println("Usage: go run publisher.go main.go --version <v> --wn <what's new url> --rn <release notes url> --apikey <api key> --dry-run false")
-		fmt.Println("example: go run publisher.go main.go --version v5.2.0-beta2 --wn http://docs.grafana.org/guides/whats-new-in-v5-2/ --rn https://community.grafana.com/t/release-notes-v5-2-x/7894 --apikey ASDF123 --dry-run true")
+		fmt.Println("Usage: go run publisher.go main.go --version <v> --wn <what's new url> --rn <release notes url> --apikey <api key> --dry-run false --enterprise false --nightly false")
+		fmt.Println("example: go run publisher.go main.go --version v5.2.0-beta2 --wn http://docs.grafana.org/guides/whats-new-in-v5-2/ --rn https://community.grafana.com/t/release-notes-v5-2-x/7894 --apikey ASDF123 --dry-run --enterprise")
 		os.Exit(1)
 	}
 
 	if dryRun {
 		log.Println("Dry-run has been enabled.")
 	}
+	var baseUrl string
+	var builder releaseBuilder
+	var product string
+
+	if fromLocal {
+		path, _ := os.Getwd()
+		builder = releaseLocalSources{
+			path: path,
+			artifactConfigurations: buildArtifactConfigurations,
+		}
+	} else {
+		builder = releaseFromExternalContent{
+			getter:     getHttpContents{},
+			rawVersion: version,
+			artifactConfigurations: buildArtifactConfigurations,
+		}
+	}
+
+	archiveProviderRoot := "https://s3-us-west-2.amazonaws.com"
 
-	p := publisher{apiKey: apiKey}
-	if err := p.doRelease(version, whatsNewUrl, releaseNotesUrl, dryRun); err != nil {
+	if enterprise {
+		product = "grafana-enterprise"
+		baseUrl = createBaseUrl(archiveProviderRoot, "grafana-enterprise-releases", product, nightly)
+	} else {
+		product = "grafana"
+		baseUrl = createBaseUrl(archiveProviderRoot, "grafana-releases", product, nightly)
+	}
+
+	p := publisher{
+		apiKey:         apiKey,
+		apiUri:         "https://grafana.com/api",
+		product:        product,
+		dryRun:         dryRun,
+		enterprise:     enterprise,
+		baseArchiveUrl: baseUrl,
+		builder:        builder,
+	}
+	if err := p.doRelease(whatsNewUrl, releaseNotesUrl, nightly); err != nil {
 		log.Fatalf("error: %v", err)
 	}
 }
+func createBaseUrl(root string, bucketName string, product string, nightly bool) string {
+	var subPath string
+	if nightly {
+		subPath = "master"
+	} else {
+		subPath = "release"
+	}
+
+	return fmt.Sprintf("%s/%s/%s/%s", root, bucketName, subPath, product)
+}

+ 35 - 80
scripts/build/release_publisher/publisher.go

@@ -12,53 +12,47 @@ import (
 )
 
 type publisher struct {
-	apiKey string
+	apiKey         string
+	apiUri         string
+	product        string
+	dryRun         bool
+	enterprise     bool
+	baseArchiveUrl string
+	builder        releaseBuilder
 }
 
-func (p *publisher) doRelease(version string, whatsNewUrl string, releaseNotesUrl string, dryRun bool) error {
-	currentRelease, err := newRelease(version, whatsNewUrl, releaseNotesUrl, buildArtifactConfigurations, getHttpContents{})
+type releaseBuilder interface {
+	prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error)
+}
+
+func (p *publisher) doRelease(whatsNewUrl string, releaseNotesUrl string, nightly bool) error {
+	currentRelease, err := p.builder.prepareRelease(p.baseArchiveUrl, whatsNewUrl, releaseNotesUrl, nightly)
 	if err != nil {
 		return err
 	}
 
-	if dryRun {
-		relJson, err := json.Marshal(currentRelease)
-		if err != nil {
-			return err
-		}
-		log.Println(string(relJson))
-
-		for _, b := range currentRelease.Builds {
-			artifactJson, err := json.Marshal(b)
-			if err != nil {
-				return err
-			}
-			log.Println(string(artifactJson))
-		}
-	} else {
-		if err := p.postRelease(currentRelease); err != nil {
-			return err
-		}
+	if err := p.postRelease(currentRelease); err != nil {
+		return err
 	}
 
 	return nil
 }
 
 func (p *publisher) postRelease(r *release) error {
-	err := p.postRequest("/grafana/versions", r, fmt.Sprintf("Create Release %s", r.Version))
+	err := p.postRequest("/versions", r, fmt.Sprintf("Create Release %s", r.Version))
 	if err != nil {
 		return err
 	}
-	err = p.postRequest("/grafana/versions/"+r.Version, r, fmt.Sprintf("Update Release %s", r.Version))
+	err = p.postRequest("/versions/"+r.Version, r, fmt.Sprintf("Update Release %s", r.Version))
 	if err != nil {
 		return err
 	}
 	for _, b := range r.Builds {
-		err = p.postRequest(fmt.Sprintf("/grafana/versions/%s/packages", r.Version), b, fmt.Sprintf("Create Build %s %s", b.Os, b.Arch))
+		err = p.postRequest(fmt.Sprintf("/versions/%s/packages", r.Version), b, fmt.Sprintf("Create Build %s %s", b.Os, b.Arch))
 		if err != nil {
 			return err
 		}
-		err = p.postRequest(fmt.Sprintf("/grafana/versions/%s/packages/%s/%s", r.Version, b.Arch, b.Os), b, fmt.Sprintf("Update Build %s %s", b.Os, b.Arch))
+		err = p.postRequest(fmt.Sprintf("/versions/%s/packages/%s/%s", r.Version, b.Arch, b.Os), b, fmt.Sprintf("Update Build %s %s", b.Os, b.Arch))
 		if err != nil {
 			return err
 		}
@@ -67,15 +61,13 @@ func (p *publisher) postRelease(r *release) error {
 	return nil
 }
 
-const baseArhiveUrl = "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana"
-
 type buildArtifact struct {
 	os         string
 	arch       string
 	urlPostfix string
 }
 
-func (t buildArtifact) getUrl(version string, isBeta bool) string {
+func (t buildArtifact) getUrl(baseArchiveUrl, version string, isBeta bool) string {
 	prefix := "-"
 	rhelReleaseExtra := ""
 
@@ -87,7 +79,7 @@ func (t buildArtifact) getUrl(version string, isBeta bool) string {
 		rhelReleaseExtra = "-1"
 	}
 
-	url := strings.Join([]string{baseArhiveUrl, prefix, version, rhelReleaseExtra, t.urlPostfix}, "")
+	url := strings.Join([]string{baseArchiveUrl, prefix, version, rhelReleaseExtra, t.urlPostfix}, "")
 	return url
 }
 
@@ -149,48 +141,32 @@ var buildArtifactConfigurations = []buildArtifact{
 	},
 }
 
-func newRelease(rawVersion string, whatsNewUrl string, releaseNotesUrl string, artifactConfigurations []buildArtifact, getter urlGetter) (*release, error) {
-	version := rawVersion[1:]
-	now := time.Now()
-	isBeta := strings.Contains(version, "beta")
-
-	builds := []build{}
-	for _, ba := range artifactConfigurations {
-		sha256, err := getter.getContents(fmt.Sprintf("%s.sha256", ba.getUrl(version, isBeta)))
-		if err != nil {
-			return nil, err
-		}
-		builds = append(builds, newBuild(ba, version, isBeta, sha256))
-	}
-
-	r := release{
-		Version:         version,
-		ReleaseDate:     time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local),
-		Stable:          !isBeta,
-		Beta:            isBeta,
-		Nightly:         false,
-		WhatsNewUrl:     whatsNewUrl,
-		ReleaseNotesUrl: releaseNotesUrl,
-		Builds:          builds,
-	}
-	return &r, nil
-}
-
-func newBuild(ba buildArtifact, version string, isBeta bool, sha256 string) build {
+func newBuild(baseArchiveUrl string, ba buildArtifact, version string, isBeta bool, sha256 string) build {
 	return build{
 		Os:     ba.os,
-		Url:    ba.getUrl(version, isBeta),
+		Url:    ba.getUrl(baseArchiveUrl, version, isBeta),
 		Sha256: sha256,
 		Arch:   ba.arch,
 	}
 }
 
+func (p *publisher) apiUrl(url string) string {
+	return fmt.Sprintf("%s/%s%s", p.apiUri, p.product, url)
+}
+
 func (p *publisher) postRequest(url string, obj interface{}, desc string) error {
 	jsonBytes, err := json.Marshal(obj)
 	if err != nil {
 		return err
 	}
-	req, err := http.NewRequest(http.MethodPost, baseUri+url, bytes.NewReader(jsonBytes))
+
+	if p.dryRun {
+		log.Println(fmt.Sprintf("POST to %s:", p.apiUrl(url)))
+		log.Println(string(jsonBytes))
+		return nil
+	}
+
+	req, err := http.NewRequest(http.MethodPost, p.apiUrl(url), bytes.NewReader(jsonBytes))
 	if err != nil {
 		return err
 	}
@@ -243,24 +219,3 @@ type build struct {
 	Sha256 string `json:"sha256"`
 	Arch   string `json:"arch"`
 }
-
-type urlGetter interface {
-	getContents(url string) (string, error)
-}
-
-type getHttpContents struct{}
-
-func (getHttpContents) getContents(url string) (string, error) {
-	response, err := http.Get(url)
-	if err != nil {
-		return "", err
-	}
-
-	defer response.Body.Close()
-	all, err := ioutil.ReadAll(response.Body)
-	if err != nil {
-		return "", err
-	}
-
-	return string(all), nil
-}

+ 79 - 3
scripts/build/release_publisher/publisher_test.go

@@ -2,16 +2,24 @@ package main
 
 import "testing"
 
-func TestNewRelease(t *testing.T) {
+func TestPreparingReleaseFromRemote(t *testing.T) {
 	versionIn := "v5.2.0-beta1"
 	expectedVersion := "5.2.0-beta1"
 	whatsNewUrl := "https://whatsnews.foo/"
 	relNotesUrl := "https://relnotes.foo/"
 	expectedArch := "amd64"
 	expectedOs := "linux"
-	buildArtifacts := []buildArtifact{{expectedOs, expectedArch, ".linux-amd64.tar.gz"}}
+	buildArtifacts := []buildArtifact{{expectedOs,expectedArch, ".linux-amd64.tar.gz"}}
 
-	rel, _ := newRelease(versionIn, whatsNewUrl, relNotesUrl, buildArtifacts, mockHttpGetter{})
+	var builder releaseBuilder
+
+	builder = releaseFromExternalContent{
+		getter:     mockHttpGetter{},
+		rawVersion: versionIn,
+		artifactConfigurations: buildArtifactConfigurations,
+	}
+
+	rel, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", whatsNewUrl, relNotesUrl, false)
 
 	if !rel.Beta || rel.Stable {
 		t.Errorf("%s should have been tagged as beta (not stable), but wasn't	.", versionIn)
@@ -41,3 +49,71 @@ type mockHttpGetter struct{}
 func (mockHttpGetter) getContents(url string) (string, error) {
 	return url, nil
 }
+
+
+func TestPreparingReleaseFromLocal(t *testing.T) {
+	whatsNewUrl := "https://whatsnews.foo/"
+	relNotesUrl := "https://relnotes.foo/"
+	expectedVersion := "5.4.0-123pre1"
+	expectedBuilds := 4
+
+	var builder releaseBuilder
+	testDataPath := "testdata"
+	builder = releaseLocalSources{
+		path:                   testDataPath,
+		artifactConfigurations: buildArtifactConfigurations,
+	}
+
+	relAll, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewUrl, relNotesUrl, true)
+
+	if relAll.Stable || !relAll.Nightly {
+		t.Error("Expected a nightly release but wasn't.")
+	}
+
+	if relAll.ReleaseNotesUrl != relNotesUrl {
+		t.Errorf("expected releaseNotesUrl to be %s, but it was %s", relNotesUrl, relAll.ReleaseNotesUrl)
+	}
+	if relAll.WhatsNewUrl != whatsNewUrl {
+		t.Errorf("expected whatsNewUrl to be %s, but it was %s", whatsNewUrl, relAll.WhatsNewUrl)
+	}
+
+	if relAll.Beta {
+		t.Errorf("Expected release to be nightly, not beta.")
+	}
+
+	if relAll.Version != expectedVersion {
+		t.Errorf("Expected version=%s, but got=%s", expectedVersion, relAll.Version)
+	}
+
+	if len(relAll.Builds) != expectedBuilds {
+		t.Errorf("Expected %v builds, but was %v", expectedBuilds, len(relAll.Builds))
+	}
+
+	expectedArch := "amd64"
+	expectedOs := "win"
+
+	builder = releaseLocalSources{
+		path:                   testDataPath,
+		artifactConfigurations: []buildArtifact{{
+			os:         expectedOs,
+			arch:       expectedArch,
+			urlPostfix: ".windows-amd64.zip",
+		}},
+	}
+
+	relOne, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewUrl, relNotesUrl, true)
+
+	if len(relOne.Builds) != 1 {
+		t.Errorf("Expected 1 artifact, but was %v", len(relOne.Builds))
+	}
+
+	build := relOne.Builds[0]
+
+	if build.Arch != expectedArch {
+		t.Fatalf("Expected arch to be %s, but was %s", expectedArch, build.Arch)
+	}
+
+	if build.Os != expectedOs {
+		t.Fatalf("Expected os to be %s, but was %s", expectedOs, build.Os)
+	}
+}

+ 0 - 0
scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz


+ 1 - 0
scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz.sha256

@@ -0,0 +1 @@
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

+ 0 - 0
scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip


+ 1 - 0
scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip.sha256

@@ -0,0 +1 @@
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

+ 0 - 0
scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm


+ 1 - 0
scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm.sha256

@@ -0,0 +1 @@
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

+ 0 - 0
scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb


+ 1 - 0
scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb.sha256

@@ -0,0 +1 @@
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

+ 1 - 1
scripts/webpack/webpack.common.js

@@ -47,7 +47,7 @@ module.exports = {
       },
       {
         test: /\.html$/,
-        exclude: /index\.template.html/,
+        exclude: /(index|error)\-template\.html/,
         use: [
           { loader: 'ngtemplate-loader?relativeTo=' + (path.resolve(__dirname, '../../public')) + '&prefix=public' },
           {

+ 7 - 2
scripts/webpack/webpack.dev.js

@@ -80,11 +80,16 @@ module.exports = merge(common, {
   plugins: [
     new CleanWebpackPlugin('../../public/build', { allowExternal: true }),
     new MiniCssExtractPlugin({
-      filename: "grafana.[name].css"
+      filename: "grafana.[name].[hash].css"
+    }),
+    new HtmlWebpackPlugin({
+      filename: path.resolve(__dirname, '../../public/views/error.html'),
+      template: path.resolve(__dirname, '../../public/views/error-template.html'),
+      inject: 'false',
     }),
     new HtmlWebpackPlugin({
       filename: path.resolve(__dirname, '../../public/views/index.html'),
-      template: path.resolve(__dirname, '../../public/views/index.template.html'),
+      template: path.resolve(__dirname, '../../public/views/index-template.html'),
       inject: 'body',
       chunks: ['manifest', 'vendor', 'app'],
     }),

+ 1 - 1
scripts/webpack/webpack.hot.js

@@ -83,7 +83,7 @@ module.exports = merge(common, {
     new CleanWebpackPlugin('../public/build', { allowExternal: true }),
     new HtmlWebpackPlugin({
       filename: path.resolve(__dirname, '../../public/views/index.html'),
-      template: path.resolve(__dirname, '../../public/views/index.template.html'),
+      template: path.resolve(__dirname, '../../public/views/index-template.html'),
       inject: 'body',
       alwaysWriteToDisk: true,
     }),

+ 7 - 2
scripts/webpack/webpack.prod.js

@@ -71,15 +71,20 @@ module.exports = merge(common, {
 
   plugins: [
     new MiniCssExtractPlugin({
-      filename: "grafana.[name].css"
+      filename: "grafana.[name].[hash].css"
     }),
     new ngAnnotatePlugin(),
     new HtmlWebpackPlugin({
       filename: path.resolve(__dirname, '../../public/views/index.html'),
-      template: path.resolve(__dirname, '../../public/views/index.template.html'),
+      template: path.resolve(__dirname, '../../public/views/index-template.html'),
       inject: 'body',
       chunks: ['vendor', 'app'],
     }),
+    new HtmlWebpackPlugin({
+      filename: path.resolve(__dirname, '../../public/views/error.html'),
+      template: path.resolve(__dirname, '../../public/views/error-template.html'),
+      inject: false,
+    }),
     function () {
       this.hooks.done.tap('Done', function (stats) {
         if (stats.compilation.errors && stats.compilation.errors.length) {