Explorar el Código

Merge remote-tracking branch 'upstream/master'

Benjamin Schweizer hace 7 años
padre
commit
4bf8f80657
Se han modificado 100 ficheros con 3743 adiciones y 949 borrados
  1. 4 0
      CHANGELOG.md
  2. 5 0
      conf/defaults.ini
  3. 5 0
      conf/sample.ini
  4. 3 1
      docs/sources/administration/provisioning.md
  5. 4 1
      docs/sources/features/datasources/postgres.md
  6. 1 1
      pkg/cmd/grafana-cli/commands/install_command.go
  7. 9 5
      pkg/cmd/grafana-server/main.go
  8. 17 0
      pkg/log/file.go
  9. 4 0
      pkg/log/handlers.go
  10. 9 0
      pkg/log/log.go
  11. 37 9
      pkg/services/rendering/http_mode.go
  12. 1 1
      pkg/services/rendering/phantomjs.go
  13. 2 2
      pkg/services/rendering/plugin_mode.go
  14. 30 8
      pkg/services/rendering/rendering.go
  15. 13 0
      pkg/setting/setting.go
  16. 11 0
      pkg/setting/setting_test.go
  17. 71 37
      public/app/containers/Teams/TeamList.tsx
  18. 2 0
      public/app/core/angular_wrappers.ts
  19. 96 0
      public/app/core/components/sidemenu/BottomNavLinks.test.tsx
  20. 78 0
      public/app/core/components/sidemenu/BottomNavLinks.tsx
  21. 44 0
      public/app/core/components/sidemenu/BottomSection.test.tsx
  22. 29 0
      public/app/core/components/sidemenu/BottomSection.tsx
  23. 35 0
      public/app/core/components/sidemenu/DropDownChild.test.tsx
  24. 21 0
      public/app/core/components/sidemenu/DropDownChild.tsx
  25. 70 0
      public/app/core/components/sidemenu/SideMenu.test.tsx
  26. 32 0
      public/app/core/components/sidemenu/SideMenu.tsx
  27. 35 0
      public/app/core/components/sidemenu/SideMenuDropDown.test.tsx
  28. 23 0
      public/app/core/components/sidemenu/SideMenuDropDown.tsx
  29. 11 0
      public/app/core/components/sidemenu/SignIn.test.tsx
  30. 23 0
      public/app/core/components/sidemenu/SignIn.tsx
  31. 41 0
      public/app/core/components/sidemenu/TopSection.test.tsx
  32. 19 0
      public/app/core/components/sidemenu/TopSection.tsx
  33. 22 0
      public/app/core/components/sidemenu/TopSectionItem.test.tsx
  34. 23 0
      public/app/core/components/sidemenu/TopSectionItem.tsx
  35. 163 0
      public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap
  36. 34 0
      public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap
  37. 21 0
      public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap
  38. 22 0
      public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
  39. 59 0
      public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
  40. 40 0
      public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap
  41. 33 0
      public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap
  42. 17 0
      public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap
  43. 0 81
      public/app/core/components/sidemenu/sidemenu.html
  44. 0 89
      public/app/core/components/sidemenu/sidemenu.ts
  45. 74 0
      public/app/core/components/sql_part/sql_part.ts
  46. 199 0
      public/app/core/components/sql_part/sql_part_editor.ts
  47. 2 2
      public/app/core/config.ts
  48. 2 2
      public/app/core/core.ts
  49. 2 0
      public/app/core/services/context_srv.ts
  50. 2 0
      public/app/core/utils/kbn.ts
  51. 1 1
      public/app/features/admin/admin.ts
  52. 10 10
      public/app/features/admin/admin_edit_org_ctrl.ts
  53. 21 21
      public/app/features/admin/admin_edit_user_ctrl.ts
  54. 6 6
      public/app/features/admin/admin_list_orgs_ctrl.ts
  55. 1 1
      public/app/features/annotations/specs/annotations_srv.test.ts
  56. 11 11
      public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts
  57. 33 33
      public/app/features/dashboard/specs/dashboard_migration.test.ts
  58. 67 67
      public/app/features/dashboard/specs/dashboard_model.test.ts
  59. 10 10
      public/app/features/dashboard/specs/history_srv.test.ts
  60. 38 38
      public/app/features/dashboard/specs/repeat.test.ts
  61. 2 2
      public/app/features/dashboard/specs/save_as_modal.test.ts
  62. 2 2
      public/app/features/dashboard/specs/save_provisioned_modal.test.ts
  63. 1 1
      public/app/features/dashboard/specs/share_modal_ctrl.test.ts
  64. 16 16
      public/app/features/dashboard/specs/time_srv.test.ts
  65. 5 5
      public/app/features/org/org_details_ctrl.ts
  66. 7 7
      public/app/features/panellinks/specs/link_srv.test.ts
  67. 1 1
      public/app/features/plugins/specs/datasource_srv.test.ts
  68. 5 5
      public/app/features/templating/specs/adhoc_variable.test.ts
  69. 85 85
      public/app/features/templating/specs/template_srv.test.ts
  70. 12 12
      public/app/features/templating/specs/variable.test.ts
  71. 21 21
      public/app/features/templating/specs/variable_srv.test.ts
  72. 5 4
      public/app/features/templating/variable_srv.ts
  73. 20 20
      public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
  74. 18 18
      public/app/plugins/datasource/elasticsearch/specs/datasource.test.ts
  75. 27 27
      public/app/plugins/datasource/graphite/specs/datasource.test.ts
  76. 25 25
      public/app/plugins/datasource/graphite/specs/gfunc.test.ts
  77. 15 15
      public/app/plugins/datasource/graphite/specs/lexer.test.ts
  78. 21 21
      public/app/plugins/datasource/graphite/specs/parser.test.ts
  79. 1 1
      public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts
  80. 3 3
      public/app/plugins/datasource/influxdb/specs/datasource.test.ts
  81. 34 34
      public/app/plugins/datasource/influxdb/specs/influx_query.test.ts
  82. 29 29
      public/app/plugins/datasource/influxdb/specs/influx_series.test.ts
  83. 19 19
      public/app/plugins/datasource/influxdb/specs/query_builder.test.ts
  84. 20 20
      public/app/plugins/datasource/mssql/specs/datasource.test.ts
  85. 18 18
      public/app/plugins/datasource/mysql/specs/datasource.test.ts
  86. 63 0
      public/app/plugins/datasource/postgres/config_ctrl.ts
  87. 28 17
      public/app/plugins/datasource/postgres/datasource.ts
  88. 176 0
      public/app/plugins/datasource/postgres/meta_query.ts
  89. 1 11
      public/app/plugins/datasource/postgres/module.ts
  90. 27 1
      public/app/plugins/datasource/postgres/partials/config.html
  91. 133 35
      public/app/plugins/datasource/postgres/partials/query.editor.html
  92. 1 0
      public/app/plugins/datasource/postgres/plugin.json
  93. 285 0
      public/app/plugins/datasource/postgres/postgres_query.ts
  94. 589 11
      public/app/plugins/datasource/postgres/query_ctrl.ts
  95. 30 18
      public/app/plugins/datasource/postgres/specs/datasource.test.ts
  96. 155 0
      public/app/plugins/datasource/postgres/specs/postgres_query.test.ts
  97. 137 0
      public/app/plugins/datasource/postgres/sql_part.ts
  98. 1 1
      public/app/plugins/datasource/prometheus/specs/completer.test.ts
  99. 6 6
      public/app/plugins/datasource/prometheus/specs/datasource.test.ts
  100. 1 1
      public/app/plugins/datasource/prometheus/specs/metric_find_query.test.ts

+ 4 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@
 * **Alerting**: Notification reminders [#7330](https://github.com/grafana/grafana/issues/7330), thx [@jbaublitz](https://github.com/jbaublitz)
 * **Dashboard**: TV & Kiosk mode changes, new cycle view mode button in dashboard toolbar [#13025](https://github.com/grafana/grafana/pull/13025)
 * **OAuth**: Gitlab OAuth with support for filter by groups [#5623](https://github.com/grafana/grafana/issues/5623), thx [@BenoitKnecht](https://github.com/BenoitKnecht)
+* **Postgres**: Graphical query builder [#10095](https://github.com/grafana/grafana/issues/10095), thx [svenklemm](https://github.com/svenklemm)
 
 ### New Features
 
@@ -19,6 +20,9 @@
 
 ### Minor
 
+* **Units**: Adds bitcoin axes unit. [#13125](https://github.com/grafana/grafana/pull/13125)
+* **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079)
+* **Logging**: Reopen log files after receiving a SIGHUP signal [#13112](https://github.com/grafana/grafana/pull/13112), thx [@filewalkwithme](https://github.com/filewalkwithme)
 * **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
 * **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248)
 * **Dashboard**: Use uid when linking to dashboards internally in a dashboard [#10705](https://github.com/grafana/grafana/issues/10705)

+ 5 - 0
conf/defaults.ini

@@ -538,3 +538,8 @@ container_name =
 
 [external_image_storage.local]
 # does not require any configuration
+
+[rendering]
+# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
+server_url =
+callback_url =

+ 5 - 0
conf/sample.ini

@@ -460,3 +460,8 @@ log_queries =
 
 [external_image_storage.local]
 # does not require any configuration
+
+[rendering]
+# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
+;server_url =
+;callback_url =

+ 3 - 1
docs/sources/administration/provisioning.md

@@ -155,7 +155,7 @@ Since not all datasources have the same configuration settings we only have the
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | graphiteVersion | string | Graphite |  Graphite version  |
 | timeInterval | string | Elastic, InfluxDB & Prometheus | Lowest interval/step value that should be used for this data source |
-| esVersion | number | Elastic | Elasticsearch version as an number (2/5/56) |
+| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) |
 | timeField | string | Elastic | Which field that should be used as timestamp |
 | interval | string | Elastic | Index date time format |
 | authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
@@ -165,6 +165,8 @@ Since not all datasources have the same configuration settings we only have the
 | tsdbVersion | string | OpenTSDB | Version |
 | tsdbResolution | string | OpenTSDB | Resolution |
 | sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
+| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
+| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
 
 #### Secure Json Data
 

+ 4 - 1
docs/sources/features/datasources/postgres.md

@@ -31,7 +31,9 @@ Name | Description
 *User* | Database user's login/username
 *Password* | Database user's password
 *SSL Mode* | This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server.
-*TimescaleDB* | With this option enabled Grafana will use TimescaleDB features, e.g. use ```time_bucket``` for grouping by time (only available in Grafana 5.3+).
+*Version* | This option determines which functions are available in the query builder (only available in Grafana 5.3+).
+*TimescaleDB* | TimescaleDB is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use `time_bucket` in the `$__timeGroup` macro and display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+).
+
 
 ### Database User Permissions (Important!)
 
@@ -292,5 +294,6 @@ datasources:
       password: "Password!"
     jsonData:
       sslmode: "disable" # disable/require/verify-ca/verify-full
+      postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10
       timescaledb: false
 ```

+ 1 - 1
pkg/cmd/grafana-cli/commands/install_command.go

@@ -152,7 +152,7 @@ func downloadFile(pluginName, filePath, url string) (err error) {
 		return err
 	}
 
-	r, err := zip.NewReader(bytes.NewReader(body), resp.ContentLength)
+	r, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
 	if err != nil {
 		return err
 	}

+ 9 - 5
pkg/cmd/grafana-server/main.go

@@ -96,13 +96,17 @@ func main() {
 
 func listenToSystemSignals(server *GrafanaServerImpl) {
 	signalChan := make(chan os.Signal, 1)
-	ignoreChan := make(chan os.Signal, 1)
+	sighupChan := make(chan os.Signal, 1)
 
-	signal.Notify(ignoreChan, syscall.SIGHUP)
+	signal.Notify(sighupChan, syscall.SIGHUP)
 	signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
 
-	select {
-	case sig := <-signalChan:
-		server.Shutdown(fmt.Sprintf("System signal: %s", sig))
+	for {
+		select {
+		case _ = <-sighupChan:
+			log.Reload()
+		case sig := <-signalChan:
+			server.Shutdown(fmt.Sprintf("System signal: %s", sig))
+		}
 	}
 }

+ 17 - 0
pkg/log/file.go

@@ -236,3 +236,20 @@ func (w *FileLogWriter) Close() {
 func (w *FileLogWriter) Flush() {
 	w.mw.fd.Sync()
 }
+
+// Reload file logger
+func (w *FileLogWriter) Reload() {
+	// block Logger's io.Writer
+	w.mw.Lock()
+	defer w.mw.Unlock()
+
+	// Close
+	fd := w.mw.fd
+	fd.Close()
+
+	// Open again
+	err := w.StartLogger()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Reload StartLogger: %s\n", err)
+	}
+}

+ 4 - 0
pkg/log/handlers.go

@@ -3,3 +3,7 @@ package log
 type DisposableHandler interface {
 	Close()
 }
+
+type ReloadableHandler interface {
+	Reload()
+}

+ 9 - 0
pkg/log/log.go

@@ -21,10 +21,12 @@ import (
 
 var Root log15.Logger
 var loggersToClose []DisposableHandler
+var loggersToReload []ReloadableHandler
 var filters map[string]log15.Lvl
 
 func init() {
 	loggersToClose = make([]DisposableHandler, 0)
+	loggersToReload = make([]ReloadableHandler, 0)
 	Root = log15.Root()
 	Root.SetHandler(log15.DiscardHandler())
 }
@@ -115,6 +117,12 @@ func Close() {
 	loggersToClose = make([]DisposableHandler, 0)
 }
 
+func Reload() {
+	for _, logger := range loggersToReload {
+		logger.Reload()
+	}
+}
+
 func GetLogLevelFor(name string) Lvl {
 	if level, ok := filters[name]; ok {
 		switch level {
@@ -230,6 +238,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 			fileHandler.Init()
 
 			loggersToClose = append(loggersToClose, fileHandler)
+			loggersToReload = append(loggersToReload, fileHandler)
 			handler = fileHandler
 		case "syslog":
 			sysLogHandler := NewSyslog(sec, format)

+ 37 - 9
pkg/services/rendering/http_mode.go

@@ -2,6 +2,7 @@ package rendering
 
 import (
 	"context"
+	"fmt"
 	"io"
 	"net"
 	"net/http"
@@ -20,14 +21,13 @@ var netTransport = &http.Transport{
 	TLSHandshakeTimeout: 5 * time.Second,
 }
 
+var netClient = &http.Client{
+	Transport: netTransport,
+}
+
 func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*RenderResult, error) {
 	filePath := rs.getFilePathForNewImage()
 
-	var netClient = &http.Client{
-		Timeout:   opts.Timeout,
-		Transport: netTransport,
-	}
-
 	rendererUrl, err := url.Parse(rs.Cfg.RendererUrl)
 	if err != nil {
 		return nil, err
@@ -35,10 +35,10 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend
 
 	queryParams := rendererUrl.Query()
 	queryParams.Add("url", rs.getURL(opts.Path))
-	queryParams.Add("renderKey", rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole))
+	queryParams.Add("renderKey", rs.getRenderKey(opts.OrgId, opts.UserId, opts.OrgRole))
 	queryParams.Add("width", strconv.Itoa(opts.Width))
 	queryParams.Add("height", strconv.Itoa(opts.Height))
-	queryParams.Add("domain", rs.getLocalDomain())
+	queryParams.Add("domain", rs.domain)
 	queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone))
 	queryParams.Add("encoding", opts.Encoding)
 	queryParams.Add("timeout", strconv.Itoa(int(opts.Timeout.Seconds())))
@@ -49,20 +49,48 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend
 		return nil, err
 	}
 
+	reqContext, cancel := context.WithTimeout(ctx, opts.Timeout+time.Second*2)
+	defer cancel()
+
+	req = req.WithContext(reqContext)
+
 	// make request to renderer server
 	resp, err := netClient.Do(req)
 	if err != nil {
-		return nil, err
+		rs.log.Error("Failed to send request to remote rendering service.", "error", err)
+		return nil, fmt.Errorf("Failed to send request to remote rendering service. %s", err)
 	}
 
 	// save response to file
 	defer resp.Body.Close()
+
+	// check for timeout first
+	if reqContext.Err() == context.DeadlineExceeded {
+		rs.log.Info("Rendering timed out")
+		return nil, ErrTimeout
+	}
+
+	// if we didnt get a 200 response, something went wrong.
+	if resp.StatusCode != http.StatusOK {
+		rs.log.Error("Remote rendering request failed", "error", resp.Status)
+		return nil, fmt.Errorf("Remote rendering request failed. %d: %s", resp.StatusCode, resp.Status)
+	}
+
 	out, err := os.Create(filePath)
 	if err != nil {
 		return nil, err
 	}
 	defer out.Close()
-	io.Copy(out, resp.Body)
+	_, err = io.Copy(out, resp.Body)
+	if err != nil {
+		// check that we didnt timeout while receiving the response.
+		if reqContext.Err() == context.DeadlineExceeded {
+			rs.log.Info("Rendering timed out")
+			return nil, ErrTimeout
+		}
+		rs.log.Error("Remote rendering request failed", "error", err)
+		return nil, fmt.Errorf("Remote rendering request failed.  %s", err)
+	}
 
 	return &RenderResult{FilePath: filePath}, err
 }

+ 1 - 1
pkg/services/rendering/phantomjs.go

@@ -49,7 +49,7 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
 		fmt.Sprintf("width=%v", opts.Width),
 		fmt.Sprintf("height=%v", opts.Height),
 		fmt.Sprintf("png=%v", pngPath),
-		fmt.Sprintf("domain=%v", rs.getLocalDomain()),
+		fmt.Sprintf("domain=%v", rs.domain),
 		fmt.Sprintf("timeout=%v", opts.Timeout.Seconds()),
 		fmt.Sprintf("renderKey=%v", renderKey),
 	}

+ 2 - 2
pkg/services/rendering/plugin_mode.go

@@ -77,10 +77,10 @@ func (rs *RenderingService) renderViaPlugin(ctx context.Context, opts Opts) (*Re
 		Height:    int32(opts.Height),
 		FilePath:  pngPath,
 		Timeout:   int32(opts.Timeout.Seconds()),
-		RenderKey: rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole),
+		RenderKey: rs.getRenderKey(opts.OrgId, opts.UserId, opts.OrgRole),
 		Encoding:  opts.Encoding,
 		Timezone:  isoTimeOffsetToPosixTz(opts.Timezone),
-		Domain:    rs.getLocalDomain(),
+		Domain:    rs.domain,
 	})
 
 	if err != nil {

+ 30 - 8
pkg/services/rendering/rendering.go

@@ -3,6 +3,8 @@ package rendering
 import (
 	"context"
 	"fmt"
+	"net/url"
+	"os"
 	"path/filepath"
 
 	plugin "github.com/hashicorp/go-plugin"
@@ -27,12 +29,31 @@ type RenderingService struct {
 	grpcPlugin   pluginModel.RendererPlugin
 	pluginInfo   *plugins.RendererPlugin
 	renderAction renderFunc
+	domain       string
 
 	Cfg *setting.Cfg `inject:""`
 }
 
 func (rs *RenderingService) Init() error {
 	rs.log = log.New("rendering")
+
+	// ensure ImagesDir exists
+	err := os.MkdirAll(rs.Cfg.ImagesDir, 0700)
+	if err != nil {
+		return err
+	}
+
+	// set value used for domain attribute of renderKey cookie
+	if rs.Cfg.RendererUrl != "" {
+		// RendererCallbackUrl has already been passed, it wont generate an error.
+		u, _ := url.Parse(rs.Cfg.RendererCallbackUrl)
+		rs.domain = u.Hostname()
+	} else if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
+		rs.domain = setting.HttpAddr
+	} else {
+		rs.domain = "localhost"
+	}
+
 	return nil
 }
 
@@ -82,16 +103,17 @@ func (rs *RenderingService) getFilePathForNewImage() string {
 }
 
 func (rs *RenderingService) getURL(path string) string {
-	// &render=1 signals to the legacy redirect layer to
-	return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.getLocalDomain(), setting.HttpPort, path)
-}
+	if rs.Cfg.RendererUrl != "" {
+		// The backend rendering service can potentially be remote.
+		// So we need to use the root_url to ensure the rendering service
+		// can reach this Grafana instance.
 
-func (rs *RenderingService) getLocalDomain() string {
-	if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
-		return setting.HttpAddr
-	}
+		// &render=1 signals to the legacy redirect layer to
+		return fmt.Sprintf("%s%s&render=1", rs.Cfg.RendererCallbackUrl, path)
 
-	return "localhost"
+	}
+	// &render=1 signals to the legacy redirect layer to
+	return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.domain, setting.HttpPort, path)
 }
 
 func (rs *RenderingService) getRenderKey(orgId, userId int64, orgRole models.RoleType) string {

+ 13 - 0
pkg/setting/setting.go

@@ -197,6 +197,7 @@ type Cfg struct {
 	ImagesDir                        string
 	PhantomDir                       string
 	RendererUrl                      string
+	RendererCallbackUrl              string
 	DisableBruteForceLoginProtection bool
 
 	TempDataLifetime time.Duration
@@ -641,6 +642,18 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	// Rendering
 	renderSec := iniFile.Section("rendering")
 	cfg.RendererUrl = renderSec.Key("server_url").String()
+	cfg.RendererCallbackUrl = renderSec.Key("callback_url").String()
+	if cfg.RendererCallbackUrl == "" {
+		cfg.RendererCallbackUrl = AppUrl
+	} else {
+		if cfg.RendererCallbackUrl[len(cfg.RendererCallbackUrl)-1] != '/' {
+			cfg.RendererCallbackUrl += "/"
+		}
+		_, err := url.Parse(cfg.RendererCallbackUrl)
+		if err != nil {
+			log.Fatal(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err)
+		}
+	}
 	cfg.ImagesDir = filepath.Join(DataPath, "png")
 	cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
 	cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)

+ 11 - 0
pkg/setting/setting_test.go

@@ -20,6 +20,7 @@ func TestLoadingSettings(t *testing.T) {
 			So(err, ShouldBeNil)
 
 			So(AdminUser, ShouldEqual, "admin")
+			So(cfg.RendererCallbackUrl, ShouldEqual, "http://localhost:3000/")
 		})
 
 		Convey("Should be able to override via environment variables", func() {
@@ -178,5 +179,15 @@ func TestLoadingSettings(t *testing.T) {
 			So(InstanceName, ShouldEqual, hostname)
 		})
 
+		Convey("Reading callback_url should add trailing slash", func() {
+			cfg := NewCfg()
+			cfg.Load(&CommandLineArgs{
+				HomePath: "../../",
+				Args:     []string{"cfg:rendering.callback_url=http://myserver/renderer"},
+			})
+
+			So(cfg.RendererCallbackUrl, ShouldEqual, "http://myserver/renderer/")
+		})
+
 	})
 }

+ 71 - 37
public/app/containers/Teams/TeamList.tsx

@@ -6,6 +6,7 @@ import { NavStore } from 'app/stores/NavStore/NavStore';
 import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore';
 import { BackendSrv } from 'app/core/services/backend_srv';
 import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
 
 interface Props {
   nav: typeof NavStore.Type;
@@ -61,48 +62,81 @@ export class TeamList extends React.Component<Props, any> {
     );
   }
 
+  renderTeamList(teams) {
+    return (
+      <div className="page-container page-body">
+        <div className="page-action-bar">
+          <div className="gf-form gf-form--grow">
+            <label className="gf-form--has-input-icon gf-form--grow">
+              <input
+                type="text"
+                className="gf-form-input"
+                placeholder="Search teams"
+                value={teams.search}
+                onChange={this.onSearchQueryChange}
+              />
+              <i className="gf-form-input-icon fa fa-search" />
+            </label>
+          </div>
+
+          <div className="page-action-bar__spacer" />
+
+          <a className="btn btn-success" href="org/teams/new">
+            <i className="fa fa-plus" /> New team
+          </a>
+        </div>
+
+        <div className="admin-list-table">
+          <table className="filter-table filter-table--hover form-inline">
+            <thead>
+              <tr>
+                <th />
+                <th>Name</th>
+                <th>Email</th>
+                <th>Members</th>
+                <th style={{ width: '1%' }} />
+              </tr>
+            </thead>
+            <tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
+          </table>
+        </div>
+      </div>
+    );
+  }
+
+  renderEmptyList() {
+    return (
+      <div className="page-container page-body">
+        <EmptyListCTA
+          model={{
+            title: "You haven't created any teams yet.",
+            buttonIcon: 'fa fa-plus',
+            buttonLink: 'org/teams/new',
+            buttonTitle: ' New team',
+            proTip: 'Assign folder and dashboard permissions to teams instead of users to ease administration.',
+            proTipLink: '',
+            proTipLinkTitle: '',
+            proTipTarget: '_blank',
+          }}
+        />
+      </div>
+    );
+  }
+
   render() {
     const { nav, teams } = this.props;
+    let view;
+
+    if (teams.filteredTeams.length > 0) {
+      view = this.renderTeamList(teams);
+    } else {
+      view = this.renderEmptyList();
+    }
+
     return (
       <div>
         <PageHeader model={nav as any} />
-        <div className="page-container page-body">
-          <div className="page-action-bar">
-            <div className="gf-form gf-form--grow">
-              <label className="gf-form--has-input-icon gf-form--grow">
-                <input
-                  type="text"
-                  className="gf-form-input"
-                  placeholder="Search teams"
-                  value={teams.search}
-                  onChange={this.onSearchQueryChange}
-                />
-                <i className="gf-form-input-icon fa fa-search" />
-              </label>
-            </div>
-
-            <div className="page-action-bar__spacer" />
-
-            <a className="btn btn-success" href="org/teams/new">
-              <i className="fa fa-plus" /> New team
-            </a>
-          </div>
-
-          <div className="admin-list-table">
-            <table className="filter-table filter-table--hover form-inline">
-              <thead>
-                <tr>
-                  <th />
-                  <th>Name</th>
-                  <th>Email</th>
-                  <th>Members</th>
-                  <th style={{ width: '1%' }} />
-                </tr>
-              </thead>
-              <tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
-            </table>
-          </div>
-        </div>
+        {view}
       </div>
     );
   }

+ 2 - 0
public/app/core/angular_wrappers.ts

@@ -4,10 +4,12 @@ import PageHeader from './components/PageHeader/PageHeader';
 import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 import { SearchResult } from './components/search/SearchResult';
 import { TagFilter } from './components/TagFilter/TagFilter';
+import { SideMenu } from './components/sidemenu/SideMenu';
 import DashboardPermissions from './components/Permissions/DashboardPermissions';
 
 export function registerAngularDirectives() {
   react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
+  react2AngularDirective('sidemenu', SideMenu, []);
   react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
   react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
   react2AngularDirective('searchResult', SearchResult, []);

+ 96 - 0
public/app/core/components/sidemenu/BottomNavLinks.test.tsx

@@ -0,0 +1,96 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import BottomNavLinks from './BottomNavLinks';
+import appEvents from '../../app_events';
+
+jest.mock('../../app_events', () => ({
+  emit: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      link: {},
+      user: {
+        isGrafanaAdmin: false,
+        isSignedIn: false,
+        orgCount: 2,
+        orgRole: '',
+        orgId: 1,
+        orgName: 'Grafana',
+        timezone: 'UTC',
+        helpFlags1: 1,
+        lightTheme: false,
+        hasEditPermissionInFolders: false,
+      },
+    },
+    propOverrides
+  );
+  return shallow(<BottomNavLinks {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render organisation switcher', () => {
+    const wrapper = setup({
+      link: {
+        showOrgSwitcher: true,
+      },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render subtitle', () => {
+    const wrapper = setup({
+      link: {
+        subTitle: 'subtitle',
+      },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render children', () => {
+    const wrapper = setup({
+      link: {
+        children: [
+          {
+            id: '1',
+          },
+          {
+            id: '2',
+          },
+          {
+            id: '3',
+          },
+          {
+            id: '4',
+            hideFromMenu: true,
+          },
+        ],
+      },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  describe('item clicked', () => {
+    const wrapper = setup();
+    const mockEvent = { preventDefault: jest.fn() };
+    it('should emit show modal event if url matches shortcut', () => {
+      const child = { url: '/shortcuts' };
+      const instance = wrapper.instance() as BottomNavLinks;
+      instance.itemClicked(mockEvent, child);
+
+      expect(appEvents.emit).toHaveBeenCalledWith('show-modal', { templateHtml: '<help-modal></help-modal>' });
+    });
+  });
+});

+ 78 - 0
public/app/core/components/sidemenu/BottomNavLinks.tsx

@@ -0,0 +1,78 @@
+import React, { PureComponent } from 'react';
+import appEvents from '../../app_events';
+import { User } from '../../services/context_srv';
+
+export interface Props {
+  link: any;
+  user: User;
+}
+
+class BottomNavLinks extends PureComponent<Props> {
+  itemClicked = (event, child) => {
+    if (child.url === '/shortcuts') {
+      event.preventDefault();
+      appEvents.emit('show-modal', {
+        templateHtml: '<help-modal></help-modal>',
+      });
+    }
+  };
+
+  switchOrg = () => {
+    appEvents.emit('show-modal', {
+      templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
+    });
+  };
+
+  render() {
+    const { link, user } = this.props;
+    return (
+      <div className="sidemenu-item dropdown dropup">
+        <a href={link.url} className="sidemenu-link" target={link.target}>
+          <span className="icon-circle sidemenu-icon">
+            {link.icon && <i className={link.icon} />}
+            {link.img && <img src={link.img} />}
+          </span>
+        </a>
+        <ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
+          {link.subTitle && (
+            <li className="sidemenu-subtitle">
+              <span className="sidemenu-item-text">{link.subTitle}</span>
+            </li>
+          )}
+          {link.showOrgSwitcher && (
+            <li className="sidemenu-org-switcher">
+              <a onClick={this.switchOrg}>
+                <div>
+                  <div className="sidemenu-org-switcher__org-name">{user.orgName}</div>
+                  <div className="sidemenu-org-switcher__org-current">Current Org:</div>
+                </div>
+                <div className="sidemenu-org-switcher__switch">
+                  <i className="fa fa-fw fa-random" />Switch
+                </div>
+              </a>
+            </li>
+          )}
+          {link.children &&
+            link.children.map((child, index) => {
+              if (!child.hideFromMenu) {
+                return (
+                  <li className={child.divider} key={`${child.text}-${index}`}>
+                    <a href={child.url} target={child.target} onClick={event => this.itemClicked(event, child)}>
+                      {child.icon && <i className={child.icon} />}
+                      {child.text}
+                    </a>
+                  </li>
+                );
+              }
+              return null;
+            })}
+          <li className="side-menu-header">
+            <span className="sidemenu-item-text">{link.text}</span>
+          </li>
+        </ul>
+      </div>
+    );
+  }
+}
+
+export default BottomNavLinks;

+ 44 - 0
public/app/core/components/sidemenu/BottomSection.test.tsx

@@ -0,0 +1,44 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import BottomSection from './BottomSection';
+
+jest.mock('../../config', () => ({
+  bootData: {
+    navTree: [
+      {
+        id: 'profile',
+        hideFromMenu: true,
+      },
+      {
+        hideFromMenu: true,
+      },
+      {
+        hideFromMenu: false,
+      },
+      {
+        hideFromMenu: true,
+      },
+    ],
+  },
+  user: {
+    orgCount: 5,
+    orgName: 'Grafana',
+  },
+}));
+
+jest.mock('app/core/services/context_srv', () => ({
+  contextSrv: {
+    sidemenu: true,
+    isSignedIn: false,
+    isGrafanaAdmin: false,
+    hasEditPermissionFolders: false,
+  },
+}));
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = shallow(<BottomSection />);
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 29 - 0
public/app/core/components/sidemenu/BottomSection.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+import _ from 'lodash';
+import SignIn from './SignIn';
+import BottomNavLinks from './BottomNavLinks';
+import { contextSrv } from 'app/core/services/context_srv';
+import config from '../../config';
+
+export default function BottomSection() {
+  const navTree = _.cloneDeep(config.bootData.navTree);
+  const bottomNav = _.filter(navTree, item => item.hideFromMenu);
+  const isSignedIn = contextSrv.isSignedIn;
+  const user = contextSrv.user;
+
+  if (user && user.orgCount > 1) {
+    const profileNode = _.find(bottomNav, { id: 'profile' });
+    if (profileNode) {
+      profileNode.showOrgSwitcher = true;
+    }
+  }
+
+  return (
+    <div className="sidemenu__bottom">
+      {!isSignedIn && <SignIn />}
+      {bottomNav.map((link, index) => {
+        return <BottomNavLinks link={link} user={user} key={`${link.url}-${index}`} />;
+      })}
+    </div>
+  );
+}

+ 35 - 0
public/app/core/components/sidemenu/DropDownChild.test.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import DropDownChild from './DropDownChild';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      child: {
+        divider: true,
+      },
+    },
+    propOverrides
+  );
+
+  return shallow(<DropDownChild {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render icon if exists', () => {
+    const wrapper = setup({
+      child: {
+        divider: false,
+        icon: 'icon-test',
+      },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 21 - 0
public/app/core/components/sidemenu/DropDownChild.tsx

@@ -0,0 +1,21 @@
+import React, { SFC } from 'react';
+
+export interface Props {
+  child: any;
+}
+
+const DropDownChild: SFC<Props> = props => {
+  const { child } = props;
+  const listItemClassName = child.divider ? 'divider' : '';
+
+  return (
+    <li className={listItemClassName}>
+      <a href={child.url}>
+        {child.icon && <i className={child.icon} />}
+        {child.text}
+      </a>
+    </li>
+  );
+};
+
+export default DropDownChild;

+ 70 - 0
public/app/core/components/sidemenu/SideMenu.test.tsx

@@ -0,0 +1,70 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { SideMenu } from './SideMenu';
+import appEvents from '../../app_events';
+import { contextSrv } from 'app/core/services/context_srv';
+
+jest.mock('../../app_events', () => ({
+  emit: jest.fn(),
+}));
+
+jest.mock('app/core/services/context_srv', () => ({
+  contextSrv: {
+    sidemenu: true,
+    user: {},
+    isSignedIn: false,
+    isGrafanaAdmin: false,
+    isEditor: false,
+    hasEditPermissionFolders: false,
+    toggleSideMenu: jest.fn(),
+  },
+}));
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      loginUrl: '',
+      user: {},
+      mainLinks: [],
+      bottomeLinks: [],
+      isSignedIn: false,
+    },
+    propOverrides
+  );
+
+  return shallow(<SideMenu {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  describe('toggle side menu', () => {
+    const wrapper = setup();
+    const instance = wrapper.instance() as SideMenu;
+    instance.toggleSideMenu();
+
+    it('should call contextSrv.toggleSideMenu', () => {
+      expect(contextSrv.toggleSideMenu).toHaveBeenCalled();
+    });
+
+    it('should emit toggle sidemenu event', () => {
+      expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu');
+    });
+  });
+
+  describe('toggle side menu on mobile', () => {
+    const wrapper = setup();
+    const instance = wrapper.instance() as SideMenu;
+    instance.toggleSideMenuSmallBreakpoint();
+
+    it('should emit toggle sidemenu event', () => {
+      expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu-mobile');
+    });
+  });
+});

+ 32 - 0
public/app/core/components/sidemenu/SideMenu.tsx

@@ -0,0 +1,32 @@
+import React, { PureComponent } from 'react';
+import appEvents from '../../app_events';
+import { contextSrv } from 'app/core/services/context_srv';
+import TopSection from './TopSection';
+import BottomSection from './BottomSection';
+
+export class SideMenu extends PureComponent {
+  toggleSideMenu = () => {
+    contextSrv.toggleSideMenu();
+    appEvents.emit('toggle-sidemenu');
+  };
+
+  toggleSideMenuSmallBreakpoint = () => {
+    appEvents.emit('toggle-sidemenu-mobile');
+  };
+
+  render() {
+    return [
+      <div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
+        <img src="public/img/grafana_icon.svg" alt="graphana_logo" />
+      </div>,
+      <div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
+        <i className="fa fa-bars" />
+        <span className="sidemenu__close">
+          <i className="fa fa-times" />&nbsp;Close
+        </span>
+      </div>,
+      <TopSection key="topsection" />,
+      <BottomSection key="bottomsection" />,
+    ];
+  }
+}

+ 35 - 0
public/app/core/components/sidemenu/SideMenuDropDown.test.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import SideMenuDropDown from './SideMenuDropDown';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      link: {
+        text: 'link',
+      },
+    },
+    propOverrides
+  );
+
+  return shallow(<SideMenuDropDown {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render children', () => {
+    const wrapper = setup({
+      link: {
+        text: 'link',
+        children: [{ id: 1 }, { id: 2 }, { id: 3 }],
+      },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 23 - 0
public/app/core/components/sidemenu/SideMenuDropDown.tsx

@@ -0,0 +1,23 @@
+import React, { SFC } from 'react';
+import DropDownChild from './DropDownChild';
+
+interface Props {
+  link: any;
+}
+
+const SideMenuDropDown: SFC<Props> = props => {
+  const { link } = props;
+  return (
+    <ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
+      <li className="side-menu-header">
+        <span className="sidemenu-item-text">{link.text}</span>
+      </li>
+      {link.children &&
+        link.children.map((child, index) => {
+          return <DropDownChild child={child} key={`${child.url}-${index}`} />;
+        })}
+    </ul>
+  );
+};
+
+export default SideMenuDropDown;

+ 11 - 0
public/app/core/components/sidemenu/SignIn.test.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import SignIn from './SignIn';
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = shallow(<SignIn />);
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 23 - 0
public/app/core/components/sidemenu/SignIn.tsx

@@ -0,0 +1,23 @@
+import React, { SFC } from 'react';
+
+const SignIn: SFC<any> = () => {
+  const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`;
+  return (
+    <div className="sidemenu-item">
+      <a href={loginUrl} className="sidemenu-link" target="_self">
+        <span className="icon-circle sidemenu-icon">
+          <i className="fa fa-fw fa-sign-in" />
+        </span>
+      </a>
+      <a href={loginUrl} target="_self">
+        <ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
+          <li className="side-menu-header">
+            <span className="sidemenu-item-text">Sign In</span>
+          </li>
+        </ul>
+      </a>
+    </div>
+  );
+};
+
+export default SignIn;

+ 41 - 0
public/app/core/components/sidemenu/TopSection.test.tsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import TopSection from './TopSection';
+
+jest.mock('../../config', () => ({
+  bootData: {
+    navTree: [
+      { id: '1', hideFromMenu: true },
+      { id: '2', hideFromMenu: true },
+      { id: '3', hideFromMenu: false },
+      { id: '4', hideFromMenu: true },
+    ],
+  },
+}));
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      mainLinks: [],
+    },
+    propOverrides
+  );
+
+  return shallow(<TopSection {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render items', () => {
+    const wrapper = setup({
+      mainLinks: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }],
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 19 - 0
public/app/core/components/sidemenu/TopSection.tsx

@@ -0,0 +1,19 @@
+import React, { SFC } from 'react';
+import _ from 'lodash';
+import TopSectionItem from './TopSectionItem';
+import config from '../../config';
+
+const TopSection: SFC<any> = () => {
+  const navTree = _.cloneDeep(config.bootData.navTree);
+  const mainLinks = _.filter(navTree, item => !item.hideFromMenu);
+
+  return (
+    <div className="sidemenu__top">
+      {mainLinks.map((link, index) => {
+        return <TopSectionItem link={link} key={`${link.id}-${index}`} />;
+      })}
+    </div>
+  );
+};
+
+export default TopSection;

+ 22 - 0
public/app/core/components/sidemenu/TopSectionItem.test.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import TopSectionItem from './TopSectionItem';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      link: {},
+    },
+    propOverrides
+  );
+
+  return shallow(<TopSectionItem {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 23 - 0
public/app/core/components/sidemenu/TopSectionItem.tsx

@@ -0,0 +1,23 @@
+import React, { SFC } from 'react';
+import SideMenuDropDown from './SideMenuDropDown';
+
+export interface Props {
+  link: any;
+}
+
+const TopSectionItem: SFC<Props> = props => {
+  const { link } = props;
+  return (
+    <div className="sidemenu-item dropdown">
+      <a className="sidemenu-link" href={link.url} target={link.target}>
+        <span className="icon-circle sidemenu-icon">
+          <i className={link.icon} />
+          {link.img && <img src={link.img} />}
+        </span>
+      </a>
+      {link.children && <SideMenuDropDown link={link} />}
+    </div>
+  );
+};
+
+export default TopSectionItem;

+ 163 - 0
public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap

@@ -0,0 +1,163 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render children 1`] = `
+<div
+  className="sidemenu-item dropdown dropup"
+>
+  <a
+    className="sidemenu-link"
+  >
+    <span
+      className="icon-circle sidemenu-icon"
+    />
+  </a>
+  <ul
+    className="dropdown-menu dropdown-menu--sidemenu"
+    role="menu"
+  >
+    <li
+      key="undefined-0"
+    >
+      <a
+        onClick={[Function]}
+      />
+    </li>
+    <li
+      key="undefined-1"
+    >
+      <a
+        onClick={[Function]}
+      />
+    </li>
+    <li
+      key="undefined-2"
+    >
+      <a
+        onClick={[Function]}
+      />
+    </li>
+    <li
+      className="side-menu-header"
+    >
+      <span
+        className="sidemenu-item-text"
+      />
+    </li>
+  </ul>
+</div>
+`;
+
+exports[`Render should render component 1`] = `
+<div
+  className="sidemenu-item dropdown dropup"
+>
+  <a
+    className="sidemenu-link"
+  >
+    <span
+      className="icon-circle sidemenu-icon"
+    />
+  </a>
+  <ul
+    className="dropdown-menu dropdown-menu--sidemenu"
+    role="menu"
+  >
+    <li
+      className="side-menu-header"
+    >
+      <span
+        className="sidemenu-item-text"
+      />
+    </li>
+  </ul>
+</div>
+`;
+
+exports[`Render should render organisation switcher 1`] = `
+<div
+  className="sidemenu-item dropdown dropup"
+>
+  <a
+    className="sidemenu-link"
+  >
+    <span
+      className="icon-circle sidemenu-icon"
+    />
+  </a>
+  <ul
+    className="dropdown-menu dropdown-menu--sidemenu"
+    role="menu"
+  >
+    <li
+      className="sidemenu-org-switcher"
+    >
+      <a
+        onClick={[Function]}
+      >
+        <div>
+          <div
+            className="sidemenu-org-switcher__org-name"
+          >
+            Grafana
+          </div>
+          <div
+            className="sidemenu-org-switcher__org-current"
+          >
+            Current Org:
+          </div>
+        </div>
+        <div
+          className="sidemenu-org-switcher__switch"
+        >
+          <i
+            className="fa fa-fw fa-random"
+          />
+          Switch
+        </div>
+      </a>
+    </li>
+    <li
+      className="side-menu-header"
+    >
+      <span
+        className="sidemenu-item-text"
+      />
+    </li>
+  </ul>
+</div>
+`;
+
+exports[`Render should render subtitle 1`] = `
+<div
+  className="sidemenu-item dropdown dropup"
+>
+  <a
+    className="sidemenu-link"
+  >
+    <span
+      className="icon-circle sidemenu-icon"
+    />
+  </a>
+  <ul
+    className="dropdown-menu dropdown-menu--sidemenu"
+    role="menu"
+  >
+    <li
+      className="sidemenu-subtitle"
+    >
+      <span
+        className="sidemenu-item-text"
+      >
+        subtitle
+      </span>
+    </li>
+    <li
+      className="side-menu-header"
+    >
+      <span
+        className="sidemenu-item-text"
+      />
+    </li>
+  </ul>
+</div>
+`;

+ 34 - 0
public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap

@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="sidemenu__bottom"
+>
+  <SignIn />
+  <BottomNavLinks
+    key="undefined-0"
+    link={
+      Object {
+        "hideFromMenu": true,
+        "id": "profile",
+      }
+    }
+  />
+  <BottomNavLinks
+    key="undefined-1"
+    link={
+      Object {
+        "hideFromMenu": true,
+      }
+    }
+  />
+  <BottomNavLinks
+    key="undefined-2"
+    link={
+      Object {
+        "hideFromMenu": true,
+      }
+    }
+  />
+</div>
+`;

+ 21 - 0
public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap

@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<li
+  className="divider"
+>
+  <a />
+</li>
+`;
+
+exports[`Render should render icon if exists 1`] = `
+<li
+  className=""
+>
+  <a>
+    <i
+      className="icon-test"
+    />
+  </a>
+</li>
+`;

+ 22 - 0
public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap

@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+Array [
+  <div
+    className="sidemenu__logo"
+    key="logo"
+    onClick={[Function]}
+  />,
+  <div
+    className="sidemenu__logo_small_breakpoint"
+    key="hamburger"
+    onClick={[Function]}
+  />,
+  <TopSection
+    key="topsection"
+  />,
+  <BottomSection
+    key="bottomsection"
+  />,
+]
+`;

+ 59 - 0
public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap

@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render children 1`] = `
+<ul
+  className="dropdown-menu dropdown-menu--sidemenu"
+  role="menu"
+>
+  <li
+    className="side-menu-header"
+  >
+    <span
+      className="sidemenu-item-text"
+    >
+      link
+    </span>
+  </li>
+  <DropDownChild
+    child={
+      Object {
+        "id": 1,
+      }
+    }
+    key="undefined-0"
+  />
+  <DropDownChild
+    child={
+      Object {
+        "id": 2,
+      }
+    }
+    key="undefined-1"
+  />
+  <DropDownChild
+    child={
+      Object {
+        "id": 3,
+      }
+    }
+    key="undefined-2"
+  />
+</ul>
+`;
+
+exports[`Render should render component 1`] = `
+<ul
+  className="dropdown-menu dropdown-menu--sidemenu"
+  role="menu"
+>
+  <li
+    className="side-menu-header"
+  >
+    <span
+      className="sidemenu-item-text"
+    >
+      link
+    </span>
+  </li>
+</ul>
+`;

+ 40 - 0
public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap

@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="sidemenu-item"
+>
+  <a
+    className="sidemenu-link"
+    href="login?redirect=blank"
+    target="_self"
+  >
+    <span
+      className="icon-circle sidemenu-icon"
+    >
+      <i
+        className="fa fa-fw fa-sign-in"
+      />
+    </span>
+  </a>
+  <a
+    href="login?redirect=blank"
+    target="_self"
+  >
+    <ul
+      className="dropdown-menu dropdown-menu--sidemenu"
+      role="menu"
+    >
+      <li
+        className="side-menu-header"
+      >
+        <span
+          className="sidemenu-item-text"
+        >
+          Sign In
+        </span>
+      </li>
+    </ul>
+  </a>
+</div>
+`;

+ 33 - 0
public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap

@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="sidemenu__top"
+>
+  <TopSectionItem
+    key="3-0"
+    link={
+      Object {
+        "hideFromMenu": false,
+        "id": "3",
+      }
+    }
+  />
+</div>
+`;
+
+exports[`Render should render items 1`] = `
+<div
+  className="sidemenu__top"
+>
+  <TopSectionItem
+    key="3-0"
+    link={
+      Object {
+        "hideFromMenu": false,
+        "id": "3",
+      }
+    }
+  />
+</div>
+`;

+ 17 - 0
public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap

@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="sidemenu-item dropdown"
+>
+  <a
+    className="sidemenu-link"
+  >
+    <span
+      className="icon-circle sidemenu-icon"
+    >
+      <i />
+    </span>
+  </a>
+</div>
+`;

+ 0 - 81
public/app/core/components/sidemenu/sidemenu.html

@@ -1,81 +0,0 @@
-<a class="sidemenu__logo" ng-click="ctrl.toggleSideMenu()">
-  <img src="public/img/grafana_icon.svg"></img>
-</a>
-
-<a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
-  <i class="fa fa-bars"></i>
-  <span class="sidemenu__close">
-    <i class="fa fa-times"></i>&nbsp;Close</span>
-</a>
-
-<div class="sidemenu__top">
-  <div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
-    <a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
-      <span class="icon-circle sidemenu-icon">
-        <i class="{{::item.icon}}" ng-show="::item.icon"></i>
-        <img ng-src="{{::item.img}}" ng-show="::item.img">
-      </span>
-    </a>
-    <ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
-      <li class="side-menu-header">
-        <span class="sidemenu-item-text">{{::item.text}}</span>
-      </li>
-      <li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
-        <a href="{{::child.url}}">
-          <i class="{{::child.icon}}" ng-show="::child.icon"></i>
-          {{::child.text}}
-        </a>
-      </li>
-    </ul>
-  </div>
-</div>
-
-<div class="sidemenu__bottom">
-  <div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
-    <a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
-      <span class="icon-circle sidemenu-icon">
-        <i class="fa fa-fw fa-sign-in"></i>
-      </span>
-    </a>
-    <a href="{{ctrl.loginUrl}}" target="_self">
-      <ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
-        <li class="side-menu-header">
-          <span class="sidemenu-item-text">Sign In</span>
-        </li>
-      </ul>
-    </a>
-  </div>
-
-  <div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
-    <a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
-      <span class="icon-circle sidemenu-icon">
-        <i class="{{::item.icon}}" ng-show="::item.icon"></i>
-        <img ng-src="{{::item.img}}" ng-show="::item.img">
-      </span>
-    </a>
-    <ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
-      <li ng-if="item.subTitle" class="sidemenu-subtitle">
-        <span class="sidemenu-item-text">{{::item.subTitle}}</span>
-      </li>
-      <li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
-        <a ng-click="ctrl.switchOrg()">
-          <div>
-            <div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
-            <div class="sidemenu-org-switcher__org-current">Current Org:</div>
-          </div>
-          <div class="sidemenu-org-switcher__switch">
-            <i class="fa fa-fw fa-random"></i>Switch</div>
-        </a>
-      </li>
-      <li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
-        <a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
-          <i class="{{::child.icon}}" ng-show="::child.icon"></i>
-          {{::child.text}}
-        </a>
-      </li>
-      <li class="side-menu-header">
-        <span class="sidemenu-item-text">{{::item.text}}</span>
-      </li>
-    </ul>
-  </div>
-</div>

+ 0 - 89
public/app/core/components/sidemenu/sidemenu.ts

@@ -1,89 +0,0 @@
-import _ from 'lodash';
-import config from 'app/core/config';
-import $ from 'jquery';
-import coreModule from '../../core_module';
-import appEvents from 'app/core/app_events';
-
-export class SideMenuCtrl {
-  user: any;
-  mainLinks: any;
-  bottomNav: any;
-  loginUrl: string;
-  isSignedIn: boolean;
-  isOpenMobile: boolean;
-
-  /** @ngInject */
-  constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
-    this.isSignedIn = contextSrv.isSignedIn;
-    this.user = contextSrv.user;
-
-    const navTree = _.cloneDeep(config.bootData.navTree);
-    this.mainLinks = _.filter(navTree, item => !item.hideFromMenu);
-    this.bottomNav = _.filter(navTree, item => item.hideFromMenu);
-    this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path());
-
-    if (contextSrv.user.orgCount > 1) {
-      const profileNode = _.find(this.bottomNav, { id: 'profile' });
-      if (profileNode) {
-        profileNode.showOrgSwitcher = true;
-      }
-    }
-
-    this.$scope.$on('$routeChangeSuccess', () => {
-      this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path());
-    });
-  }
-
-  toggleSideMenu() {
-    this.contextSrv.toggleSideMenu();
-    appEvents.emit('toggle-sidemenu');
-
-    this.$timeout(() => {
-      this.$rootScope.$broadcast('render');
-    });
-  }
-
-  toggleSideMenuSmallBreakpoint() {
-    appEvents.emit('toggle-sidemenu-mobile');
-  }
-
-  switchOrg() {
-    this.$rootScope.appEvent('show-modal', {
-      templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
-    });
-  }
-
-  itemClicked(item, evt) {
-    if (item.url === '/shortcuts') {
-      appEvents.emit('show-modal', {
-        templateHtml: '<help-modal></help-modal>',
-      });
-      evt.preventDefault();
-    }
-  }
-}
-
-export function sideMenuDirective() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/core/components/sidemenu/sidemenu.html',
-    controller: SideMenuCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {},
-    link: function(scope, elem) {
-      // hack to hide dropdown menu
-      elem.on('click.dropdown', '.dropdown-menu a', function(evt) {
-        const menu = $(evt.target).parents('.dropdown-menu');
-        const parent = menu.parent();
-        menu.detach();
-
-        setTimeout(function() {
-          parent.append(menu);
-        }, 100);
-      });
-    },
-  };
-}
-
-coreModule.directive('sidemenu', sideMenuDirective);

+ 74 - 0
public/app/core/components/sql_part/sql_part.ts

@@ -0,0 +1,74 @@
+import _ from 'lodash';
+
+export class SqlPartDef {
+  type: string;
+  style: string;
+  label: string;
+  params: any[];
+  defaultParams: any[];
+  wrapOpen: string;
+  wrapClose: string;
+  separator: string;
+
+  constructor(options: any) {
+    this.type = options.type;
+    if (options.label) {
+      this.label = options.label;
+    } else {
+      this.label = this.type[0].toUpperCase() + this.type.substring(1) + ':';
+    }
+    this.style = options.style;
+    if (this.style === 'function') {
+      this.wrapOpen = '(';
+      this.wrapClose = ')';
+      this.separator = ', ';
+    } else {
+      this.wrapOpen = ' ';
+      this.wrapClose = ' ';
+      this.separator = ' ';
+    }
+    this.params = options.params;
+    this.defaultParams = options.defaultParams;
+  }
+}
+
+export class SqlPart {
+  part: any;
+  def: SqlPartDef;
+  params: any[];
+  label: string;
+  name: string;
+  datatype: string;
+
+  constructor(part: any, def: any) {
+    this.part = part;
+    this.def = def;
+    if (!this.def) {
+      throw { message: 'Could not find sql part ' + part.type };
+    }
+
+    this.datatype = part.datatype;
+
+    if (part.name) {
+      this.name = part.name;
+      this.label = def.label + ' ' + part.name;
+    } else {
+      this.name = '';
+      this.label = def.label;
+    }
+
+    part.params = part.params || _.clone(this.def.defaultParams);
+    this.params = part.params;
+  }
+
+  updateParam(strValue, index) {
+    // handle optional parameters
+    if (strValue === '' && this.def.params[index].optional) {
+      this.params.splice(index, 1);
+    } else {
+      this.params[index] = strValue;
+    }
+
+    this.part.params = this.params;
+  }
+}

+ 199 - 0
public/app/core/components/sql_part/sql_part_editor.ts

@@ -0,0 +1,199 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import coreModule from 'app/core/core_module';
+
+const template = `
+<div class="dropdown cascade-open">
+<a ng-click="showActionsMenu()" class="query-part-name pointer dropdown-toggle" data-toggle="dropdown">{{part.label}}</a>
+<span>{{part.def.wrapOpen}}</span><span class="query-part-parameters"></span><span>{{part.def.wrapClose}}</span>
+<ul class="dropdown-menu">
+  <li ng-repeat="action in partActions">
+    <a ng-click="triggerPartAction(action)">{{action.text}}</a>
+  </li>
+</ul>
+`;
+
+/** @ngInject */
+export function sqlPartEditorDirective($compile, templateSrv) {
+  const paramTemplate = '<input type="text" class="hide input-mini"></input>';
+
+  return {
+    restrict: 'E',
+    template: template,
+    scope: {
+      part: '=',
+      handleEvent: '&',
+      debounce: '@',
+    },
+    link: function postLink($scope, elem) {
+      const part = $scope.part;
+      const partDef = part.def;
+      const $paramsContainer = elem.find('.query-part-parameters');
+      const debounceLookup = $scope.debounce;
+      let cancelBlur = null;
+
+      $scope.partActions = [];
+
+      function clickFuncParam(this: any, paramIndex) {
+        /*jshint validthis:true */
+        const $link = $(this);
+        const $input = $link.next();
+
+        $input.val(part.params[paramIndex]);
+        $input.css('width', $link.width() + 16 + 'px');
+
+        $link.hide();
+        $input.show();
+        $input.focus();
+        $input.select();
+
+        const typeahead = $input.data('typeahead');
+        if (typeahead) {
+          $input.val('');
+          typeahead.lookup();
+        }
+      }
+
+      function inputBlur($input, paramIndex) {
+        cancelBlur = setTimeout(function() {
+          switchToLink($input, paramIndex);
+        }, 200);
+      }
+
+      function switchToLink($input, paramIndex) {
+        /*jshint validthis:true */
+        const $link = $input.prev();
+        const newValue = $input.val();
+
+        if (newValue !== '' || part.def.params[paramIndex].optional) {
+          $link.html(templateSrv.highlightVariablesAsHtml(newValue));
+
+          part.updateParam($input.val(), paramIndex);
+          $scope.$apply(() => {
+            $scope.handleEvent({ $event: { name: 'part-param-changed' } });
+          });
+        }
+
+        $input.hide();
+        $link.show();
+      }
+
+      function inputKeyPress(this: any, paramIndex, e) {
+        /*jshint validthis:true */
+        if (e.which === 13) {
+          switchToLink($(this), paramIndex);
+        }
+      }
+
+      function inputKeyDown(this: any) {
+        /*jshint validthis:true */
+        this.style.width = (3 + this.value.length) * 8 + 'px';
+      }
+
+      function addTypeahead($input, param, paramIndex) {
+        if (!param.options && !param.dynamicLookup) {
+          return;
+        }
+
+        const typeaheadSource = function(query, callback) {
+          if (param.options) {
+            let options = param.options;
+            if (param.type === 'int') {
+              options = _.map(options, function(val) {
+                return val.toString();
+              });
+            }
+            return options;
+          }
+
+          $scope.$apply(function() {
+            $scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(function(result) {
+              const dynamicOptions = _.map(result, function(op) {
+                return op.value;
+              });
+
+              // add current value to dropdown if it's not in dynamicOptions
+              if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) {
+                dynamicOptions.unshift(part.params[paramIndex]);
+              }
+
+              callback(dynamicOptions);
+            });
+          });
+        };
+
+        $input.attr('data-provide', 'typeahead');
+
+        $input.typeahead({
+          source: typeaheadSource,
+          minLength: 0,
+          items: 1000,
+          updater: function(value) {
+            if (value === part.params[paramIndex]) {
+              clearTimeout(cancelBlur);
+              $input.focus();
+              return value;
+            }
+            return value;
+          },
+        });
+
+        const typeahead = $input.data('typeahead');
+        typeahead.lookup = function() {
+          this.query = this.$element.val() || '';
+          const items = this.source(this.query, $.proxy(this.process, this));
+          return items ? this.process(items) : items;
+        };
+
+        if (debounceLookup) {
+          typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
+        }
+      }
+
+      $scope.showActionsMenu = function() {
+        $scope.handleEvent({ $event: { name: 'get-part-actions' } }).then(res => {
+          $scope.partActions = res;
+        });
+      };
+
+      $scope.triggerPartAction = function(action) {
+        $scope.handleEvent({ $event: { name: 'action', action: action } });
+      };
+
+      function addElementsAndCompile() {
+        _.each(partDef.params, function(param, index) {
+          if (param.optional && part.params.length <= index) {
+            return;
+          }
+
+          if (index > 0) {
+            $('<span>' + partDef.separator + '</span>').appendTo($paramsContainer);
+          }
+
+          const paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
+          const $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
+          const $input = $(paramTemplate);
+
+          $paramLink.appendTo($paramsContainer);
+          $input.appendTo($paramsContainer);
+
+          $input.blur(_.partial(inputBlur, $input, index));
+          $input.keyup(inputKeyDown);
+          $input.keypress(_.partial(inputKeyPress, index));
+          $paramLink.click(_.partial(clickFuncParam, index));
+
+          addTypeahead($input, param, index);
+        });
+      }
+
+      function relink() {
+        $paramsContainer.empty();
+        addElementsAndCompile();
+      }
+
+      relink();
+    },
+  };
+}
+
+coreModule.directive('sqlPartEditor', sqlPartEditorDirective);

+ 2 - 2
public/app/core/config.ts

@@ -33,9 +33,9 @@ export class Settings {
   constructor(options) {
     const defaults = {
       datasources: {},
-      window_title_prefix: 'Grafana - ',
+      windowTitlePrefix: 'Grafana - ',
       panels: {},
-      new_panel_title: 'Panel Title',
+      newPanelTitle: 'Panel Title',
       playlist_timespan: '1m',
       unsaved_changes_warning: true,
       appSubUrl: '',

+ 2 - 2
public/app/core/core.ts

@@ -20,7 +20,6 @@ import './services/search_srv';
 import './services/ng_react';
 
 import { grafanaAppDirective } from './components/grafana_app';
-import { sideMenuDirective } from './components/sidemenu/sidemenu';
 import { searchDirective } from './components/search/search';
 import { infoPopover } from './components/info_popover';
 import { navbarDirective } from './components/navbar/navbar';
@@ -31,6 +30,7 @@ import { layoutSelector } from './components/layout_selector/layout_selector';
 import { switchDirective } from './components/switch';
 import { dashboardSelector } from './components/dashboard_selector';
 import { queryPartEditorDirective } from './components/query_part/query_part_editor';
+import { sqlPartEditorDirective } from './components/sql_part/sql_part_editor';
 import { formDropdownDirective } from './components/form_dropdown/form_dropdown';
 import 'app/core/controllers/all';
 import 'app/core/services/all';
@@ -61,7 +61,6 @@ export {
   arrayJoin,
   coreModule,
   grafanaAppDirective,
-  sideMenuDirective,
   navbarDirective,
   searchDirective,
   liveSrv,
@@ -72,6 +71,7 @@ export {
   appEvents,
   dashboardSelector,
   queryPartEditorDirective,
+  sqlPartEditorDirective,
   colors,
   formDropdownDirective,
   assignModelProperties,

+ 2 - 0
public/app/core/services/context_srv.ts

@@ -8,6 +8,8 @@ export class User {
   isSignedIn: any;
   orgRole: any;
   orgId: number;
+  orgName: string;
+  orgCount: number;
   timezone: string;
   helpFlags1: number;
   lightTheme: boolean;

+ 2 - 0
public/app/core/utils/kbn.ts

@@ -450,6 +450,7 @@ kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
 kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk');
 kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF');
 kbn.valueFormats.currencyPLN = kbn.formatBuilders.currency('zł');
+kbn.valueFormats.currencyBTC = kbn.formatBuilders.currency('฿');
 
 // Data (Binary)
 kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
@@ -882,6 +883,7 @@ kbn.getUnitFormats = function() {
         { text: 'Czech koruna (czk)', value: 'currencyCZK' },
         { text: 'Swiss franc (CHF)', value: 'currencyCHF' },
         { text: 'Polish Złoty (PLN)', value: 'currencyPLN' },
+        { text: 'Bitcoin (฿)', value: 'currencyBTC' },
       ],
     },
     {

+ 1 - 1
public/app/features/admin/admin.ts

@@ -12,7 +12,7 @@ class AdminSettingsCtrl {
   constructor($scope, backendSrv, navModelSrv) {
     this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-settings', 1);
 
-    backendSrv.get('/api/admin/settings').then(function(settings) {
+    backendSrv.get('/api/admin/settings').then(settings => {
       $scope.settings = settings;
     });
   }

+ 10 - 10
public/app/features/admin/admin_edit_org_ctrl.ts

@@ -3,7 +3,7 @@ import angular from 'angular';
 export class AdminEditOrgCtrl {
   /** @ngInject */
   constructor($scope, $routeParams, backendSrv, $location, navModelSrv) {
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1);
 
       if ($routeParams.id) {
@@ -12,34 +12,34 @@ export class AdminEditOrgCtrl {
       }
     };
 
-    $scope.getOrg = function(id) {
-      backendSrv.get('/api/orgs/' + id).then(function(org) {
+    $scope.getOrg = id => {
+      backendSrv.get('/api/orgs/' + id).then(org => {
         $scope.org = org;
       });
     };
 
-    $scope.getOrgUsers = function(id) {
-      backendSrv.get('/api/orgs/' + id + '/users').then(function(orgUsers) {
+    $scope.getOrgUsers = id => {
+      backendSrv.get('/api/orgs/' + id + '/users').then(orgUsers => {
         $scope.orgUsers = orgUsers;
       });
     };
 
-    $scope.update = function() {
+    $scope.update = () => {
       if (!$scope.orgDetailsForm.$valid) {
         return;
       }
 
-      backendSrv.put('/api/orgs/' + $scope.org.id, $scope.org).then(function() {
+      backendSrv.put('/api/orgs/' + $scope.org.id, $scope.org).then(() => {
         $location.path('/admin/orgs');
       });
     };
 
-    $scope.updateOrgUser = function(orgUser) {
+    $scope.updateOrgUser = orgUser => {
       backendSrv.patch('/api/orgs/' + orgUser.orgId + '/users/' + orgUser.userId, orgUser);
     };
 
-    $scope.removeOrgUser = function(orgUser) {
-      backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + orgUser.userId).then(function() {
+    $scope.removeOrgUser = orgUser => {
+      backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + orgUser.userId).then(() => {
         $scope.getOrgUsers($scope.org.id);
       });
     };

+ 21 - 21
public/app/features/admin/admin_edit_user_ctrl.ts

@@ -9,72 +9,72 @@ export class AdminEditUserCtrl {
     $scope.permissions = {};
     $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-users', 1);
 
-    $scope.init = function() {
+    $scope.init = () => {
       if ($routeParams.id) {
         $scope.getUser($routeParams.id);
         $scope.getUserOrgs($routeParams.id);
       }
     };
 
-    $scope.getUser = function(id) {
-      backendSrv.get('/api/users/' + id).then(function(user) {
+    $scope.getUser = id => {
+      backendSrv.get('/api/users/' + id).then(user => {
         $scope.user = user;
         $scope.user_id = id;
         $scope.permissions.isGrafanaAdmin = user.isGrafanaAdmin;
       });
     };
 
-    $scope.setPassword = function() {
+    $scope.setPassword = () => {
       if (!$scope.passwordForm.$valid) {
         return;
       }
 
       const payload = { password: $scope.password };
-      backendSrv.put('/api/admin/users/' + $scope.user_id + '/password', payload).then(function() {
+      backendSrv.put('/api/admin/users/' + $scope.user_id + '/password', payload).then(() => {
         $location.path('/admin/users');
       });
     };
 
-    $scope.updatePermissions = function() {
+    $scope.updatePermissions = () => {
       const payload = $scope.permissions;
 
-      backendSrv.put('/api/admin/users/' + $scope.user_id + '/permissions', payload).then(function() {
+      backendSrv.put('/api/admin/users/' + $scope.user_id + '/permissions', payload).then(() => {
         $location.path('/admin/users');
       });
     };
 
-    $scope.create = function() {
+    $scope.create = () => {
       if (!$scope.userForm.$valid) {
         return;
       }
 
-      backendSrv.post('/api/admin/users', $scope.user).then(function() {
+      backendSrv.post('/api/admin/users', $scope.user).then(() => {
         $location.path('/admin/users');
       });
     };
 
-    $scope.getUserOrgs = function(id) {
-      backendSrv.get('/api/users/' + id + '/orgs').then(function(orgs) {
+    $scope.getUserOrgs = id => {
+      backendSrv.get('/api/users/' + id + '/orgs').then(orgs => {
         $scope.orgs = orgs;
       });
     };
 
-    $scope.update = function() {
+    $scope.update = () => {
       if (!$scope.userForm.$valid) {
         return;
       }
 
-      backendSrv.put('/api/users/' + $scope.user_id, $scope.user).then(function() {
+      backendSrv.put('/api/users/' + $scope.user_id, $scope.user).then(() => {
         $location.path('/admin/users');
       });
     };
 
-    $scope.updateOrgUser = function(orgUser) {
-      backendSrv.patch('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id, orgUser).then(function() {});
+    $scope.updateOrgUser = orgUser => {
+      backendSrv.patch('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id, orgUser).then(() => {});
     };
 
-    $scope.removeOrgUser = function(orgUser) {
-      backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id).then(function() {
+    $scope.removeOrgUser = orgUser => {
+      backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id).then(() => {
         $scope.getUser($scope.user_id);
         $scope.getUserOrgs($scope.user_id);
       });
@@ -82,19 +82,19 @@ export class AdminEditUserCtrl {
 
     $scope.orgsSearchCache = [];
 
-    $scope.searchOrgs = function(queryStr, callback) {
+    $scope.searchOrgs = (queryStr, callback) => {
       if ($scope.orgsSearchCache.length > 0) {
         callback(_.map($scope.orgsSearchCache, 'name'));
         return;
       }
 
-      backendSrv.get('/api/orgs', { query: '' }).then(function(result) {
+      backendSrv.get('/api/orgs', { query: '' }).then(result => {
         $scope.orgsSearchCache = result;
         callback(_.map(result, 'name'));
       });
     };
 
-    $scope.addOrgUser = function() {
+    $scope.addOrgUser = () => {
       if (!$scope.addOrgForm.$valid) {
         return;
       }
@@ -108,7 +108,7 @@ export class AdminEditUserCtrl {
 
       $scope.newOrg.loginOrEmail = $scope.user.login;
 
-      backendSrv.post('/api/orgs/' + orgInfo.id + '/users/', $scope.newOrg).then(function() {
+      backendSrv.post('/api/orgs/' + orgInfo.id + '/users/', $scope.newOrg).then(() => {
         $scope.getUser($scope.user_id);
         $scope.getUserOrgs($scope.user_id);
       });

+ 6 - 6
public/app/features/admin/admin_list_orgs_ctrl.ts

@@ -3,26 +3,26 @@ import angular from 'angular';
 export class AdminListOrgsCtrl {
   /** @ngInject */
   constructor($scope, backendSrv, navModelSrv) {
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1);
       $scope.getOrgs();
     };
 
-    $scope.getOrgs = function() {
-      backendSrv.get('/api/orgs').then(function(orgs) {
+    $scope.getOrgs = () => {
+      backendSrv.get('/api/orgs').then(orgs => {
         $scope.orgs = orgs;
       });
     };
 
-    $scope.deleteOrg = function(org) {
+    $scope.deleteOrg = org => {
       $scope.appEvent('confirm-modal', {
         title: 'Delete',
         text: 'Do you want to delete organization ' + org.name + '?',
         text2: 'All dashboards for this organization will be removed!',
         icon: 'fa-trash',
         yesText: 'Delete',
-        onConfirm: function() {
-          backendSrv.delete('/api/orgs/' + org.id).then(function() {
+        onConfirm: () => {
+          backendSrv.delete('/api/orgs/' + org.id).then(() => {
             $scope.getOrgs();
           });
         },

+ 1 - 1
public/app/features/annotations/specs/annotations_srv.test.ts

@@ -2,7 +2,7 @@ import '../annotations_srv';
 import 'app/features/dashboard/time_srv';
 import { AnnotationsSrv } from '../annotations_srv';
 
-describe('AnnotationsSrv', function() {
+describe('AnnotationsSrv', () => {
   const $rootScope = {
     onAppEvent: jest.fn(),
   };

+ 11 - 11
public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts

@@ -1,7 +1,7 @@
 import { DashboardImportCtrl } from '../dashboard_import_ctrl';
 import config from '../../../core/config';
 
-describe('DashboardImportCtrl', function() {
+describe('DashboardImportCtrl', () => {
   const ctx: any = {};
 
   let navModelSrv;
@@ -26,8 +26,8 @@ describe('DashboardImportCtrl', function() {
     ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {});
   });
 
-  describe('when uploading json', function() {
-    beforeEach(function() {
+  describe('when uploading json', () => {
+    beforeEach(() => {
       config.datasources = {
         ds: {
           type: 'test-db',
@@ -46,19 +46,19 @@ describe('DashboardImportCtrl', function() {
       });
     });
 
-    it('should build input model', function() {
+    it('should build input model', () => {
       expect(ctx.ctrl.inputs.length).toBe(1);
       expect(ctx.ctrl.inputs[0].name).toBe('ds');
       expect(ctx.ctrl.inputs[0].info).toBe('Select a Test DB data source');
     });
 
-    it('should set inputValid to false', function() {
+    it('should set inputValid to false', () => {
       expect(ctx.ctrl.inputsValid).toBe(false);
     });
   });
 
-  describe('when specifying grafana.com url', function() {
-    beforeEach(function() {
+  describe('when specifying grafana.com url', () => {
+    beforeEach(() => {
       ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123';
       // setup api mock
       backendSrv.get = jest.fn(() => {
@@ -69,13 +69,13 @@ describe('DashboardImportCtrl', function() {
       return ctx.ctrl.checkGnetDashboard();
     });
 
-    it('should call gnet api with correct dashboard id', function() {
+    it('should call gnet api with correct dashboard id', () => {
       expect(backendSrv.get.mock.calls[0][0]).toBe('api/gnet/dashboards/123');
     });
   });
 
-  describe('when specifying dashboard id', function() {
-    beforeEach(function() {
+  describe('when specifying dashboard id', () => {
+    beforeEach(() => {
       ctx.ctrl.gnetUrl = '2342';
       // setup api mock
       backendSrv.get = jest.fn(() => {
@@ -86,7 +86,7 @@ describe('DashboardImportCtrl', function() {
       return ctx.ctrl.checkGnetDashboard();
     });
 
-    it('should call gnet api with correct dashboard id', function() {
+    it('should call gnet api with correct dashboard id', () => {
       expect(backendSrv.get.mock.calls[0][0]).toBe('api/gnet/dashboards/2342');
     });
   });

+ 33 - 33
public/app/features/dashboard/specs/dashboard_migration.test.ts

@@ -6,14 +6,14 @@ import { expect } from 'test/lib/common';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 
-describe('DashboardModel', function() {
-  describe('when creating dashboard with old schema', function() {
+describe('DashboardModel', () => {
+  describe('when creating dashboard with old schema', () => {
     let model;
     let graph;
     let singlestat;
     let table;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         services: {
           filter: { time: { from: 'now-1d', to: 'now' }, list: [{}] },
@@ -65,52 +65,52 @@ describe('DashboardModel', function() {
       table = model.panels[2];
     });
 
-    it('should have title', function() {
+    it('should have title', () => {
       expect(model.title).toBe('No Title');
     });
 
-    it('should have panel id', function() {
+    it('should have panel id', () => {
       expect(graph.id).toBe(1);
     });
 
-    it('should move time and filtering list', function() {
+    it('should move time and filtering list', () => {
       expect(model.time.from).toBe('now-1d');
       expect(model.templating.list[0].allFormat).toBe('glob');
     });
 
-    it('graphite panel should change name too graph', function() {
+    it('graphite panel should change name too graph', () => {
       expect(graph.type).toBe('graph');
     });
 
-    it('single stat panel should have two thresholds', function() {
+    it('single stat panel should have two thresholds', () => {
       expect(singlestat.thresholds).toBe('20,30');
     });
 
-    it('queries without refId should get it', function() {
+    it('queries without refId should get it', () => {
       expect(graph.targets[1].refId).toBe('B');
     });
 
-    it('update legend setting', function() {
+    it('update legend setting', () => {
       expect(graph.legend.show).toBe(true);
     });
 
-    it('move aliasYAxis to series override', function() {
+    it('move aliasYAxis to series override', () => {
       expect(graph.seriesOverrides[0].alias).toBe('test');
       expect(graph.seriesOverrides[0].yaxis).toBe(2);
     });
 
-    it('should move pulldowns to new schema', function() {
+    it('should move pulldowns to new schema', () => {
       expect(model.annotations.list[1].name).toBe('old');
     });
 
-    it('table panel should only have two thresholds values', function() {
+    it('table panel should only have two thresholds values', () => {
       expect(table.styles[0].thresholds[0]).toBe('20');
       expect(table.styles[0].thresholds[1]).toBe('30');
       expect(table.styles[1].thresholds[0]).toBe('200');
       expect(table.styles[1].thresholds[1]).toBe('300');
     });
 
-    it('graph grid to yaxes options', function() {
+    it('graph grid to yaxes options', () => {
       expect(graph.yaxes[0].min).toBe(1);
       expect(graph.yaxes[0].max).toBe(10);
       expect(graph.yaxes[0].format).toBe('kbyte');
@@ -126,11 +126,11 @@ describe('DashboardModel', function() {
       expect(graph.y_formats).toBe(undefined);
     });
 
-    it('dashboard schema version should be set to latest', function() {
+    it('dashboard schema version should be set to latest', () => {
       expect(model.schemaVersion).toBe(16);
     });
 
-    it('graph thresholds should be migrated', function() {
+    it('graph thresholds should be migrated', () => {
       expect(graph.thresholds.length).toBe(2);
       expect(graph.thresholds[0].op).toBe('gt');
       expect(graph.thresholds[0].value).toBe(200);
@@ -140,16 +140,16 @@ describe('DashboardModel', function() {
     });
   });
 
-  describe('when migrating to the grid layout', function() {
+  describe('when migrating to the grid layout', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = {
         rows: [],
       };
     });
 
-    it('should create proper grid', function() {
+    it('should create proper grid', () => {
       model.rows = [createRow({ collapse: false, height: 8 }, [[6], [6]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -158,7 +158,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should add special "row" panel if row is collapsed', function() {
+    it('should add special "row" panel if row is collapsed', () => {
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]]), createRow({ height: 8 }, [[12]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -171,7 +171,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should add special "row" panel if row has visible title', function() {
+    it('should add special "row" panel if row has visible title', () => {
       model.rows = [
         createRow({ showTitle: true, title: 'Row', height: 8 }, [[6], [6]]),
         createRow({ height: 8 }, [[12]]),
@@ -189,7 +189,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should not add "row" panel if row has not visible title or not collapsed', function() {
+    it('should not add "row" panel if row has not visible title or not collapsed', () => {
       model.rows = [
         createRow({ collapse: true, height: 8 }, [[12]]),
         createRow({ height: 8 }, [[12]]),
@@ -212,7 +212,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should add all rows if even one collapsed or titled row is present', function() {
+    it('should add all rows if even one collapsed or titled row is present', () => {
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]]), createRow({ height: 8 }, [[12]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -225,7 +225,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should properly place panels with fixed height', function() {
+    it('should properly place panels with fixed height', () => {
       model.rows = [
         createRow({ height: 6 }, [[6], [6, 3], [6, 3]]),
         createRow({ height: 6 }, [[4], [4], [4, 3], [4, 3]]),
@@ -245,7 +245,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should place panel to the right side of panel having bigger height', function() {
+    it('should place panel to the right side of panel having bigger height', () => {
       model.rows = [createRow({ height: 6 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -260,7 +260,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should fill current row if it possible', function() {
+    it('should fill current row if it possible', () => {
       model.rows = [createRow({ height: 9 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -276,7 +276,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should fill current row if it possible (2)', function() {
+    it('should fill current row if it possible (2)', () => {
       model.rows = [createRow({ height: 8 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -292,7 +292,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should fill current row if panel height more than row height', function() {
+    it('should fill current row if panel height more than row height', () => {
       model.rows = [createRow({ height: 6 }, [[4], [2, 3], [4, 8], [2, 3], [2, 3]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -307,7 +307,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should wrap panels to multiple rows', function() {
+    it('should wrap panels to multiple rows', () => {
       model.rows = [createRow({ height: 6 }, [[6], [6], [12], [6], [3], [3]])];
       const dashboard = new DashboardModel(model);
       const panelGridPos = getGridPositions(dashboard);
@@ -323,7 +323,7 @@ describe('DashboardModel', function() {
       expect(panelGridPos).toEqual(expectedGrid);
     });
 
-    it('should add repeated row if repeat set', function() {
+    it('should add repeated row if repeat set', () => {
       model.rows = [
         createRow({ showTitle: true, title: 'Row', height: 8, repeat: 'server' }, [[6]]),
         createRow({ height: 8 }, [[12]]),
@@ -344,7 +344,7 @@ describe('DashboardModel', function() {
       expect(dashboard.panels[3].repeat).toBeUndefined();
     });
 
-    it('should ignore repeated row', function() {
+    it('should ignore repeated row', () => {
       model.rows = [
         createRow({ showTitle: true, title: 'Row1', height: 8, repeat: 'server' }, [[6]]),
         createRow(
@@ -364,7 +364,7 @@ describe('DashboardModel', function() {
       expect(dashboard.panels.length).toBe(2);
     });
 
-    it('minSpan should be twice', function() {
+    it('minSpan should be twice', () => {
       model.rows = [createRow({ height: 8 }, [[6]])];
       model.rows[0].panels[0] = { minSpan: 12 };
 
@@ -372,7 +372,7 @@ describe('DashboardModel', function() {
       expect(dashboard.panels[0].minSpan).toBe(24);
     });
 
-    it('should assign id', function() {
+    it('should assign id', () => {
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
       model.rows[0].panels[0] = {};
 

+ 67 - 67
public/app/features/dashboard/specs/dashboard_model.test.ts

@@ -4,43 +4,43 @@ import { PanelModel } from '../panel_model';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 
-describe('DashboardModel', function() {
-  describe('when creating new dashboard model defaults only', function() {
+describe('DashboardModel', () => {
+  describe('when creating new dashboard model defaults only', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({}, {});
     });
 
-    it('should have title', function() {
+    it('should have title', () => {
       expect(model.title).toBe('No Title');
     });
 
-    it('should have meta', function() {
+    it('should have meta', () => {
       expect(model.meta.canSave).toBe(true);
       expect(model.meta.canShare).toBe(true);
     });
 
-    it('should have default properties', function() {
+    it('should have default properties', () => {
       expect(model.panels.length).toBe(0);
     });
   });
 
-  describe('when getting next panel id', function() {
+  describe('when getting next panel id', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         panels: [{ id: 5 }],
       });
     });
 
-    it('should return max id + 1', function() {
+    it('should return max id + 1', () => {
       expect(model.getNextPanelId()).toBe(6);
     });
   });
 
-  describe('getSaveModelClone', function() {
+  describe('getSaveModelClone', () => {
     it('should sort keys', () => {
       const model = new DashboardModel({});
       const saveModel = model.getSaveModelClone();
@@ -68,20 +68,20 @@ describe('DashboardModel', function() {
     });
   });
 
-  describe('row and panel manipulation', function() {
+  describe('row and panel manipulation', () => {
     let dashboard;
 
-    beforeEach(function() {
+    beforeEach(() => {
       dashboard = new DashboardModel({});
     });
 
-    it('adding panel should new up panel model', function() {
+    it('adding panel should new up panel model', () => {
       dashboard.addPanel({ type: 'test', title: 'test' });
 
       expect(dashboard.panels[0] instanceof PanelModel).toBe(true);
     });
 
-    it('duplicate panel should try to add to the right if there is space', function() {
+    it('duplicate panel should try to add to the right if there is space', () => {
       const panel = { id: 10, gridPos: { x: 0, y: 0, w: 6, h: 2 } };
 
       dashboard.addPanel(panel);
@@ -95,7 +95,7 @@ describe('DashboardModel', function() {
       });
     });
 
-    it('duplicate panel should remove repeat data', function() {
+    it('duplicate panel should remove repeat data', () => {
       const panel = {
         id: 10,
         gridPos: { x: 0, y: 0, w: 6, h: 2 },
@@ -111,29 +111,29 @@ describe('DashboardModel', function() {
     });
   });
 
-  describe('Given editable false dashboard', function() {
+  describe('Given editable false dashboard', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({ editable: false });
     });
 
-    it('Should set meta canEdit and canSave to false', function() {
+    it('Should set meta canEdit and canSave to false', () => {
       expect(model.meta.canSave).toBe(false);
       expect(model.meta.canEdit).toBe(false);
     });
 
-    it('getSaveModelClone should remove meta', function() {
+    it('getSaveModelClone should remove meta', () => {
       const clone = model.getSaveModelClone();
       expect(clone.meta).toBe(undefined);
     });
   });
 
-  describe('when loading dashboard with old influxdb query schema', function() {
+  describe('when loading dashboard with old influxdb query schema', () => {
     let model;
     let target;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         panels: [
           {
@@ -185,7 +185,7 @@ describe('DashboardModel', function() {
       target = model.panels[0].targets[0];
     });
 
-    it('should update query schema', function() {
+    it('should update query schema', () => {
       expect(target.fields).toBe(undefined);
       expect(target.select.length).toBe(2);
       expect(target.select[0].length).toBe(4);
@@ -196,10 +196,10 @@ describe('DashboardModel', function() {
     });
   });
 
-  describe('when creating dashboard model with missing list for annoations or templating', function() {
+  describe('when creating dashboard model with missing list for annoations or templating', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         annotations: {
           enable: true,
@@ -210,54 +210,54 @@ describe('DashboardModel', function() {
       });
     });
 
-    it('should add empty list', function() {
+    it('should add empty list', () => {
       expect(model.annotations.list.length).toBe(1);
       expect(model.templating.list.length).toBe(0);
     });
 
-    it('should add builtin annotation query', function() {
+    it('should add builtin annotation query', () => {
       expect(model.annotations.list[0].builtIn).toBe(1);
       expect(model.templating.list.length).toBe(0);
     });
   });
 
-  describe('Formatting epoch timestamp when timezone is set as utc', function() {
+  describe('Formatting epoch timestamp when timezone is set as utc', () => {
     let dashboard;
 
-    beforeEach(function() {
+    beforeEach(() => {
       dashboard = new DashboardModel({ timezone: 'utc' });
     });
 
-    it('Should format timestamp with second resolution by default', function() {
+    it('Should format timestamp with second resolution by default', () => {
       expect(dashboard.formatDate(1234567890000)).toBe('2009-02-13 23:31:30');
     });
 
-    it('Should format timestamp with second resolution even if second format is passed as parameter', function() {
+    it('Should format timestamp with second resolution even if second format is passed as parameter', () => {
       expect(dashboard.formatDate(1234567890007, 'YYYY-MM-DD HH:mm:ss')).toBe('2009-02-13 23:31:30');
     });
 
-    it('Should format timestamp with millisecond resolution if format is passed as parameter', function() {
+    it('Should format timestamp with millisecond resolution if format is passed as parameter', () => {
       expect(dashboard.formatDate(1234567890007, 'YYYY-MM-DD HH:mm:ss.SSS')).toBe('2009-02-13 23:31:30.007');
     });
   });
 
-  describe('updateSubmenuVisibility with empty lists', function() {
+  describe('updateSubmenuVisibility with empty lists', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({});
       model.updateSubmenuVisibility();
     });
 
-    it('should not enable submmenu', function() {
+    it('should not enable submmenu', () => {
       expect(model.meta.submenuEnabled).toBe(false);
     });
   });
 
-  describe('updateSubmenuVisibility with annotation', function() {
+  describe('updateSubmenuVisibility with annotation', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         annotations: {
           list: [{}],
@@ -266,15 +266,15 @@ describe('DashboardModel', function() {
       model.updateSubmenuVisibility();
     });
 
-    it('should enable submmenu', function() {
+    it('should enable submmenu', () => {
       expect(model.meta.submenuEnabled).toBe(true);
     });
   });
 
-  describe('updateSubmenuVisibility with template var', function() {
+  describe('updateSubmenuVisibility with template var', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         templating: {
           list: [{}],
@@ -283,15 +283,15 @@ describe('DashboardModel', function() {
       model.updateSubmenuVisibility();
     });
 
-    it('should enable submmenu', function() {
+    it('should enable submmenu', () => {
       expect(model.meta.submenuEnabled).toBe(true);
     });
   });
 
-  describe('updateSubmenuVisibility with hidden template var', function() {
+  describe('updateSubmenuVisibility with hidden template var', () => {
     let model;
 
-    beforeEach(function() {
+    beforeEach(() => {
       model = new DashboardModel({
         templating: {
           list: [{ hide: 2 }],
@@ -300,15 +300,15 @@ describe('DashboardModel', function() {
       model.updateSubmenuVisibility();
     });
 
-    it('should not enable submmenu', function() {
+    it('should not enable submmenu', () => {
       expect(model.meta.submenuEnabled).toBe(false);
     });
   });
 
-  describe('updateSubmenuVisibility with hidden annotation toggle', function() {
+  describe('updateSubmenuVisibility with hidden annotation toggle', () => {
     let dashboard;
 
-    beforeEach(function() {
+    beforeEach(() => {
       dashboard = new DashboardModel({
         annotations: {
           list: [{ hide: true }],
@@ -317,15 +317,15 @@ describe('DashboardModel', function() {
       dashboard.updateSubmenuVisibility();
     });
 
-    it('should not enable submmenu', function() {
+    it('should not enable submmenu', () => {
       expect(dashboard.meta.submenuEnabled).toBe(false);
     });
   });
 
-  describe('When collapsing row', function() {
+  describe('When collapsing row', () => {
     let dashboard;
 
-    beforeEach(function() {
+    beforeEach(() => {
       dashboard = new DashboardModel({
         panels: [
           { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 2 } },
@@ -338,36 +338,36 @@ describe('DashboardModel', function() {
       dashboard.toggleRow(dashboard.panels[1]);
     });
 
-    it('should remove panels and put them inside collapsed row', function() {
+    it('should remove panels and put them inside collapsed row', () => {
       expect(dashboard.panels.length).toBe(3);
       expect(dashboard.panels[1].panels.length).toBe(2);
     });
 
-    describe('and when removing row and its panels', function() {
-      beforeEach(function() {
+    describe('and when removing row and its panels', () => {
+      beforeEach(() => {
         dashboard.removeRow(dashboard.panels[1], true);
       });
 
-      it('should remove row and its panels', function() {
+      it('should remove row and its panels', () => {
         expect(dashboard.panels.length).toBe(2);
       });
     });
 
-    describe('and when removing only the row', function() {
-      beforeEach(function() {
+    describe('and when removing only the row', () => {
+      beforeEach(() => {
         dashboard.removeRow(dashboard.panels[1], false);
       });
 
-      it('should only remove row', function() {
+      it('should only remove row', () => {
         expect(dashboard.panels.length).toBe(4);
       });
     });
   });
 
-  describe('When expanding row', function() {
+  describe('When expanding row', () => {
     let dashboard;
 
-    beforeEach(function() {
+    beforeEach(() => {
       dashboard = new DashboardModel({
         panels: [
           { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 6 } },
@@ -387,16 +387,16 @@ describe('DashboardModel', function() {
       dashboard.toggleRow(dashboard.panels[1]);
     });
 
-    it('should add panels back', function() {
+    it('should add panels back', () => {
       expect(dashboard.panels.length).toBe(5);
     });
 
-    it('should add them below row in array', function() {
+    it('should add them below row in array', () => {
       expect(dashboard.panels[2].id).toBe(3);
       expect(dashboard.panels[3].id).toBe(4);
     });
 
-    it('should position them below row', function() {
+    it('should position them below row', () => {
       expect(dashboard.panels[2].gridPos).toMatchObject({
         x: 0,
         y: 7,
@@ -405,7 +405,7 @@ describe('DashboardModel', function() {
       });
     });
 
-    it('should move panels below down', function() {
+    it('should move panels below down', () => {
       expect(dashboard.panels[4].gridPos).toMatchObject({
         x: 0,
         y: 9,
@@ -414,22 +414,22 @@ describe('DashboardModel', function() {
       });
     });
 
-    describe('and when removing row and its panels', function() {
-      beforeEach(function() {
+    describe('and when removing row and its panels', () => {
+      beforeEach(() => {
         dashboard.removeRow(dashboard.panels[1], true);
       });
 
-      it('should remove row and its panels', function() {
+      it('should remove row and its panels', () => {
         expect(dashboard.panels.length).toBe(2);
       });
     });
 
-    describe('and when removing only the row', function() {
-      beforeEach(function() {
+    describe('and when removing only the row', () => {
+      beforeEach(() => {
         dashboard.removeRow(dashboard.panels[1], false);
       });
 
-      it('should only remove row', function() {
+      it('should only remove row', () => {
         expect(dashboard.panels.length).toBe(4);
       });
     });

+ 10 - 10
public/app/features/dashboard/specs/history_srv.test.ts

@@ -4,7 +4,7 @@ import { HistorySrv } from '../history/history_srv';
 import { DashboardModel } from '../dashboard_model';
 jest.mock('app/core/store');
 
-describe('historySrv', function() {
+describe('historySrv', () => {
   const versionsResponse = versions();
   const restoreResponse = restore;
 
@@ -19,35 +19,35 @@ describe('historySrv', function() {
   const emptyDash = new DashboardModel({});
   const historyListOpts = { limit: 10, start: 0 };
 
-  describe('getHistoryList', function() {
-    it('should return a versions array for the given dashboard id', function() {
+  describe('getHistoryList', () => {
+    it('should return a versions array for the given dashboard id', () => {
       backendSrv.get = jest.fn(() => Promise.resolve(versionsResponse));
       historySrv = new HistorySrv(backendSrv);
 
-      return historySrv.getHistoryList(dash, historyListOpts).then(function(versions) {
+      return historySrv.getHistoryList(dash, historyListOpts).then(versions => {
         expect(versions).toEqual(versionsResponse);
       });
     });
 
-    it('should return an empty array when not given an id', function() {
-      return historySrv.getHistoryList(emptyDash, historyListOpts).then(function(versions) {
+    it('should return an empty array when not given an id', () => {
+      return historySrv.getHistoryList(emptyDash, historyListOpts).then(versions => {
         expect(versions).toEqual([]);
       });
     });
 
-    it('should return an empty array when not given a dashboard', function() {
-      return historySrv.getHistoryList(null, historyListOpts).then(function(versions) {
+    it('should return an empty array when not given a dashboard', () => {
+      return historySrv.getHistoryList(null, historyListOpts).then(versions => {
         expect(versions).toEqual([]);
       });
     });
   });
 
   describe('restoreDashboard', () => {
-    it('should return a success response given valid parameters', function() {
+    it('should return a success response given valid parameters', () => {
       const version = 6;
       backendSrv.post = jest.fn(() => Promise.resolve(restoreResponse(version)));
       historySrv = new HistorySrv(backendSrv);
-      return historySrv.restoreDashboard(dash, version).then(function(response) {
+      return historySrv.restoreDashboard(dash, version).then(response => {
         expect(response).toEqual(restoreResponse(version));
       });
     });

+ 38 - 38
public/app/features/dashboard/specs/repeat.test.ts

@@ -4,10 +4,10 @@ import { expect } from 'test/lib/common';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 
-describe('given dashboard with panel repeat', function() {
+describe('given dashboard with panel repeat', () => {
   let dashboard;
 
-  beforeEach(function() {
+  beforeEach(() => {
     const dashboardJSON = {
       panels: [
         { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
@@ -35,7 +35,7 @@ describe('given dashboard with panel repeat', function() {
     dashboard.processRepeats();
   });
 
-  it('should repeat panels when row is expanding', function() {
+  it('should repeat panels when row is expanding', () => {
     expect(dashboard.panels.length).toBe(4);
 
     // toggle row
@@ -55,10 +55,10 @@ describe('given dashboard with panel repeat', function() {
   });
 });
 
-describe('given dashboard with panel repeat in horizontal direction', function() {
+describe('given dashboard with panel repeat in horizontal direction', () => {
   let dashboard;
 
-  beforeEach(function() {
+  beforeEach(() => {
     dashboard = new DashboardModel({
       panels: [
         {
@@ -89,22 +89,22 @@ describe('given dashboard with panel repeat in horizontal direction', function()
     dashboard.processRepeats();
   });
 
-  it('should repeat panel 3 times', function() {
+  it('should repeat panel 3 times', () => {
     expect(dashboard.panels.length).toBe(3);
   });
 
-  it('should mark panel repeated', function() {
+  it('should mark panel repeated', () => {
     expect(dashboard.panels[0].repeat).toBe('apps');
     expect(dashboard.panels[1].repeatPanelId).toBe(2);
   });
 
-  it('should set scopedVars on panels', function() {
+  it('should set scopedVars on panels', () => {
     expect(dashboard.panels[0].scopedVars.apps.value).toBe('se1');
     expect(dashboard.panels[1].scopedVars.apps.value).toBe('se2');
     expect(dashboard.panels[2].scopedVars.apps.value).toBe('se3');
   });
 
-  it('should place on first row and adjust width so all fit', function() {
+  it('should place on first row and adjust width so all fit', () => {
     expect(dashboard.panels[0].gridPos).toMatchObject({
       x: 0,
       y: 0,
@@ -125,23 +125,23 @@ describe('given dashboard with panel repeat in horizontal direction', function()
     });
   });
 
-  describe('After a second iteration', function() {
-    beforeEach(function() {
+  describe('After a second iteration', () => {
+    beforeEach(() => {
       dashboard.panels[0].fill = 10;
       dashboard.processRepeats();
     });
 
-    it('reused panel should copy properties from source', function() {
+    it('reused panel should copy properties from source', () => {
       expect(dashboard.panels[1].fill).toBe(10);
     });
 
-    it('should have same panel count', function() {
+    it('should have same panel count', () => {
       expect(dashboard.panels.length).toBe(3);
     });
   });
 
-  describe('After a second iteration with different variable', function() {
-    beforeEach(function() {
+  describe('After a second iteration with different variable', () => {
+    beforeEach(() => {
       dashboard.templating.list.push({
         name: 'server',
         current: { text: 'se1, se2, se3', value: ['se1'] },
@@ -151,46 +151,46 @@ describe('given dashboard with panel repeat in horizontal direction', function()
       dashboard.processRepeats();
     });
 
-    it('should remove scopedVars value for last variable', function() {
+    it('should remove scopedVars value for last variable', () => {
       expect(dashboard.panels[0].scopedVars.apps).toBe(undefined);
     });
 
-    it('should have new variable value in scopedVars', function() {
+    it('should have new variable value in scopedVars', () => {
       expect(dashboard.panels[0].scopedVars.server.value).toBe('se1');
     });
   });
 
-  describe('After a second iteration and selected values reduced', function() {
-    beforeEach(function() {
+  describe('After a second iteration and selected values reduced', () => {
+    beforeEach(() => {
       dashboard.templating.list[0].options[1].selected = false;
       dashboard.processRepeats();
     });
 
-    it('should clean up repeated panel', function() {
+    it('should clean up repeated panel', () => {
       expect(dashboard.panels.length).toBe(2);
     });
   });
 
-  describe('After a second iteration and panel repeat is turned off', function() {
-    beforeEach(function() {
+  describe('After a second iteration and panel repeat is turned off', () => {
+    beforeEach(() => {
       dashboard.panels[0].repeat = null;
       dashboard.processRepeats();
     });
 
-    it('should clean up repeated panel', function() {
+    it('should clean up repeated panel', () => {
       expect(dashboard.panels.length).toBe(1);
     });
 
-    it('should remove scoped vars from reused panel', function() {
+    it('should remove scoped vars from reused panel', () => {
       expect(dashboard.panels[0].scopedVars).toBe(undefined);
     });
   });
 });
 
-describe('given dashboard with panel repeat in vertical direction', function() {
+describe('given dashboard with panel repeat in vertical direction', () => {
   let dashboard;
 
-  beforeEach(function() {
+  beforeEach(() => {
     dashboard = new DashboardModel({
       panels: [
         { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
@@ -218,7 +218,7 @@ describe('given dashboard with panel repeat in vertical direction', function() {
     dashboard.processRepeats();
   });
 
-  it('should place on items on top of each other and keep witdh', function() {
+  it('should place on items on top of each other and keep witdh', () => {
     expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // first row
 
     expect(dashboard.panels[1].gridPos).toMatchObject({ x: 5, y: 1, h: 2, w: 8 });
@@ -290,7 +290,7 @@ describe('given dashboard with row repeat and panel repeat in horizontal directi
     ]);
   });
 
-  it('should be placed in their places', function() {
+  it('should be placed in their places', () => {
     expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // 1st row
 
     expect(dashboard.panels[1].gridPos).toMatchObject({ x: 0, y: 1, h: 2, w: 6 });
@@ -311,10 +311,10 @@ describe('given dashboard with row repeat and panel repeat in horizontal directi
   });
 });
 
-describe('given dashboard with row repeat', function() {
+describe('given dashboard with row repeat', () => {
   let dashboard, dashboardJSON;
 
-  beforeEach(function() {
+  beforeEach(() => {
     dashboardJSON = {
       panels: [
         {
@@ -349,12 +349,12 @@ describe('given dashboard with row repeat', function() {
     dashboard.processRepeats();
   });
 
-  it('should not repeat only row', function() {
+  it('should not repeat only row', () => {
     const panelTypes = _.map(dashboard.panels, 'type');
     expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph', 'row', 'graph']);
   });
 
-  it('should set scopedVars for each panel', function() {
+  it('should set scopedVars for each panel', () => {
     dashboardJSON.templating.list[0].options[2].selected = true;
     dashboard = new DashboardModel(dashboardJSON);
     dashboard.processRepeats();
@@ -375,12 +375,12 @@ describe('given dashboard with row repeat', function() {
     expect(scopedVars).toEqual(['se1', 'se1', 'se1', 'se2', 'se2', 'se2', 'se3', 'se3', 'se3']);
   });
 
-  it('should repeat only configured row', function() {
+  it('should repeat only configured row', () => {
     expect(dashboard.panels[6].id).toBe(4);
     expect(dashboard.panels[7].id).toBe(5);
   });
 
-  it('should repeat only row if it is collapsed', function() {
+  it('should repeat only row if it is collapsed', () => {
     dashboardJSON.panels = [
       {
         id: 1,
@@ -405,7 +405,7 @@ describe('given dashboard with row repeat', function() {
     expect(dashboard.panels[1].panels).toHaveLength(2);
   });
 
-  it('should properly repeat multiple rows', function() {
+  it('should properly repeat multiple rows', () => {
     dashboardJSON.panels = [
       {
         id: 1,
@@ -469,7 +469,7 @@ describe('given dashboard with row repeat', function() {
     expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02');
   });
 
-  it('should assign unique ids for repeated panels', function() {
+  it('should assign unique ids for repeated panels', () => {
     dashboardJSON.panels = [
       {
         id: 1,
@@ -501,7 +501,7 @@ describe('given dashboard with row repeat', function() {
     expect(panelIds.length).toEqual(_.uniq(panelIds).length);
   });
 
-  it('should place new panels in proper order', function() {
+  it('should place new panels in proper order', () => {
     dashboardJSON.panels = [
       { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 }, repeat: 'apps' },
       { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 3, w: 12 } },
@@ -646,7 +646,7 @@ describe('given dashboard with row and panel repeat', () => {
     });
   });
 
-  it('should repeat panels when row is expanding', function() {
+  it('should repeat panels when row is expanding', () => {
     dashboard = new DashboardModel(dashboardJSON);
     dashboard.processRepeats();
 

+ 2 - 2
public/app/features/dashboard/specs/save_as_modal.test.ts

@@ -10,11 +10,11 @@ describe('saving dashboard as', () => {
       };
 
       const mockDashboardSrv = {
-        getCurrent: function() {
+        getCurrent: () => {
           return {
             id: 5,
             meta: {},
-            getSaveModelClone: function() {
+            getSaveModelClone: () => {
               return json;
             },
           };

+ 2 - 2
public/app/features/dashboard/specs/save_provisioned_modal.test.ts

@@ -7,11 +7,11 @@ describe('SaveProvisionedDashboardModalCtrl', () => {
   };
 
   const mockDashboardSrv = {
-    getCurrent: function() {
+    getCurrent: () => {
       return {
         id: 5,
         meta: {},
-        getSaveModelClone: function() {
+        getSaveModelClone: () => {
           return json;
         },
       };

+ 1 - 1
public/app/features/dashboard/specs/share_modal_ctrl.test.ts

@@ -136,7 +136,7 @@ describe('ShareModalCtrl', () => {
       ctx.$location.absUrl = () => 'http://server/#!/test';
       ctx.scope.options.includeTemplateVars = true;
 
-      ctx.templateSrv.fillVariableValuesForUrl = function(params) {
+      ctx.templateSrv.fillVariableValuesForUrl = params => {
         params['var-app'] = 'mupp';
         params['var-server'] = 'srv-01';
       };

+ 16 - 16
public/app/features/dashboard/specs/time_srv.test.ts

@@ -2,7 +2,7 @@ import { TimeSrv } from '../time_srv';
 import '../time_srv';
 import moment from 'moment';
 
-describe('timeSrv', function() {
+describe('timeSrv', () => {
   const rootScope = {
     $on: jest.fn(),
     onAppEvent: jest.fn(),
@@ -26,20 +26,20 @@ describe('timeSrv', function() {
     getTimezone: jest.fn(() => 'browser'),
   };
 
-  beforeEach(function() {
+  beforeEach(() => {
     timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
     timeSrv.init(_dashboard);
   });
 
-  describe('timeRange', function() {
-    it('should return unparsed when parse is false', function() {
+  describe('timeRange', () => {
+    it('should return unparsed when parse is false', () => {
       timeSrv.setTime({ from: 'now', to: 'now-1h' });
       const time = timeSrv.timeRange();
       expect(time.raw.from).toBe('now');
       expect(time.raw.to).toBe('now-1h');
     });
 
-    it('should return parsed when parse is true', function() {
+    it('should return parsed when parse is true', () => {
       timeSrv.setTime({ from: 'now', to: 'now-1h' });
       const time = timeSrv.timeRange();
       expect(moment.isMoment(time.from)).toBe(true);
@@ -47,8 +47,8 @@ describe('timeSrv', function() {
     });
   });
 
-  describe('init time from url', function() {
-    it('should handle relative times', function() {
+  describe('init time from url', () => {
+    it('should handle relative times', () => {
       location = {
         search: jest.fn(() => ({
           from: 'now-2d',
@@ -63,7 +63,7 @@ describe('timeSrv', function() {
       expect(time.raw.to).toBe('now');
     });
 
-    it('should handle formatted dates', function() {
+    it('should handle formatted dates', () => {
       location = {
         search: jest.fn(() => ({
           from: '20140410T052010',
@@ -79,7 +79,7 @@ describe('timeSrv', function() {
       expect(time.to.valueOf()).toEqual(new Date('2014-05-20T03:10:22Z').getTime());
     });
 
-    it('should handle formatted dates without time', function() {
+    it('should handle formatted dates without time', () => {
       location = {
         search: jest.fn(() => ({
           from: '20140410',
@@ -95,7 +95,7 @@ describe('timeSrv', function() {
       expect(time.to.valueOf()).toEqual(new Date('2014-05-20T00:00:00Z').getTime());
     });
 
-    it('should handle epochs', function() {
+    it('should handle epochs', () => {
       location = {
         search: jest.fn(() => ({
           from: '1410337646373',
@@ -111,7 +111,7 @@ describe('timeSrv', function() {
       expect(time.to.valueOf()).toEqual(1410337665699);
     });
 
-    it('should handle bad dates', function() {
+    it('should handle bad dates', () => {
       location = {
         search: jest.fn(() => ({
           from: '20151126T00010%3C%2Fp%3E%3Cspan%20class',
@@ -128,22 +128,22 @@ describe('timeSrv', function() {
     });
   });
 
-  describe('setTime', function() {
-    it('should return disable refresh if refresh is disabled for any range', function() {
+  describe('setTime', () => {
+    it('should return disable refresh if refresh is disabled for any range', () => {
       _dashboard.refresh = false;
 
       timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' });
       expect(_dashboard.refresh).toBe(false);
     });
 
-    it('should restore refresh for absolute time range', function() {
+    it('should restore refresh for absolute time range', () => {
       _dashboard.refresh = '30s';
 
       timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' });
       expect(_dashboard.refresh).toBe('30s');
     });
 
-    it('should restore refresh after relative time range is set', function() {
+    it('should restore refresh after relative time range is set', () => {
       _dashboard.refresh = '10s';
       timeSrv.setTime({
         from: moment([2011, 1, 1]),
@@ -154,7 +154,7 @@ describe('timeSrv', function() {
       expect(_dashboard.refresh).toBe('10s');
     });
 
-    it('should keep refresh after relative time range is changed and now delay exists', function() {
+    it('should keep refresh after relative time range is changed and now delay exists', () => {
       _dashboard.refresh = '10s';
       timeSrv.setTime({ from: 'now-1h', to: 'now-10s' });
       expect(_dashboard.refresh).toBe('10s');

+ 5 - 5
public/app/features/org/org_details_ctrl.ts

@@ -3,20 +3,20 @@ import angular from 'angular';
 export class OrgDetailsCtrl {
   /** @ngInject */
   constructor($scope, $http, backendSrv, contextSrv, navModelSrv) {
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.getOrgInfo();
       $scope.navModel = navModelSrv.getNav('cfg', 'org-settings', 0);
     };
 
-    $scope.getOrgInfo = function() {
-      backendSrv.get('/api/org').then(function(org) {
+    $scope.getOrgInfo = () => {
+      backendSrv.get('/api/org').then(org => {
         $scope.org = org;
         $scope.address = org.address;
         contextSrv.user.orgName = org.name;
       });
     };
 
-    $scope.update = function() {
+    $scope.update = () => {
       if (!$scope.orgForm.$valid) {
         return;
       }
@@ -24,7 +24,7 @@ export class OrgDetailsCtrl {
       backendSrv.put('/api/org', data).then($scope.getOrgInfo);
     };
 
-    $scope.updateAddress = function() {
+    $scope.updateAddress = () => {
       if (!$scope.addressForm.$valid) {
         return;
       }

+ 7 - 7
public/app/features/panellinks/specs/link_srv.test.ts

@@ -6,7 +6,7 @@ jest.mock('angular', () => {
   return new AngularJSMock();
 });
 
-describe('linkSrv', function() {
+describe('linkSrv', () => {
   let linkSrv;
   const templateSrvMock = {};
   const timeSrvMock = {};
@@ -15,24 +15,24 @@ describe('linkSrv', function() {
     linkSrv = new LinkSrv(templateSrvMock, timeSrvMock);
   });
 
-  describe('when appending query strings', function() {
-    it('add ? to URL if not present', function() {
+  describe('when appending query strings', () => {
+    it('add ? to URL if not present', () => {
       const url = linkSrv.appendToQueryString('http://example.com', 'foo=bar');
       expect(url).toBe('http://example.com?foo=bar');
     });
 
-    it('do not add & to URL if ? is present but query string is empty', function() {
+    it('do not add & to URL if ? is present but query string is empty', () => {
       const url = linkSrv.appendToQueryString('http://example.com?', 'foo=bar');
       expect(url).toBe('http://example.com?foo=bar');
     });
 
-    it('add & to URL if query string is present', function() {
+    it('add & to URL if query string is present', () => {
       const url = linkSrv.appendToQueryString('http://example.com?foo=bar', 'hello=world');
       expect(url).toBe('http://example.com?foo=bar&hello=world');
     });
 
-    it('do not change the URL if there is nothing to append', function() {
-      _.each(['', undefined, null], function(toAppend) {
+    it('do not change the URL if there is nothing to append', () => {
+      _.each(['', undefined, null], toAppend => {
         const url1 = linkSrv.appendToQueryString('http://example.com', toAppend);
         expect(url1).toBe('http://example.com');
 

+ 1 - 1
public/app/features/plugins/specs/datasource_srv.test.ts

@@ -15,7 +15,7 @@ const templateSrv = {
   ],
 };
 
-describe('datasource_srv', function() {
+describe('datasource_srv', () => {
   const _datasourceSrv = new DatasourceSrv({}, {}, {}, templateSrv);
 
   describe('when loading explore sources', () => {

+ 5 - 5
public/app/features/templating/specs/adhoc_variable.test.ts

@@ -1,8 +1,8 @@
 import { AdhocVariable } from '../adhoc_variable';
 
-describe('AdhocVariable', function() {
-  describe('when serializing to url', function() {
-    it('should set return key value and op separated by pipe', function() {
+describe('AdhocVariable', () => {
+  describe('when serializing to url', () => {
+    it('should set return key value and op separated by pipe', () => {
       const variable = new AdhocVariable({
         filters: [
           { key: 'key1', operator: '=', value: 'value1' },
@@ -15,8 +15,8 @@ describe('AdhocVariable', function() {
     });
   });
 
-  describe('when deserializing from url', function() {
-    it('should restore filters', function() {
+  describe('when deserializing from url', () => {
+    it('should restore filters', () => {
       const variable = new AdhocVariable({});
       variable.setValueFromUrl(['key1|=|value1', 'key2|!=|value2', 'key3|=|value3a__gfp__value3b__gfp__value3c']);
 

+ 85 - 85
public/app/features/templating/specs/template_srv.test.ts

@@ -1,6 +1,6 @@
 import { TemplateSrv } from '../template_srv';
 
-describe('templateSrv', function() {
+describe('templateSrv', () => {
   let _templateSrv;
 
   function initTemplateSrv(variables) {
@@ -8,58 +8,58 @@ describe('templateSrv', function() {
     _templateSrv.init(variables);
   }
 
-  describe('init', function() {
-    beforeEach(function() {
+  describe('init', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
     });
 
-    it('should initialize template data', function() {
+    it('should initialize template data', () => {
       const target = _templateSrv.replace('this.[[test]].filters');
       expect(target).toBe('this.oogle.filters');
     });
   });
 
-  describe('replace can pass scoped vars', function() {
-    beforeEach(function() {
+  describe('replace can pass scoped vars', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
     });
 
-    it('should replace $test with scoped value', function() {
+    it('should replace $test with scoped value', () => {
       const target = _templateSrv.replace('this.$test.filters', {
         test: { value: 'mupp', text: 'asd' },
       });
       expect(target).toBe('this.mupp.filters');
     });
 
-    it('should replace ${test} with scoped value', function() {
+    it('should replace ${test} with scoped value', () => {
       const target = _templateSrv.replace('this.${test}.filters', {
         test: { value: 'mupp', text: 'asd' },
       });
       expect(target).toBe('this.mupp.filters');
     });
 
-    it('should replace ${test:glob} with scoped value', function() {
+    it('should replace ${test:glob} with scoped value', () => {
       const target = _templateSrv.replace('this.${test:glob}.filters', {
         test: { value: 'mupp', text: 'asd' },
       });
       expect(target).toBe('this.mupp.filters');
     });
 
-    it('should replace $test with scoped text', function() {
+    it('should replace $test with scoped text', () => {
       const target = _templateSrv.replaceWithText('this.$test.filters', {
         test: { value: 'mupp', text: 'asd' },
       });
       expect(target).toBe('this.asd.filters');
     });
 
-    it('should replace ${test} with scoped text', function() {
+    it('should replace ${test} with scoped text', () => {
       const target = _templateSrv.replaceWithText('this.${test}.filters', {
         test: { value: 'mupp', text: 'asd' },
       });
       expect(target).toBe('this.asd.filters');
     });
 
-    it('should replace ${test:glob} with scoped text', function() {
+    it('should replace ${test:glob} with scoped text', () => {
       const target = _templateSrv.replaceWithText('this.${test:glob}.filters', {
         test: { value: 'mupp', text: 'asd' },
       });
@@ -67,8 +67,8 @@ describe('templateSrv', function() {
     });
   });
 
-  describe('getAdhocFilters', function() {
-    beforeEach(function() {
+  describe('getAdhocFilters', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'datasource',
@@ -80,24 +80,24 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should return filters if datasourceName match', function() {
+    it('should return filters if datasourceName match', () => {
       const filters = _templateSrv.getAdhocFilters('oogle');
       expect(filters).toMatchObject([1]);
     });
 
-    it('should return empty array if datasourceName does not match', function() {
+    it('should return empty array if datasourceName does not match', () => {
       const filters = _templateSrv.getAdhocFilters('oogleasdasd');
       expect(filters).toMatchObject([]);
     });
 
-    it('should return filters when datasourceName match via data source variable', function() {
+    it('should return filters when datasourceName match via data source variable', () => {
       const filters = _templateSrv.getAdhocFilters('logstash');
       expect(filters).toMatchObject([2]);
     });
   });
 
-  describe('replace can pass multi / all format', function() {
-    beforeEach(function() {
+  describe('replace can pass multi / all format', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'query',
@@ -107,44 +107,44 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should replace $test with globbed value', function() {
+    it('should replace $test with globbed value', () => {
       const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
     });
 
-    it('should replace ${test} with globbed value', function() {
+    it('should replace ${test} with globbed value', () => {
       const target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
     });
 
-    it('should replace ${test:glob} with globbed value', function() {
+    it('should replace ${test:glob} with globbed value', () => {
       const target = _templateSrv.replace('this.${test:glob}.filters', {});
       expect(target).toBe('this.{value1,value2}.filters');
     });
 
-    it('should replace $test with piped value', function() {
+    it('should replace $test with piped value', () => {
       const target = _templateSrv.replace('this=$test', {}, 'pipe');
       expect(target).toBe('this=value1|value2');
     });
 
-    it('should replace ${test} with piped value', function() {
+    it('should replace ${test} with piped value', () => {
       const target = _templateSrv.replace('this=${test}', {}, 'pipe');
       expect(target).toBe('this=value1|value2');
     });
 
-    it('should replace ${test:pipe} with piped value', function() {
+    it('should replace ${test:pipe} with piped value', () => {
       const target = _templateSrv.replace('this=${test:pipe}', {});
       expect(target).toBe('this=value1|value2');
     });
 
-    it('should replace ${test:pipe} with piped value and $test with globbed value', function() {
+    it('should replace ${test:pipe} with piped value and $test with globbed value', () => {
       const target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
       expect(target).toBe('value1|value2,{value1,value2}');
     });
   });
 
-  describe('variable with all option', function() {
-    beforeEach(function() {
+  describe('variable with all option', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'query',
@@ -155,29 +155,29 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should replace $test with formatted all value', function() {
+    it('should replace $test with formatted all value', () => {
       const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
     });
 
-    it('should replace ${test} with formatted all value', function() {
+    it('should replace ${test} with formatted all value', () => {
       const target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
     });
 
-    it('should replace ${test:glob} with formatted all value', function() {
+    it('should replace ${test:glob} with formatted all value', () => {
       const target = _templateSrv.replace('this.${test:glob}.filters', {});
       expect(target).toBe('this.{value1,value2}.filters');
     });
 
-    it('should replace ${test:pipe} with piped value and $test with globbed value', function() {
+    it('should replace ${test:pipe} with piped value and $test with globbed value', () => {
       const target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
       expect(target).toBe('value1|value2,{value1,value2}');
     });
   });
 
-  describe('variable with all option and custom value', function() {
-    beforeEach(function() {
+  describe('variable with all option and custom value', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'query',
@@ -189,148 +189,148 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should replace $test with formatted all value', function() {
+    it('should replace $test with formatted all value', () => {
       const target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       expect(target).toBe('this.*.filters');
     });
 
-    it('should replace ${test} with formatted all value', function() {
+    it('should replace ${test} with formatted all value', () => {
       const target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
       expect(target).toBe('this.*.filters');
     });
 
-    it('should replace ${test:glob} with formatted all value', function() {
+    it('should replace ${test:glob} with formatted all value', () => {
       const target = _templateSrv.replace('this.${test:glob}.filters', {});
       expect(target).toBe('this.*.filters');
     });
 
-    it('should not escape custom all value', function() {
+    it('should not escape custom all value', () => {
       const target = _templateSrv.replace('this.$test', {}, 'regex');
       expect(target).toBe('this.*');
     });
   });
 
-  describe('lucene format', function() {
-    it('should properly escape $test with lucene escape sequences', function() {
+  describe('lucene format', () => {
+    it('should properly escape $test with lucene escape sequences', () => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
       const target = _templateSrv.replace('this:$test', {}, 'lucene');
       expect(target).toBe('this:value\\/4');
     });
 
-    it('should properly escape ${test} with lucene escape sequences', function() {
+    it('should properly escape ${test} with lucene escape sequences', () => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
       const target = _templateSrv.replace('this:${test}', {}, 'lucene');
       expect(target).toBe('this:value\\/4');
     });
 
-    it('should properly escape ${test:lucene} with lucene escape sequences', function() {
+    it('should properly escape ${test:lucene} with lucene escape sequences', () => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
       const target = _templateSrv.replace('this:${test:lucene}', {});
       expect(target).toBe('this:value\\/4');
     });
   });
 
-  describe('format variable to string values', function() {
-    it('single value should return value', function() {
+  describe('format variable to string values', () => {
+    it('single value should return value', () => {
       const result = _templateSrv.formatValue('test');
       expect(result).toBe('test');
     });
 
-    it('multi value and glob format should render glob string', function() {
+    it('multi value and glob format should render glob string', () => {
       const result = _templateSrv.formatValue(['test', 'test2'], 'glob');
       expect(result).toBe('{test,test2}');
     });
 
-    it('multi value and lucene should render as lucene expr', function() {
+    it('multi value and lucene should render as lucene expr', () => {
       const result = _templateSrv.formatValue(['test', 'test2'], 'lucene');
       expect(result).toBe('("test" OR "test2")');
     });
 
-    it('multi value and regex format should render regex string', function() {
+    it('multi value and regex format should render regex string', () => {
       const result = _templateSrv.formatValue(['test.', 'test2'], 'regex');
       expect(result).toBe('(test\\.|test2)');
     });
 
-    it('multi value and pipe should render pipe string', function() {
+    it('multi value and pipe should render pipe string', () => {
       const result = _templateSrv.formatValue(['test', 'test2'], 'pipe');
       expect(result).toBe('test|test2');
     });
 
-    it('multi value and distributed should render distributed string', function() {
+    it('multi value and distributed should render distributed string', () => {
       const result = _templateSrv.formatValue(['test', 'test2'], 'distributed', {
         name: 'build',
       });
       expect(result).toBe('test,build=test2');
     });
 
-    it('multi value and distributed should render when not string', function() {
+    it('multi value and distributed should render when not string', () => {
       const result = _templateSrv.formatValue(['test'], 'distributed', {
         name: 'build',
       });
       expect(result).toBe('test');
     });
 
-    it('multi value and csv format should render csv string', function() {
+    it('multi value and csv format should render csv string', () => {
       const result = _templateSrv.formatValue(['test', 'test2'], 'csv');
       expect(result).toBe('test,test2');
     });
 
-    it('multi value and percentencode format should render percent-encoded string', function() {
+    it('multi value and percentencode format should render percent-encoded string', () => {
       const result = _templateSrv.formatValue(['foo()bar BAZ', 'test2'], 'percentencode');
       expect(result).toBe('%7bfoo%28%29bar%20BAZ%2ctest2%7d');
     });
 
-    it('slash should be properly escaped in regex format', function() {
+    it('slash should be properly escaped in regex format', () => {
       const result = _templateSrv.formatValue('Gi3/14', 'regex');
       expect(result).toBe('Gi3\\/14');
     });
   });
 
-  describe('can check if variable exists', function() {
-    beforeEach(function() {
+  describe('can check if variable exists', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
     });
 
-    it('should return true if exists', function() {
+    it('should return true if exists', () => {
       const result = _templateSrv.variableExists('$test');
       expect(result).toBe(true);
     });
   });
 
-  describe('can highlight variables in string', function() {
-    beforeEach(function() {
+  describe('can highlight variables in string', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
     });
 
-    it('should insert html', function() {
+    it('should insert html', () => {
       const result = _templateSrv.highlightVariablesAsHtml('$test');
       expect(result).toBe('<span class="template-variable">$test</span>');
     });
 
-    it('should insert html anywhere in string', function() {
+    it('should insert html anywhere in string', () => {
       const result = _templateSrv.highlightVariablesAsHtml('this $test ok');
       expect(result).toBe('this <span class="template-variable">$test</span> ok');
     });
 
-    it('should ignore if variables does not exist', function() {
+    it('should ignore if variables does not exist', () => {
       const result = _templateSrv.highlightVariablesAsHtml('this $google ok');
       expect(result).toBe('this $google ok');
     });
   });
 
-  describe('updateTemplateData with simple value', function() {
-    beforeEach(function() {
+  describe('updateTemplateData with simple value', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'muuuu' } }]);
     });
 
-    it('should set current value and update template data', function() {
+    it('should set current value and update template data', () => {
       const target = _templateSrv.replace('this.[[test]].filters');
       expect(target).toBe('this.muuuu.filters');
     });
   });
 
-  describe('fillVariableValuesForUrl with multi value', function() {
-    beforeEach(function() {
+  describe('fillVariableValuesForUrl with multi value', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'query',
@@ -343,15 +343,15 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should set multiple url params', function() {
+    it('should set multiple url params', () => {
       const params = {};
       _templateSrv.fillVariableValuesForUrl(params);
       expect(params['var-test']).toMatchObject(['val1', 'val2']);
     });
   });
 
-  describe('fillVariableValuesForUrl skip url sync', function() {
-    beforeEach(function() {
+  describe('fillVariableValuesForUrl skip url sync', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           name: 'test',
@@ -364,15 +364,15 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should not include template variable value in url', function() {
+    it('should not include template variable value in url', () => {
       const params = {};
       _templateSrv.fillVariableValuesForUrl(params);
       expect(params['var-test']).toBe(undefined);
     });
   });
 
-  describe('fillVariableValuesForUrl with multi value with skip url sync', function() {
-    beforeEach(function() {
+  describe('fillVariableValuesForUrl with multi value with skip url sync', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'query',
@@ -386,19 +386,19 @@ describe('templateSrv', function() {
       ]);
     });
 
-    it('should not include template variable value in url', function() {
+    it('should not include template variable value in url', () => {
       const params = {};
       _templateSrv.fillVariableValuesForUrl(params);
       expect(params['var-test']).toBe(undefined);
     });
   });
 
-  describe('fillVariableValuesForUrl with multi value and scopedVars', function() {
-    beforeEach(function() {
+  describe('fillVariableValuesForUrl with multi value and scopedVars', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]);
     });
 
-    it('should set scoped value as url params', function() {
+    it('should set scoped value as url params', () => {
       const params = {};
       _templateSrv.fillVariableValuesForUrl(params, {
         test: { value: 'val1' },
@@ -407,12 +407,12 @@ describe('templateSrv', function() {
     });
   });
 
-  describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', function() {
-    beforeEach(function() {
+  describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', () => {
+    beforeEach(() => {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]);
     });
 
-    it('should not set scoped value as url params', function() {
+    it('should not set scoped value as url params', () => {
       const params = {};
       _templateSrv.fillVariableValuesForUrl(params, {
         test: { name: 'test', value: 'val1', skipUrlSync: true },
@@ -421,8 +421,8 @@ describe('templateSrv', function() {
     });
   });
 
-  describe('replaceWithText', function() {
-    beforeEach(function() {
+  describe('replaceWithText', () => {
+    beforeEach(() => {
       initTemplateSrv([
         {
           type: 'query',
@@ -439,18 +439,18 @@ describe('templateSrv', function() {
       _templateSrv.updateTemplateData();
     });
 
-    it('should replace with text except for grafanaVariables', function() {
+    it('should replace with text except for grafanaVariables', () => {
       const target = _templateSrv.replaceWithText('Server: $server, period: $period');
       expect(target).toBe('Server: All, period: 13m');
     });
   });
 
-  describe('built in interval variables', function() {
-    beforeEach(function() {
+  describe('built in interval variables', () => {
+    beforeEach(() => {
       initTemplateSrv([]);
     });
 
-    it('should replace $__interval_ms with interval milliseconds', function() {
+    it('should replace $__interval_ms with interval milliseconds', () => {
       const target = _templateSrv.replace('10 * $__interval_ms', {
         __interval_ms: { text: '100', value: '100' },
       });

+ 12 - 12
public/app/features/templating/specs/variable.test.ts

@@ -1,53 +1,53 @@
 import { containsVariable, assignModelProperties } from '../variable';
 
-describe('containsVariable', function() {
-  describe('when checking if a string contains a variable', function() {
-    it('should find it with $const syntax', function() {
+describe('containsVariable', () => {
+  describe('when checking if a string contains a variable', () => {
+    it('should find it with $const syntax', () => {
       const contains = containsVariable('this.$test.filters', 'test');
       expect(contains).toBe(true);
     });
 
-    it('should not find it if only part matches with $const syntax', function() {
+    it('should not find it if only part matches with $const syntax', () => {
       const contains = containsVariable('this.$serverDomain.filters', 'server');
       expect(contains).toBe(false);
     });
 
-    it('should find it if it ends with variable and passing multiple test strings', function() {
+    it('should find it if it ends with variable and passing multiple test strings', () => {
       const contains = containsVariable('show field keys from $pgmetric', 'test string2', 'pgmetric');
       expect(contains).toBe(true);
     });
 
-    it('should find it with [[var]] syntax', function() {
+    it('should find it with [[var]] syntax', () => {
       const contains = containsVariable('this.[[test]].filters', 'test');
       expect(contains).toBe(true);
     });
 
-    it('should find it when part of segment', function() {
+    it('should find it when part of segment', () => {
       const contains = containsVariable('metrics.$env.$group-*', 'group');
       expect(contains).toBe(true);
     });
 
-    it('should find it its the only thing', function() {
+    it('should find it its the only thing', () => {
       const contains = containsVariable('$env', 'env');
       expect(contains).toBe(true);
     });
 
-    it('should be able to pass in multiple test strings', function() {
+    it('should be able to pass in multiple test strings', () => {
       const contains = containsVariable('asd', 'asd2.$env', 'env');
       expect(contains).toBe(true);
     });
   });
 });
 
-describe('assignModelProperties', function() {
-  it('only set properties defined in defaults', function() {
+describe('assignModelProperties', () => {
+  it('only set properties defined in defaults', () => {
     const target: any = { test: 'asd' };
     assignModelProperties(target, { propA: 1, propB: 2 }, { propB: 0 });
     expect(target.propB).toBe(2);
     expect(target.test).toBe('asd');
   });
 
-  it('use default value if not found on source', function() {
+  it('use default value if not found on source', () => {
     const target: any = { test: 'asd' };
     assignModelProperties(target, { propA: 1, propB: 2 }, { propC: 10 });
     expect(target.propC).toBe(10);

+ 21 - 21
public/app/features/templating/specs/variable_srv.test.ts

@@ -34,7 +34,7 @@ describe('VariableSrv', function(this: any) {
   function describeUpdateVariable(desc, fn) {
     describe(desc, () => {
       const scenario: any = {};
-      scenario.setup = function(setupFn) {
+      scenario.setup = setupFn => {
         scenario.setupFn = setupFn;
       };
 
@@ -135,7 +135,7 @@ describe('VariableSrv', function(this: any) {
   //
   // Query variable update
   //
-  describeUpdateVariable('query variable with empty current object and refresh', function(scenario) {
+  describeUpdateVariable('query variable with empty current object and refresh', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -154,7 +154,7 @@ describe('VariableSrv', function(this: any) {
 
   describeUpdateVariable(
     'query variable with multi select and new options does not contain some selected values',
-    function(scenario) {
+    scenario => {
       scenario.setup(() => {
         scenario.variableModel = {
           type: 'query',
@@ -177,7 +177,7 @@ describe('VariableSrv', function(this: any) {
 
   describeUpdateVariable(
     'query variable with multi select and new options does not contain any selected values',
-    function(scenario) {
+    scenario => {
       scenario.setup(() => {
         scenario.variableModel = {
           type: 'query',
@@ -198,7 +198,7 @@ describe('VariableSrv', function(this: any) {
     }
   );
 
-  describeUpdateVariable('query variable with multi select and $__all selected', function(scenario) {
+  describeUpdateVariable('query variable with multi select and $__all selected', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -219,7 +219,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('query variable with numeric results', function(scenario) {
+  describeUpdateVariable('query variable with numeric results', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -237,7 +237,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('basic query variable', function(scenario) {
+  describeUpdateVariable('basic query variable', scenario => {
     scenario.setup(() => {
       scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' };
       scenario.queryResult = [{ text: 'backend1' }, { text: 'backend2' }];
@@ -255,7 +255,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('and existing value still exists in options', function(scenario) {
+  describeUpdateVariable('and existing value still exists in options', scenario => {
     scenario.setup(() => {
       scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' };
       scenario.variableModel.current = { value: 'backend2', text: 'backend2' };
@@ -267,7 +267,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('and regex pattern exists', function(scenario) {
+  describeUpdateVariable('and regex pattern exists', scenario => {
     scenario.setup(() => {
       scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' };
       scenario.variableModel.regex = '/apps.*(backend_[0-9]+)/';
@@ -282,7 +282,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('and regex pattern exists and no match', function(scenario) {
+  describeUpdateVariable('and regex pattern exists and no match', scenario => {
     scenario.setup(() => {
       scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' };
       scenario.variableModel.regex = '/apps.*(backendasd[0-9]+)/';
@@ -298,7 +298,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('regex pattern without slashes', function(scenario) {
+  describeUpdateVariable('regex pattern without slashes', scenario => {
     scenario.setup(() => {
       scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' };
       scenario.variableModel.regex = 'backend_01';
@@ -313,7 +313,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('regex pattern remove duplicates', function(scenario) {
+  describeUpdateVariable('regex pattern remove duplicates', scenario => {
     scenario.setup(() => {
       scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' };
       scenario.variableModel.regex = '/backend_01/';
@@ -328,7 +328,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('with include All', function(scenario) {
+  describeUpdateVariable('with include All', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -345,7 +345,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('with include all and custom value', function(scenario) {
+  describeUpdateVariable('with include all and custom value', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -362,7 +362,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('without sort', function(scenario) {
+  describeUpdateVariable('without sort', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -380,7 +380,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('with alphabetical sort (asc)', function(scenario) {
+  describeUpdateVariable('with alphabetical sort (asc)', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -398,7 +398,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('with alphabetical sort (desc)', function(scenario) {
+  describeUpdateVariable('with alphabetical sort (desc)', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -416,7 +416,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('with numerical sort (asc)', function(scenario) {
+  describeUpdateVariable('with numerical sort (asc)', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -434,7 +434,7 @@ describe('VariableSrv', function(this: any) {
     });
   });
 
-  describeUpdateVariable('with numerical sort (desc)', function(scenario) {
+  describeUpdateVariable('with numerical sort (desc)', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'query',
@@ -455,7 +455,7 @@ describe('VariableSrv', function(this: any) {
   //
   // datasource variable update
   //
-  describeUpdateVariable('datasource variable with regex filter', function(scenario) {
+  describeUpdateVariable('datasource variable with regex filter', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'datasource',
@@ -486,7 +486,7 @@ describe('VariableSrv', function(this: any) {
   //
   // Custom variable update
   //
-  describeUpdateVariable('update custom variable', function(scenario) {
+  describeUpdateVariable('update custom variable', scenario => {
     scenario.setup(() => {
       scenario.variableModel = {
         type: 'custom',

+ 5 - 4
public/app/features/templating/variable_srv.ts

@@ -122,12 +122,13 @@ export class VariableSrv {
     }
 
     const g = this.createGraph();
-    const promises = g
-      .getNode(variable.name)
-      .getOptimizedInputEdges()
-      .map(e => {
+    const node = g.getNode(variable.name);
+    let promises = [];
+    if (node) {
+      promises = node.getOptimizedInputEdges().map(e => {
         return this.updateOptions(this.variables.find(v => v.name === e.inputNode.name));
       });
+    }
 
     return this.$q.all(promises).then(() => {
       if (emitChangeEvents) {

+ 20 - 20
public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts

@@ -3,7 +3,7 @@ import CloudWatchDatasource from '../datasource';
 import * as dateMath from 'app/core/utils/datemath';
 import _ from 'lodash';
 
-describe('CloudWatchDatasource', function() {
+describe('CloudWatchDatasource', () => {
   const instanceSettings = {
     jsonData: { defaultRegion: 'us-east-1', access: 'proxy' },
   };
@@ -34,7 +34,7 @@ describe('CloudWatchDatasource', function() {
     ctx.ds = new CloudWatchDatasource(instanceSettings, {}, backendSrv, templateSrv, timeSrv);
   });
 
-  describe('When performing CloudWatch query', function() {
+  describe('When performing CloudWatch query', () => {
     let requestParams;
 
     const query = {
@@ -80,8 +80,8 @@ describe('CloudWatchDatasource', function() {
       });
     });
 
-    it('should generate the correct query', function(done) {
-      ctx.ds.query(query).then(function() {
+    it('should generate the correct query', done => {
+      ctx.ds.query(query).then(() => {
         const params = requestParams.queries[0];
         expect(params.namespace).toBe(query.targets[0].namespace);
         expect(params.metricName).toBe(query.targets[0].metricName);
@@ -92,7 +92,7 @@ describe('CloudWatchDatasource', function() {
       });
     });
 
-    it('should generate the correct query with interval variable', function(done) {
+    it('should generate the correct query with interval variable', done => {
       ctx.templateSrv.data = {
         period: '10m',
       };
@@ -114,14 +114,14 @@ describe('CloudWatchDatasource', function() {
         ],
       };
 
-      ctx.ds.query(query).then(function() {
+      ctx.ds.query(query).then(() => {
         const params = requestParams.queries[0];
         expect(params.period).toBe('600');
         done();
       });
     });
 
-    it('should cancel query for invalid extended statistics', function() {
+    it('should cancel query for invalid extended statistics', () => {
       const query = {
         range: { from: 'now-1h', to: 'now' },
         rangeRaw: { from: 1483228800, to: 1483232400 },
@@ -141,8 +141,8 @@ describe('CloudWatchDatasource', function() {
       expect(ctx.ds.query.bind(ctx.ds, query)).toThrow(/Invalid extended statistics/);
     });
 
-    it('should return series list', function(done) {
-      ctx.ds.query(query).then(function(result) {
+    it('should return series list', done => {
+      ctx.ds.query(query).then(result => {
         expect(result.data[0].target).toBe(response.results.A.series[0].name);
         expect(result.data[0].datapoints[0][0]).toBe(response.results.A.series[0].points[0][0]);
         done();
@@ -150,8 +150,8 @@ describe('CloudWatchDatasource', function() {
     });
   });
 
-  describe('When query region is "default"', function() {
-    it('should return the datasource region if empty or "default"', function() {
+  describe('When query region is "default"', () => {
+    it('should return the datasource region if empty or "default"', () => {
       const defaultRegion = instanceSettings.jsonData.defaultRegion;
 
       expect(ctx.ds.getActualRegion()).toBe(defaultRegion);
@@ -159,19 +159,19 @@ describe('CloudWatchDatasource', function() {
       expect(ctx.ds.getActualRegion('default')).toBe(defaultRegion);
     });
 
-    it('should return the specified region if specified', function() {
+    it('should return the specified region if specified', () => {
       expect(ctx.ds.getActualRegion('some-fake-region-1')).toBe('some-fake-region-1');
     });
 
     let requestParams;
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.ds.performTimeSeriesQuery = jest.fn(request => {
         requestParams = request;
         return Promise.resolve({ data: {} });
       });
     });
 
-    it('should query for the datasource region if empty or "default"', function(done) {
+    it('should query for the datasource region if empty or "default"', done => {
       const query = {
         range: { from: 'now-1h', to: 'now' },
         rangeRaw: { from: 1483228800, to: 1483232400 },
@@ -189,14 +189,14 @@ describe('CloudWatchDatasource', function() {
         ],
       };
 
-      ctx.ds.query(query).then(function(result) {
+      ctx.ds.query(query).then(result => {
         expect(requestParams.queries[0].region).toBe(instanceSettings.jsonData.defaultRegion);
         done();
       });
     });
   });
 
-  describe('When performing CloudWatch query for extended statistics', function() {
+  describe('When performing CloudWatch query for extended statistics', () => {
     const query = {
       range: { from: 'now-1h', to: 'now' },
       rangeRaw: { from: 1483228800, to: 1483232400 },
@@ -235,14 +235,14 @@ describe('CloudWatchDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(params => {
         return Promise.resolve({ data: response });
       });
     });
 
-    it('should return series list', function(done) {
-      ctx.ds.query(query).then(function(result) {
+    it('should return series list', done => {
+      ctx.ds.query(query).then(result => {
         expect(result.data[0].target).toBe(response.results.A.series[0].name);
         expect(result.data[0].datapoints[0][0]).toBe(response.results.A.series[0].points[0][0]);
         done();
@@ -378,7 +378,7 @@ describe('CloudWatchDatasource', function() {
     });
   });
 
-  it('should caclculate the correct period', function() {
+  it('should caclculate the correct period', () => {
     const hourSec = 60 * 60;
     const daySec = hourSec * 24;
     const start = 1483196400 * 1000;

+ 18 - 18
public/app/plugins/datasource/elasticsearch/specs/datasource.test.ts

@@ -43,8 +43,8 @@ describe('ElasticDatasource', function(this: any) {
     ctx.ds = new ElasticDatasource(instanceSettings, {}, backendSrv, templateSrv, timeSrv);
   }
 
-  describe('When testing datasource with index pattern', function() {
-    beforeEach(function() {
+  describe('When testing datasource with index pattern', () => {
+    beforeEach(() => {
       createDatasource({
         url: 'http://es.com',
         index: '[asd-]YYYY.MM.DD',
@@ -52,7 +52,7 @@ describe('ElasticDatasource', function(this: any) {
       });
     });
 
-    it('should translate index pattern to current day', function() {
+    it('should translate index pattern to current day', () => {
       let requestOptions;
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         requestOptions = options;
@@ -66,7 +66,7 @@ describe('ElasticDatasource', function(this: any) {
     });
   });
 
-  describe('When issuing metric query with interval pattern', function() {
+  describe('When issuing metric query with interval pattern', () => {
     let requestOptions, parts, header;
 
     beforeEach(() => {
@@ -99,20 +99,20 @@ describe('ElasticDatasource', function(this: any) {
       header = angular.fromJson(parts[0]);
     });
 
-    it('should translate index pattern to current day', function() {
+    it('should translate index pattern to current day', () => {
       expect(header.index).toEqual(['asd-2015.05.30', 'asd-2015.05.31', 'asd-2015.06.01']);
     });
 
-    it('should json escape lucene query', function() {
+    it('should json escape lucene query', () => {
       const body = angular.fromJson(parts[1]);
       expect(body.query.bool.filter[1].query_string.query).toBe('escape\\:test');
     });
   });
 
-  describe('When issuing document query', function() {
+  describe('When issuing document query', () => {
     let requestOptions, parts, header;
 
-    beforeEach(function() {
+    beforeEach(() => {
       createDatasource({
         url: 'http://es.com',
         index: 'test',
@@ -142,17 +142,17 @@ describe('ElasticDatasource', function(this: any) {
       header = angular.fromJson(parts[0]);
     });
 
-    it('should set search type to query_then_fetch', function() {
+    it('should set search type to query_then_fetch', () => {
       expect(header.search_type).toEqual('query_then_fetch');
     });
 
-    it('should set size', function() {
+    it('should set size', () => {
       const body = angular.fromJson(parts[1]);
       expect(body.size).toBe(500);
     });
   });
 
-  describe('When getting fields', function() {
+  describe('When getting fields', () => {
     beforeEach(() => {
       createDatasource({ url: 'http://es.com', index: 'metricbeat' });
 
@@ -203,7 +203,7 @@ describe('ElasticDatasource', function(this: any) {
       });
     });
 
-    it('should return nested fields', function() {
+    it('should return nested fields', () => {
       ctx.ds
         .getFields({
           find: 'fields',
@@ -224,7 +224,7 @@ describe('ElasticDatasource', function(this: any) {
         });
     });
 
-    it('should return fields related to query type', function() {
+    it('should return fields related to query type', () => {
       ctx.ds
         .getFields({
           find: 'fields',
@@ -249,10 +249,10 @@ describe('ElasticDatasource', function(this: any) {
     });
   });
 
-  describe('When issuing aggregation query on es5.x', function() {
+  describe('When issuing aggregation query on es5.x', () => {
     let requestOptions, parts, header;
 
-    beforeEach(function() {
+    beforeEach(() => {
       createDatasource({
         url: 'http://es.com',
         index: 'test',
@@ -282,17 +282,17 @@ describe('ElasticDatasource', function(this: any) {
       header = angular.fromJson(parts[0]);
     });
 
-    it('should not set search type to count', function() {
+    it('should not set search type to count', () => {
       expect(header.search_type).not.toEqual('count');
     });
 
-    it('should set size to 0', function() {
+    it('should set size to 0', () => {
       const body = angular.fromJson(parts[1]);
       expect(body.size).toBe(0);
     });
   });
 
-  describe('When issuing metricFind query on es5.x', function() {
+  describe('When issuing metricFind query on es5.x', () => {
     let requestOptions, parts, header, body, results;
 
     beforeEach(() => {

+ 27 - 27
public/app/plugins/datasource/graphite/specs/datasource.test.ts

@@ -12,12 +12,12 @@ describe('graphiteDatasource', () => {
     instanceSettings: { url: 'url', name: 'graphiteProd', jsonData: {} },
   };
 
-  beforeEach(function() {
+  beforeEach(() => {
     ctx.instanceSettings.url = '/api/datasources/proxy/1';
     ctx.ds = new GraphiteDatasource(ctx.instanceSettings, ctx.$q, ctx.backendSrv, ctx.templateSrv);
   });
 
-  describe('When querying graphite with one target using query editor target spec', function() {
+  describe('When querying graphite with one target using query editor target spec', () => {
     const query = {
       panelId: 3,
       dashboardId: 5,
@@ -30,14 +30,14 @@ describe('graphiteDatasource', () => {
     let requestOptions;
 
     beforeEach(async () => {
-      ctx.backendSrv.datasourceRequest = function(options) {
+      ctx.backendSrv.datasourceRequest = options => {
         requestOptions = options;
         return ctx.$q.when({
           data: [{ target: 'prod1.count', datapoints: [[10, 1], [12, 1]] }],
         });
       };
 
-      await ctx.ds.query(query).then(function(data) {
+      await ctx.ds.query(query).then(data => {
         results = data;
       });
     });
@@ -47,15 +47,15 @@ describe('graphiteDatasource', () => {
       expect(requestOptions.headers['X-Panel-Id']).toBe(3);
     });
 
-    it('should generate the correct query', function() {
+    it('should generate the correct query', () => {
       expect(requestOptions.url).toBe('/api/datasources/proxy/1/render');
     });
 
-    it('should set unique requestId', function() {
+    it('should set unique requestId', () => {
       expect(requestOptions.requestId).toBe('graphiteProd.panelId.3');
     });
 
-    it('should query correctly', function() {
+    it('should query correctly', () => {
       const params = requestOptions.data.split('&');
       expect(params).toContain('target=prod1.count');
       expect(params).toContain('target=prod2.count');
@@ -63,17 +63,17 @@ describe('graphiteDatasource', () => {
       expect(params).toContain('until=now');
     });
 
-    it('should exclude undefined params', function() {
+    it('should exclude undefined params', () => {
       const params = requestOptions.data.split('&');
       expect(params).not.toContain('cacheTimeout=undefined');
     });
 
-    it('should return series list', function() {
+    it('should return series list', () => {
       expect(results.data.length).toBe(1);
       expect(results.data[0].target).toBe('prod1.count');
     });
 
-    it('should convert to millisecond resolution', function() {
+    it('should convert to millisecond resolution', () => {
       expect(results.data[0].datapoints[0][0]).toBe(10);
     });
   });
@@ -106,11 +106,11 @@ describe('graphiteDatasource', () => {
       };
 
       beforeEach(async () => {
-        ctx.backendSrv.datasourceRequest = function(options) {
+        ctx.backendSrv.datasourceRequest = options => {
           return ctx.$q.when(response);
         };
 
-        await ctx.ds.annotationQuery(options).then(function(data) {
+        await ctx.ds.annotationQuery(options).then(data => {
           results = data;
         });
       });
@@ -136,11 +136,11 @@ describe('graphiteDatasource', () => {
         ],
       };
       beforeEach(() => {
-        ctx.backendSrv.datasourceRequest = function(options) {
+        ctx.backendSrv.datasourceRequest = options => {
           return ctx.$q.when(response);
         };
 
-        ctx.ds.annotationQuery(options).then(function(data) {
+        ctx.ds.annotationQuery(options).then(data => {
           results = data;
         });
         // ctx.$rootScope.$apply();
@@ -155,29 +155,29 @@ describe('graphiteDatasource', () => {
     });
   });
 
-  describe('building graphite params', function() {
-    it('should return empty array if no targets', function() {
+  describe('building graphite params', () => {
+    it('should return empty array if no targets', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{}],
       });
       expect(results.length).toBe(0);
     });
 
-    it('should uri escape targets', function() {
+    it('should uri escape targets', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{ target: 'prod1.{test,test2}' }, { target: 'prod2.count' }],
       });
       expect(results).toContain('target=prod1.%7Btest%2Ctest2%7D');
     });
 
-    it('should replace target placeholder', function() {
+    it('should replace target placeholder', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{ target: 'series1' }, { target: 'series2' }, { target: 'asPercent(#A,#B)' }],
       });
       expect(results[2]).toBe('target=asPercent(series1%2Cseries2)');
     });
 
-    it('should replace target placeholder for hidden series', function() {
+    it('should replace target placeholder for hidden series', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [
           { target: 'series1', hide: true },
@@ -188,28 +188,28 @@ describe('graphiteDatasource', () => {
       expect(results[0]).toBe('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))'));
     });
 
-    it('should replace target placeholder when nesting query references', function() {
+    it('should replace target placeholder when nesting query references', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{ target: 'series1' }, { target: 'sumSeries(#A)' }, { target: 'asPercent(#A,#B)' }],
       });
       expect(results[2]).toBe('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))'));
     });
 
-    it('should fix wrong minute interval parameters', function() {
+    it('should fix wrong minute interval parameters', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{ target: "summarize(prod.25m.count, '25m', 'sum')" }],
       });
       expect(results[0]).toBe('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')"));
     });
 
-    it('should fix wrong month interval parameters', function() {
+    it('should fix wrong month interval parameters', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{ target: "summarize(prod.5M.count, '5M', 'sum')" }],
       });
       expect(results[0]).toBe('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')"));
     });
 
-    it('should ignore empty targets', function() {
+    it('should ignore empty targets', () => {
       const results = ctx.ds.buildGraphiteParams({
         targets: [{ target: 'series1' }, { target: '' }],
       });
@@ -222,7 +222,7 @@ describe('graphiteDatasource', () => {
     let requestOptions;
 
     beforeEach(() => {
-      ctx.backendSrv.datasourceRequest = function(options) {
+      ctx.backendSrv.datasourceRequest = options => {
         requestOptions = options;
         return ctx.$q.when({
           data: ['backend_01', 'backend_02'],
@@ -307,7 +307,7 @@ describe('graphiteDatasource', () => {
 });
 
 function accessScenario(name, url, fn) {
-  describe('access scenario ' + name, function() {
+  describe('access scenario ' + name, () => {
     const ctx: any = {
       backendSrv: {},
       $q: $q,
@@ -332,12 +332,12 @@ function accessScenario(name, url, fn) {
   });
 }
 
-accessScenario('with proxy access', '/api/datasources/proxy/1', function(httpOptions) {
+accessScenario('with proxy access', '/api/datasources/proxy/1', httpOptions => {
   expect(httpOptions.headers['X-Dashboard-Id']).toBe(1);
   expect(httpOptions.headers['X-Panel-Id']).toBe(2);
 });
 
-accessScenario('with direct access', 'http://localhost:8080', function(httpOptions) {
+accessScenario('with direct access', 'http://localhost:8080', httpOptions => {
   expect(httpOptions.headers['X-Dashboard-Id']).toBe(undefined);
   expect(httpOptions.headers['X-Panel-Id']).toBe(undefined);
 });

+ 25 - 25
public/app/plugins/datasource/graphite/specs/gfunc.test.ts

@@ -1,7 +1,7 @@
 import gfunc from '../gfunc';
 
-describe('when creating func instance from func names', function() {
-  it('should return func instance', function() {
+describe('when creating func instance from func names', () => {
+  it('should return func instance', () => {
     const func = gfunc.createFuncInstance('sumSeries');
     expect(func).toBeTruthy();
     expect(func.def.name).toEqual('sumSeries');
@@ -10,18 +10,18 @@ describe('when creating func instance from func names', function() {
     expect(func.def.defaultParams.length).toEqual(1);
   });
 
-  it('should return func instance with shortName', function() {
+  it('should return func instance with shortName', () => {
     const func = gfunc.createFuncInstance('sum');
     expect(func).toBeTruthy();
   });
 
-  it('should return func instance from funcDef', function() {
+  it('should return func instance from funcDef', () => {
     const func = gfunc.createFuncInstance('sum');
     const func2 = gfunc.createFuncInstance(func.def);
     expect(func2).toBeTruthy();
   });
 
-  it('func instance should have text representation', function() {
+  it('func instance should have text representation', () => {
     const func = gfunc.createFuncInstance('groupByNode');
     func.params[0] = 5;
     func.params[1] = 'avg';
@@ -30,78 +30,78 @@ describe('when creating func instance from func names', function() {
   });
 });
 
-describe('when rendering func instance', function() {
-  it('should handle single metric param', function() {
+describe('when rendering func instance', () => {
+  it('should handle single metric param', () => {
     const func = gfunc.createFuncInstance('sumSeries');
     expect(func.render('hello.metric')).toEqual('sumSeries(hello.metric)');
   });
 
-  it('should include default params if options enable it', function() {
+  it('should include default params if options enable it', () => {
     const func = gfunc.createFuncInstance('scaleToSeconds', {
       withDefaultParams: true,
     });
     expect(func.render('hello')).toEqual('scaleToSeconds(hello, 1)');
   });
 
-  it('should handle int or interval params with number', function() {
+  it('should handle int or interval params with number', () => {
     const func = gfunc.createFuncInstance('movingMedian');
     func.params[0] = '5';
     expect(func.render('hello')).toEqual('movingMedian(hello, 5)');
   });
 
-  it('should handle int or interval params with interval string', function() {
+  it('should handle int or interval params with interval string', () => {
     const func = gfunc.createFuncInstance('movingMedian');
     func.params[0] = '5min';
     expect(func.render('hello')).toEqual("movingMedian(hello, '5min')");
   });
 
-  it('should never quote boolean paramater', function() {
+  it('should never quote boolean paramater', () => {
     const func = gfunc.createFuncInstance('sortByName');
     func.params[0] = '$natural';
     expect(func.render('hello')).toEqual('sortByName(hello, $natural)');
   });
 
-  it('should never quote int paramater', function() {
+  it('should never quote int paramater', () => {
     const func = gfunc.createFuncInstance('maximumAbove');
     func.params[0] = '$value';
     expect(func.render('hello')).toEqual('maximumAbove(hello, $value)');
   });
 
-  it('should never quote node paramater', function() {
+  it('should never quote node paramater', () => {
     const func = gfunc.createFuncInstance('aliasByNode');
     func.params[0] = '$node';
     expect(func.render('hello')).toEqual('aliasByNode(hello, $node)');
   });
 
-  it('should handle metric param and int param and string param', function() {
+  it('should handle metric param and int param and string param', () => {
     const func = gfunc.createFuncInstance('groupByNode');
     func.params[0] = 5;
     func.params[1] = 'avg';
     expect(func.render('hello.metric')).toEqual("groupByNode(hello.metric, 5, 'avg')");
   });
 
-  it('should handle function with no metric param', function() {
+  it('should handle function with no metric param', () => {
     const func = gfunc.createFuncInstance('randomWalk');
     func.params[0] = 'test';
     expect(func.render(undefined)).toEqual("randomWalk('test')");
   });
 
-  it('should handle function multiple series params', function() {
+  it('should handle function multiple series params', () => {
     const func = gfunc.createFuncInstance('asPercent');
     func.params[0] = '#B';
     expect(func.render('#A')).toEqual('asPercent(#A, #B)');
   });
 });
 
-describe('when requesting function definitions', function() {
-  it('should return function definitions', function() {
+describe('when requesting function definitions', () => {
+  it('should return function definitions', () => {
     const funcIndex = gfunc.getFuncDefs('1.0');
     expect(Object.keys(funcIndex).length).toBeGreaterThan(8);
   });
 });
 
-describe('when updating func param', function() {
-  it('should update param value and update text representation', function() {
+describe('when updating func param', () => {
+  it('should update param value and update text representation', () => {
     const func = gfunc.createFuncInstance('summarize', {
       withDefaultParams: true,
     });
@@ -110,21 +110,21 @@ describe('when updating func param', function() {
     expect(func.text).toBe('summarize(1h, sum, false)');
   });
 
-  it('should parse numbers as float', function() {
+  it('should parse numbers as float', () => {
     const func = gfunc.createFuncInstance('scale');
     func.updateParam('0.001', 0);
     expect(func.params[0]).toBe('0.001');
   });
 });
 
-describe('when updating func param with optional second parameter', function() {
-  it('should update value and text', function() {
+describe('when updating func param with optional second parameter', () => {
+  it('should update value and text', () => {
     const func = gfunc.createFuncInstance('aliasByNode');
     func.updateParam('1', 0);
     expect(func.params[0]).toBe('1');
   });
 
-  it('should slit text and put value in second param', function() {
+  it('should slit text and put value in second param', () => {
     const func = gfunc.createFuncInstance('aliasByNode');
     func.updateParam('4,-5', 0);
     expect(func.params[0]).toBe('4');
@@ -132,7 +132,7 @@ describe('when updating func param with optional second parameter', function() {
     expect(func.text).toBe('aliasByNode(4, -5)');
   });
 
-  it('should remove second param when empty string is set', function() {
+  it('should remove second param when empty string is set', () => {
     const func = gfunc.createFuncInstance('aliasByNode');
     func.updateParam('4,-5', 0);
     func.updateParam('', 1);

+ 15 - 15
public/app/plugins/datasource/graphite/specs/lexer.test.ts

@@ -1,7 +1,7 @@
 import { Lexer } from '../lexer';
 
-describe('when lexing graphite expression', function() {
-  it('should tokenize metric expression', function() {
+describe('when lexing graphite expression', () => {
+  it('should tokenize metric expression', () => {
     const lexer = new Lexer('metric.test.*.asd.count');
     const tokens = lexer.tokenize();
     expect(tokens[0].value).toBe('metric');
@@ -11,27 +11,27 @@ describe('when lexing graphite expression', function() {
     expect(tokens[4].pos).toBe(13);
   });
 
-  it('should tokenize metric expression with dash', function() {
+  it('should tokenize metric expression with dash', () => {
     const lexer = new Lexer('metric.test.se1-server-*.asd.count');
     const tokens = lexer.tokenize();
     expect(tokens[4].type).toBe('identifier');
     expect(tokens[4].value).toBe('se1-server-*');
   });
 
-  it('should tokenize metric expression with dash2', function() {
+  it('should tokenize metric expression with dash2', () => {
     const lexer = new Lexer('net.192-168-1-1.192-168-1-9.ping_value.*');
     const tokens = lexer.tokenize();
     expect(tokens[0].value).toBe('net');
     expect(tokens[2].value).toBe('192-168-1-1');
   });
 
-  it('should tokenize metric expression with equal sign', function() {
+  it('should tokenize metric expression with equal sign', () => {
     const lexer = new Lexer('apps=test');
     const tokens = lexer.tokenize();
     expect(tokens[0].value).toBe('apps=test');
   });
 
-  it('simple function2', function() {
+  it('simple function2', () => {
     const lexer = new Lexer('offset(test.metric, -100)');
     const tokens = lexer.tokenize();
     expect(tokens[2].type).toBe('identifier');
@@ -39,7 +39,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens[6].type).toBe('number');
   });
 
-  it('should tokenize metric expression with curly braces', function() {
+  it('should tokenize metric expression with curly braces', () => {
     const lexer = new Lexer('metric.se1-{first, second}.count');
     const tokens = lexer.tokenize();
     expect(tokens.length).toBe(10);
@@ -49,7 +49,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens[6].value).toBe('second');
   });
 
-  it('should tokenize metric expression with number segments', function() {
+  it('should tokenize metric expression with number segments', () => {
     const lexer = new Lexer('metric.10.12_10.test');
     const tokens = lexer.tokenize();
     expect(tokens[0].type).toBe('identifier');
@@ -59,7 +59,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens[4].type).toBe('identifier');
   });
 
-  it('should tokenize metric expression with segment that start with number', function() {
+  it('should tokenize metric expression with segment that start with number', () => {
     const lexer = new Lexer('metric.001-server');
     const tokens = lexer.tokenize();
     expect(tokens[0].type).toBe('identifier');
@@ -67,7 +67,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens.length).toBe(3);
   });
 
-  it('should tokenize func call with numbered metric and number arg', function() {
+  it('should tokenize func call with numbered metric and number arg', () => {
     const lexer = new Lexer('scale(metric.10, 15)');
     const tokens = lexer.tokenize();
     expect(tokens[0].type).toBe('identifier');
@@ -78,7 +78,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens[6].type).toBe('number');
   });
 
-  it('should tokenize metric with template parameter', function() {
+  it('should tokenize metric with template parameter', () => {
     const lexer = new Lexer('metric.[[server]].test');
     const tokens = lexer.tokenize();
     expect(tokens[2].type).toBe('identifier');
@@ -86,7 +86,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens[4].type).toBe('identifier');
   });
 
-  it('should tokenize metric with question mark', function() {
+  it('should tokenize metric with question mark', () => {
     const lexer = new Lexer('metric.server_??.test');
     const tokens = lexer.tokenize();
     expect(tokens[2].type).toBe('identifier');
@@ -94,7 +94,7 @@ describe('when lexing graphite expression', function() {
     expect(tokens[4].type).toBe('identifier');
   });
 
-  it('should handle error with unterminated string', function() {
+  it('should handle error with unterminated string', () => {
     const lexer = new Lexer("alias(metric, 'asd)");
     const tokens = lexer.tokenize();
     expect(tokens[0].value).toBe('alias');
@@ -106,14 +106,14 @@ describe('when lexing graphite expression', function() {
     expect(tokens[4].pos).toBe(20);
   });
 
-  it('should handle float parameters', function() {
+  it('should handle float parameters', () => {
     const lexer = new Lexer('alias(metric, 0.002)');
     const tokens = lexer.tokenize();
     expect(tokens[4].type).toBe('number');
     expect(tokens[4].value).toBe('0.002');
   });
 
-  it('should handle bool parameters', function() {
+  it('should handle bool parameters', () => {
     const lexer = new Lexer('alias(metric, true, false)');
     const tokens = lexer.tokenize();
     expect(tokens[4].type).toBe('bool');

+ 21 - 21
public/app/plugins/datasource/graphite/specs/parser.test.ts

@@ -1,7 +1,7 @@
 import { Parser } from '../parser';
 
-describe('when parsing', function() {
-  it('simple metric expression', function() {
+describe('when parsing', () => {
+  it('simple metric expression', () => {
     const parser = new Parser('metric.test.*.asd.count');
     const rootNode = parser.getAst();
 
@@ -10,7 +10,7 @@ describe('when parsing', function() {
     expect(rootNode.segments[0].value).toBe('metric');
   });
 
-  it('simple metric expression with numbers in segments', function() {
+  it('simple metric expression with numbers in segments', () => {
     const parser = new Parser('metric.10.15_20.5');
     const rootNode = parser.getAst();
 
@@ -21,7 +21,7 @@ describe('when parsing', function() {
     expect(rootNode.segments[3].value).toBe('5');
   });
 
-  it('simple metric expression with curly braces', function() {
+  it('simple metric expression with curly braces', () => {
     const parser = new Parser('metric.se1-{count, max}');
     const rootNode = parser.getAst();
 
@@ -30,7 +30,7 @@ describe('when parsing', function() {
     expect(rootNode.segments[1].value).toBe('se1-{count,max}');
   });
 
-  it('simple metric expression with curly braces at start of segment and with post chars', function() {
+  it('simple metric expression with curly braces at start of segment and with post chars', () => {
     const parser = new Parser('metric.{count, max}-something.count');
     const rootNode = parser.getAst();
 
@@ -39,14 +39,14 @@ describe('when parsing', function() {
     expect(rootNode.segments[1].value).toBe('{count,max}-something');
   });
 
-  it('simple function', function() {
+  it('simple function', () => {
     const parser = new Parser('sum(test)');
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
     expect(rootNode.params.length).toBe(1);
   });
 
-  it('simple function2', function() {
+  it('simple function2', () => {
     const parser = new Parser('offset(test.metric, -100)');
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
@@ -54,7 +54,7 @@ describe('when parsing', function() {
     expect(rootNode.params[1].type).toBe('number');
   });
 
-  it('simple function with string arg', function() {
+  it('simple function with string arg', () => {
     const parser = new Parser("randomWalk('test')");
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
@@ -62,7 +62,7 @@ describe('when parsing', function() {
     expect(rootNode.params[0].type).toBe('string');
   });
 
-  it('function with multiple args', function() {
+  it('function with multiple args', () => {
     const parser = new Parser("sum(test, 1, 'test')");
     const rootNode = parser.getAst();
 
@@ -73,7 +73,7 @@ describe('when parsing', function() {
     expect(rootNode.params[2].type).toBe('string');
   });
 
-  it('function with nested function', function() {
+  it('function with nested function', () => {
     const parser = new Parser('sum(scaleToSeconds(test, 1))');
     const rootNode = parser.getAst();
 
@@ -86,7 +86,7 @@ describe('when parsing', function() {
     expect(rootNode.params[0].params[1].type).toBe('number');
   });
 
-  it('function with multiple series', function() {
+  it('function with multiple series', () => {
     const parser = new Parser('sum(test.test.*.count, test.timers.*.count)');
     const rootNode = parser.getAst();
 
@@ -96,7 +96,7 @@ describe('when parsing', function() {
     expect(rootNode.params[1].type).toBe('metric');
   });
 
-  it('function with templated series', function() {
+  it('function with templated series', () => {
     const parser = new Parser('sum(test.[[server]].count)');
     const rootNode = parser.getAst();
 
@@ -106,7 +106,7 @@ describe('when parsing', function() {
     expect(rootNode.params[0].segments[1].value).toBe('[[server]]');
   });
 
-  it('invalid metric expression', function() {
+  it('invalid metric expression', () => {
     const parser = new Parser('metric.test.*.asd.');
     const rootNode = parser.getAst();
 
@@ -114,7 +114,7 @@ describe('when parsing', function() {
     expect(rootNode.pos).toBe(19);
   });
 
-  it('invalid function expression missing closing parenthesis', function() {
+  it('invalid function expression missing closing parenthesis', () => {
     const parser = new Parser('sum(test');
     const rootNode = parser.getAst();
 
@@ -122,7 +122,7 @@ describe('when parsing', function() {
     expect(rootNode.pos).toBe(9);
   });
 
-  it('unclosed string in function', function() {
+  it('unclosed string in function', () => {
     const parser = new Parser("sum('test)");
     const rootNode = parser.getAst();
 
@@ -130,13 +130,13 @@ describe('when parsing', function() {
     expect(rootNode.pos).toBe(11);
   });
 
-  it('handle issue #69', function() {
+  it('handle issue #69', () => {
     const parser = new Parser('cactiStyle(offset(scale(net.192-168-1-1.192-168-1-9.ping_value.*,0.001),-100))');
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
   });
 
-  it('handle float function arguments', function() {
+  it('handle float function arguments', () => {
     const parser = new Parser('scale(test, 0.002)');
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
@@ -144,7 +144,7 @@ describe('when parsing', function() {
     expect(rootNode.params[1].value).toBe(0.002);
   });
 
-  it('handle curly brace pattern at start', function() {
+  it('handle curly brace pattern at start', () => {
     const parser = new Parser('{apps}.test');
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('metric');
@@ -152,7 +152,7 @@ describe('when parsing', function() {
     expect(rootNode.segments[1].value).toBe('test');
   });
 
-  it('series parameters', function() {
+  it('series parameters', () => {
     const parser = new Parser('asPercent(#A, #B)');
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
@@ -161,7 +161,7 @@ describe('when parsing', function() {
     expect(rootNode.params[1].value).toBe('#B');
   });
 
-  it('series parameters, issue 2788', function() {
+  it('series parameters, issue 2788', () => {
     const parser = new Parser("summarize(diffSeries(#A, #B), '10m', 'sum', false)");
     const rootNode = parser.getAst();
     expect(rootNode.type).toBe('function');
@@ -170,7 +170,7 @@ describe('when parsing', function() {
     expect(rootNode.params[3].type).toBe('bool');
   });
 
-  it('should parse metric expression with ip number segments', function() {
+  it('should parse metric expression with ip number segments', () => {
     const parser = new Parser('5.10.123.5');
     const rootNode = parser.getAst();
     expect(rootNode.segments[0].value).toBe('5');

+ 1 - 1
public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts

@@ -137,7 +137,7 @@ describe('GraphiteQueryCtrl', () => {
       ctx.ctrl.target.target = 'test.count';
       ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]);
       ctx.ctrl.parseTarget();
-      ctx.ctrl.getAltSegments(1).then(function(results) {
+      ctx.ctrl.getAltSegments(1).then(results => {
         ctx.altSegments = results;
       });
     });

+ 3 - 3
public/app/plugins/datasource/influxdb/specs/datasource.test.ts

@@ -10,7 +10,7 @@ describe('InfluxDataSource', () => {
     instanceSettings: { url: 'url', name: 'influxDb', jsonData: {} },
   };
 
-  beforeEach(function() {
+  beforeEach(() => {
     ctx.instanceSettings.url = '/api/datasources/proxy/1';
     ctx.ds = new InfluxDatasource(ctx.instanceSettings, ctx.$q, ctx.backendSrv, ctx.templateSrv);
   });
@@ -26,7 +26,7 @@ describe('InfluxDataSource', () => {
     let requestQuery;
 
     beforeEach(async () => {
-      ctx.backendSrv.datasourceRequest = function(req) {
+      ctx.backendSrv.datasourceRequest = req => {
         requestQuery = req.params.q;
         return ctx.$q.when({
           results: [
@@ -43,7 +43,7 @@ describe('InfluxDataSource', () => {
         });
       };
 
-      await ctx.ds.metricFindQuery(query, queryOptions).then(function(_) {});
+      await ctx.ds.metricFindQuery(query, queryOptions).then(_ => {});
     });
 
     it('should replace $timefilter', () => {

+ 34 - 34
public/app/plugins/datasource/influxdb/specs/influx_query.test.ts

@@ -1,10 +1,10 @@
 import InfluxQuery from '../influx_query';
 
-describe('InfluxQuery', function() {
+describe('InfluxQuery', () => {
   const templateSrv = { replace: val => val };
 
-  describe('render series with mesurement only', function() {
-    it('should generate correct query', function() {
+  describe('render series with mesurement only', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -18,8 +18,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('render series with policy only', function() {
-    it('should generate correct query', function() {
+  describe('render series with policy only', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -36,8 +36,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('render series with math and alias', function() {
-    it('should generate correct query', function() {
+  describe('render series with math and alias', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -61,8 +61,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('series with single tag only', function() {
-    it('should generate correct query', function() {
+  describe('series with single tag only', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -81,7 +81,7 @@ describe('InfluxQuery', function() {
       );
     });
 
-    it('should switch regex operator with tag value is regex', function() {
+    it('should switch regex operator with tag value is regex', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -99,8 +99,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('series with multiple tags only', function() {
-    it('should generate correct query', function() {
+  describe('series with multiple tags only', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -119,8 +119,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('series with tags OR condition', function() {
-    it('should generate correct query', function() {
+  describe('series with tags OR condition', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -139,8 +139,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('query with value condition', function() {
-    it('should not quote value', function() {
+  describe('query with value condition', () => {
+    it('should not quote value', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -156,8 +156,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('series with groupByTag', function() {
-    it('should generate correct query', function() {
+  describe('series with groupByTag', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -173,8 +173,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('render series without group by', function() {
-    it('should generate correct query', function() {
+  describe('render series without group by', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -189,8 +189,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('render series without group by and fill', function() {
-    it('should generate correct query', function() {
+  describe('render series without group by and fill', () => {
+    it('should generate correct query', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -205,8 +205,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('when adding group by part', function() {
-    it('should add tag before fill', function() {
+  describe('when adding group by part', () => {
+    it('should add tag before fill', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -223,7 +223,7 @@ describe('InfluxQuery', function() {
       expect(query.target.groupBy[2].type).toBe('fill');
     });
 
-    it('should add tag last if no fill', function() {
+    it('should add tag last if no fill', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -239,8 +239,8 @@ describe('InfluxQuery', function() {
     });
   });
 
-  describe('when adding select part', function() {
-    it('should add mean after after field', function() {
+  describe('when adding select part', () => {
+    it('should add mean after after field', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -255,7 +255,7 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][1].type).toBe('mean');
     });
 
-    it('should replace sum by mean', function() {
+    it('should replace sum by mean', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -270,7 +270,7 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][1].type).toBe('sum');
     });
 
-    it('should add math before alias', function() {
+    it('should add math before alias', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -285,7 +285,7 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][2].type).toBe('math');
     });
 
-    it('should add math last', function() {
+    it('should add math last', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -300,7 +300,7 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][2].type).toBe('math');
     });
 
-    it('should replace math', function() {
+    it('should replace math', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -315,7 +315,7 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][2].type).toBe('math');
     });
 
-    it('should add math when one only query part', function() {
+    it('should add math when one only query part', () => {
       const query = new InfluxQuery(
         {
           measurement: 'cpu',
@@ -330,8 +330,8 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][1].type).toBe('math');
     });
 
-    describe('when render adhoc filters', function() {
-      it('should generate correct query segment', function() {
+    describe('when render adhoc filters', () => {
+      it('should generate correct query segment', () => {
         const query = new InfluxQuery({ measurement: 'cpu' }, templateSrv, {});
 
         const queryText = query.renderAdhocFilters([

+ 29 - 29
public/app/plugins/datasource/influxdb/specs/influx_series.test.ts

@@ -1,7 +1,7 @@
 import InfluxSeries from '../influx_series';
 
-describe('when generating timeseries from influxdb response', function() {
-  describe('given multiple fields for series', function() {
+describe('when generating timeseries from influxdb response', () => {
+  describe('given multiple fields for series', () => {
     const options = {
       alias: '',
       series: [
@@ -13,8 +13,8 @@ describe('when generating timeseries from influxdb response', function() {
         },
       ],
     };
-    describe('and no alias', function() {
-      it('should generate multiple datapoints for each column', function() {
+    describe('and no alias', () => {
+      it('should generate multiple datapoints for each column', () => {
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
 
@@ -39,8 +39,8 @@ describe('when generating timeseries from influxdb response', function() {
       });
     });
 
-    describe('and simple alias', function() {
-      it('should use alias', function() {
+    describe('and simple alias', () => {
+      it('should use alias', () => {
         options.alias = 'new series';
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
@@ -51,8 +51,8 @@ describe('when generating timeseries from influxdb response', function() {
       });
     });
 
-    describe('and alias patterns', function() {
-      it('should replace patterns', function() {
+    describe('and alias patterns', () => {
+      it('should replace patterns', () => {
         options.alias = 'alias: $m -> $tag_server ([[measurement]])';
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
@@ -64,7 +64,7 @@ describe('when generating timeseries from influxdb response', function() {
     });
   });
 
-  describe('given measurement with default fieldname', function() {
+  describe('given measurement with default fieldname', () => {
     const options = {
       series: [
         {
@@ -82,8 +82,8 @@ describe('when generating timeseries from influxdb response', function() {
       ],
     };
 
-    describe('and no alias', function() {
-      it('should generate label with no field', function() {
+    describe('and no alias', () => {
+      it('should generate label with no field', () => {
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
 
@@ -93,7 +93,7 @@ describe('when generating timeseries from influxdb response', function() {
     });
   });
 
-  describe('given two series', function() {
+  describe('given two series', () => {
     const options = {
       alias: '',
       series: [
@@ -112,8 +112,8 @@ describe('when generating timeseries from influxdb response', function() {
       ],
     };
 
-    describe('and no alias', function() {
-      it('should generate two time series', function() {
+    describe('and no alias', () => {
+      it('should generate two time series', () => {
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
 
@@ -132,8 +132,8 @@ describe('when generating timeseries from influxdb response', function() {
       });
     });
 
-    describe('and simple alias', function() {
-      it('should use alias', function() {
+    describe('and simple alias', () => {
+      it('should use alias', () => {
         options.alias = 'new series';
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
@@ -142,8 +142,8 @@ describe('when generating timeseries from influxdb response', function() {
       });
     });
 
-    describe('and alias patterns', function() {
-      it('should replace patterns', function() {
+    describe('and alias patterns', () => {
+      it('should replace patterns', () => {
         options.alias = 'alias: $m -> $tag_server ([[measurement]])';
         const series = new InfluxSeries(options);
         const result = series.getTimeSeries();
@@ -154,7 +154,7 @@ describe('when generating timeseries from influxdb response', function() {
     });
   });
 
-  describe('given measurement with dots', function() {
+  describe('given measurement with dots', () => {
     const options = {
       alias: '',
       series: [
@@ -167,7 +167,7 @@ describe('when generating timeseries from influxdb response', function() {
       ],
     };
 
-    it('should replace patterns', function() {
+    it('should replace patterns', () => {
       options.alias = 'alias: $1 -> [[3]]';
       const series = new InfluxSeries(options);
       const result = series.getTimeSeries();
@@ -176,7 +176,7 @@ describe('when generating timeseries from influxdb response', function() {
     });
   });
 
-  describe('given table response', function() {
+  describe('given table response', () => {
     const options = {
       alias: '',
       series: [
@@ -189,7 +189,7 @@ describe('when generating timeseries from influxdb response', function() {
       ],
     };
 
-    it('should return table', function() {
+    it('should return table', () => {
       const series = new InfluxSeries(options);
       const table = series.getTable();
 
@@ -200,7 +200,7 @@ describe('when generating timeseries from influxdb response', function() {
     });
   });
 
-  describe('given table response from SHOW CARDINALITY', function() {
+  describe('given table response from SHOW CARDINALITY', () => {
     const options = {
       alias: '',
       series: [
@@ -212,7 +212,7 @@ describe('when generating timeseries from influxdb response', function() {
       ],
     };
 
-    it('should return table', function() {
+    it('should return table', () => {
       const series = new InfluxSeries(options);
       const table = series.getTable();
 
@@ -223,8 +223,8 @@ describe('when generating timeseries from influxdb response', function() {
     });
   });
 
-  describe('given annotation response', function() {
-    describe('with empty tagsColumn', function() {
+  describe('given annotation response', () => {
+    describe('with empty tagsColumn', () => {
       const options = {
         alias: '',
         annotation: {},
@@ -238,7 +238,7 @@ describe('when generating timeseries from influxdb response', function() {
         ],
       };
 
-      it('should multiple tags', function() {
+      it('should multiple tags', () => {
         const series = new InfluxSeries(options);
         const annotations = series.getAnnotations();
 
@@ -246,7 +246,7 @@ describe('when generating timeseries from influxdb response', function() {
       });
     });
 
-    describe('given annotation response', function() {
+    describe('given annotation response', () => {
       const options = {
         alias: '',
         annotation: {
@@ -262,7 +262,7 @@ describe('when generating timeseries from influxdb response', function() {
         ],
       };
 
-      it('should multiple tags', function() {
+      it('should multiple tags', () => {
         const series = new InfluxSeries(options);
         const annotations = series.getAnnotations();
 

+ 19 - 19
public/app/plugins/datasource/influxdb/specs/query_builder.test.ts

@@ -1,14 +1,14 @@
 import { InfluxQueryBuilder } from '../query_builder';
 
-describe('InfluxQueryBuilder', function() {
-  describe('when building explore queries', function() {
-    it('should only have measurement condition in tag keys query given query with measurement', function() {
+describe('InfluxQueryBuilder', () => {
+  describe('when building explore queries', () => {
+    it('should only have measurement condition in tag keys query given query with measurement', () => {
       const builder = new InfluxQueryBuilder({ measurement: 'cpu', tags: [] });
       const query = builder.buildExploreQuery('TAG_KEYS');
       expect(query).toBe('SHOW TAG KEYS FROM "cpu"');
     });
 
-    it('should handle regex measurement in tag keys query', function() {
+    it('should handle regex measurement in tag keys query', () => {
       const builder = new InfluxQueryBuilder({
         measurement: '/.*/',
         tags: [],
@@ -17,13 +17,13 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG KEYS FROM /.*/');
     });
 
-    it('should have no conditions in tags keys query given query with no measurement or tag', function() {
+    it('should have no conditions in tags keys query given query with no measurement or tag', () => {
       const builder = new InfluxQueryBuilder({ measurement: '', tags: [] });
       const query = builder.buildExploreQuery('TAG_KEYS');
       expect(query).toBe('SHOW TAG KEYS');
     });
 
-    it('should have where condition in tag keys query with tags', function() {
+    it('should have where condition in tag keys query with tags', () => {
       const builder = new InfluxQueryBuilder({
         measurement: '',
         tags: [{ key: 'host', value: 'se1' }],
@@ -32,25 +32,25 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG KEYS WHERE "host" = \'se1\'');
     });
 
-    it('should have no conditions in measurement query for query with no tags', function() {
+    it('should have no conditions in measurement query for query with no tags', () => {
       const builder = new InfluxQueryBuilder({ measurement: '', tags: [] });
       const query = builder.buildExploreQuery('MEASUREMENTS');
       expect(query).toBe('SHOW MEASUREMENTS LIMIT 100');
     });
 
-    it('should have no conditions in measurement query for query with no tags and empty query', function() {
+    it('should have no conditions in measurement query for query with no tags and empty query', () => {
       const builder = new InfluxQueryBuilder({ measurement: '', tags: [] });
       const query = builder.buildExploreQuery('MEASUREMENTS', undefined, '');
       expect(query).toBe('SHOW MEASUREMENTS LIMIT 100');
     });
 
-    it('should have WITH MEASUREMENT in measurement query for non-empty query with no tags', function() {
+    it('should have WITH MEASUREMENT in measurement query for non-empty query with no tags', () => {
       const builder = new InfluxQueryBuilder({ measurement: '', tags: [] });
       const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'something');
       expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /something/ LIMIT 100');
     });
 
-    it('should have WITH MEASUREMENT WHERE in measurement query for non-empty query with tags', function() {
+    it('should have WITH MEASUREMENT WHERE in measurement query for non-empty query with tags', () => {
       const builder = new InfluxQueryBuilder({
         measurement: '',
         tags: [{ key: 'app', value: 'email' }],
@@ -59,7 +59,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /something/ WHERE "app" = \'email\' LIMIT 100');
     });
 
-    it('should have where condition in measurement query for query with tags', function() {
+    it('should have where condition in measurement query for query with tags', () => {
       const builder = new InfluxQueryBuilder({
         measurement: '',
         tags: [{ key: 'app', value: 'email' }],
@@ -68,7 +68,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW MEASUREMENTS WHERE "app" = \'email\' LIMIT 100');
     });
 
-    it('should have where tag name IN filter in tag values query for query with one tag', function() {
+    it('should have where tag name IN filter in tag values query for query with one tag', () => {
       const builder = new InfluxQueryBuilder({
         measurement: '',
         tags: [{ key: 'app', value: 'asdsadsad' }],
@@ -77,7 +77,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG VALUES WITH KEY = "app"');
     });
 
-    it('should have measurement tag condition and tag name IN filter in tag values query', function() {
+    it('should have measurement tag condition and tag name IN filter in tag values query', () => {
       const builder = new InfluxQueryBuilder({
         measurement: 'cpu',
         tags: [{ key: 'app', value: 'email' }, { key: 'host', value: 'server1' }],
@@ -86,7 +86,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG VALUES FROM "cpu" WITH KEY = "app" WHERE "host" = \'server1\'');
     });
 
-    it('should select from policy correctly if policy is specified', function() {
+    it('should select from policy correctly if policy is specified', () => {
       const builder = new InfluxQueryBuilder({
         measurement: 'cpu',
         policy: 'one_week',
@@ -96,7 +96,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG VALUES FROM "one_week"."cpu" WITH KEY = "app" WHERE "host" = \'server1\'');
     });
 
-    it('should not include policy when policy is default', function() {
+    it('should not include policy when policy is default', () => {
       const builder = new InfluxQueryBuilder({
         measurement: 'cpu',
         policy: 'default',
@@ -106,7 +106,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG VALUES FROM "cpu" WITH KEY = "app"');
     });
 
-    it('should switch to regex operator in tag condition', function() {
+    it('should switch to regex operator in tag condition', () => {
       const builder = new InfluxQueryBuilder({
         measurement: 'cpu',
         tags: [{ key: 'host', value: '/server.*/' }],
@@ -115,7 +115,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW TAG VALUES FROM "cpu" WITH KEY = "app" WHERE "host" =~ /server.*/');
     });
 
-    it('should build show field query', function() {
+    it('should build show field query', () => {
       const builder = new InfluxQueryBuilder({
         measurement: 'cpu',
         tags: [{ key: 'app', value: 'email' }],
@@ -124,7 +124,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW FIELD KEYS FROM "cpu"');
     });
 
-    it('should build show field query with regexp', function() {
+    it('should build show field query with regexp', () => {
       const builder = new InfluxQueryBuilder({
         measurement: '/$var/',
         tags: [{ key: 'app', value: 'email' }],
@@ -133,7 +133,7 @@ describe('InfluxQueryBuilder', function() {
       expect(query).toBe('SHOW FIELD KEYS FROM /$var/');
     });
 
-    it('should build show retention policies query', function() {
+    it('should build show retention policies query', () => {
       const builder = new InfluxQueryBuilder({ measurement: 'cpu', tags: [] }, 'site');
       const query = builder.buildExploreQuery('RETENTION POLICIES');
       expect(query).toBe('SHOW RETENTION POLICIES on "site"');

+ 20 - 20
public/app/plugins/datasource/mssql/specs/datasource.test.ts

@@ -4,20 +4,20 @@ import { TemplateSrvStub } from 'test/specs/helpers';
 import { CustomVariable } from 'app/features/templating/custom_variable';
 import q from 'q';
 
-describe('MSSQLDatasource', function() {
+describe('MSSQLDatasource', () => {
   const ctx: any = {
     backendSrv: {},
     templateSrv: new TemplateSrvStub(),
   };
 
-  beforeEach(function() {
+  beforeEach(() => {
     ctx.$q = q;
     ctx.instanceSettings = { name: 'mssql' };
 
     ctx.ds = new MssqlDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.$q, ctx.templateSrv);
   });
 
-  describe('When performing annotationQuery', function() {
+  describe('When performing annotationQuery', () => {
     let results;
 
     const annotationName = 'MyAnno';
@@ -61,7 +61,7 @@ describe('MSSQLDatasource', function() {
       });
     });
 
-    it('should return annotation list', function() {
+    it('should return annotation list', () => {
       expect(results.length).toBe(3);
 
       expect(results[0].text).toBe('some text');
@@ -75,7 +75,7 @@ describe('MSSQLDatasource', function() {
     });
   });
 
-  describe('When performing metricFindQuery', function() {
+  describe('When performing metricFindQuery', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -95,24 +95,24 @@ describe('MSSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
+    beforeEach(() => {
+      ctx.backendSrv.datasourceRequest = options => {
         return ctx.$q.when({ data: response, status: 200 });
       };
 
-      return ctx.ds.metricFindQuery(query).then(function(data) {
+      return ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of all column values', function() {
+    it('should return list of all column values', () => {
       expect(results.length).toBe(6);
       expect(results[0].text).toBe('aTitle');
       expect(results[5].text).toBe('some text3');
     });
   });
 
-  describe('When performing metricFindQuery with key, value columns', function() {
+  describe('When performing metricFindQuery with key, value columns', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -132,17 +132,17 @@ describe('MSSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
+    beforeEach(() => {
+      ctx.backendSrv.datasourceRequest = options => {
         return ctx.$q.when({ data: response, status: 200 });
       };
 
-      return ctx.ds.metricFindQuery(query).then(function(data) {
+      return ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of as text, value', function() {
+    it('should return list of as text, value', () => {
       expect(results.length).toBe(3);
       expect(results[0].text).toBe('aTitle');
       expect(results[0].value).toBe('value1');
@@ -151,7 +151,7 @@ describe('MSSQLDatasource', function() {
     });
   });
 
-  describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() {
+  describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -171,17 +171,17 @@ describe('MSSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
+    beforeEach(() => {
+      ctx.backendSrv.datasourceRequest = options => {
         return ctx.$q.when({ data: response, status: 200 });
       };
 
-      return ctx.ds.metricFindQuery(query).then(function(data) {
+      return ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of unique keys', function() {
+    it('should return list of unique keys', () => {
       expect(results.length).toBe(1);
       expect(results[0].text).toBe('aTitle');
       expect(results[0].value).toBe('same');
@@ -189,7 +189,7 @@ describe('MSSQLDatasource', function() {
   });
 
   describe('When interpolating variables', () => {
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.variable = new CustomVariable({}, {});
     });
 

+ 18 - 18
public/app/plugins/datasource/mysql/specs/datasource.test.ts

@@ -2,7 +2,7 @@ import moment from 'moment';
 import { MysqlDatasource } from '../datasource';
 import { CustomVariable } from 'app/features/templating/custom_variable';
 
-describe('MySQLDatasource', function() {
+describe('MySQLDatasource', () => {
   const instanceSettings = { name: 'mysql' };
   const backendSrv = {};
   const templateSrv = {
@@ -17,7 +17,7 @@ describe('MySQLDatasource', function() {
     ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv);
   });
 
-  describe('When performing annotationQuery', function() {
+  describe('When performing annotationQuery', () => {
     let results;
 
     const annotationName = 'MyAnno';
@@ -51,16 +51,16 @@ describe('MySQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.annotationQuery(options).then(function(data) {
+      ctx.ds.annotationQuery(options).then(data => {
         results = data;
       });
     });
 
-    it('should return annotation list', function() {
+    it('should return annotation list', () => {
       expect(results.length).toBe(3);
 
       expect(results[0].text).toBe('some text');
@@ -74,7 +74,7 @@ describe('MySQLDatasource', function() {
     });
   });
 
-  describe('When performing metricFindQuery', function() {
+  describe('When performing metricFindQuery', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -94,23 +94,23 @@ describe('MySQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.metricFindQuery(query).then(function(data) {
+      ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of all column values', function() {
+    it('should return list of all column values', () => {
       expect(results.length).toBe(6);
       expect(results[0].text).toBe('aTitle');
       expect(results[5].text).toBe('some text3');
     });
   });
 
-  describe('When performing metricFindQuery with key, value columns', function() {
+  describe('When performing metricFindQuery with key, value columns', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -130,16 +130,16 @@ describe('MySQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.metricFindQuery(query).then(function(data) {
+      ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of as text, value', function() {
+    it('should return list of as text, value', () => {
       expect(results.length).toBe(3);
       expect(results[0].text).toBe('aTitle');
       expect(results[0].value).toBe('value1');
@@ -148,7 +148,7 @@ describe('MySQLDatasource', function() {
     });
   });
 
-  describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() {
+  describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -168,16 +168,16 @@ describe('MySQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.metricFindQuery(query).then(function(data) {
+      ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of unique keys', function() {
+    it('should return list of unique keys', () => {
       expect(results.length).toBe(1);
       expect(results[0].text).toBe('aTitle');
       expect(results[0].value).toBe('same');
@@ -185,7 +185,7 @@ describe('MySQLDatasource', function() {
   });
 
   describe('When interpolating variables', () => {
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.variable = new CustomVariable({}, {});
     });
 

+ 63 - 0
public/app/plugins/datasource/postgres/config_ctrl.ts

@@ -0,0 +1,63 @@
+import _ from 'lodash';
+
+export class PostgresConfigCtrl {
+  static templateUrl = 'partials/config.html';
+
+  current: any;
+  datasourceSrv: any;
+  showTimescaleDBHelp: boolean;
+
+  /** @ngInject */
+  constructor($scope, datasourceSrv) {
+    this.datasourceSrv = datasourceSrv;
+    this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full';
+    this.current.jsonData.postgresVersion = this.current.jsonData.postgresVersion || 903;
+    this.showTimescaleDBHelp = false;
+    this.autoDetectFeatures();
+  }
+
+  autoDetectFeatures() {
+    if (!this.current.id) {
+      return;
+    }
+
+    this.datasourceSrv.loadDatasource(this.current.name).then(ds => {
+      return ds.getVersion().then(version => {
+        version = Number(version[0].text);
+
+        // timescaledb is only available for 9.6+
+        if (version >= 906) {
+          ds.getTimescaleDBVersion().then(version => {
+            if (version.length === 1) {
+              this.current.jsonData.timescaledb = true;
+            }
+          });
+        }
+
+        const major = Math.trunc(version / 100);
+        const minor = version % 100;
+        let name = String(major);
+        if (version < 1000) {
+          name = String(major) + '.' + String(minor);
+        }
+        if (!_.find(this.postgresVersions, (p: any) => p.value === version)) {
+          this.postgresVersions.push({ name: name, value: version });
+        }
+        this.current.jsonData.postgresVersion = version;
+      });
+    });
+  }
+
+  toggleTimescaleDBHelp() {
+    this.showTimescaleDBHelp = !this.showTimescaleDBHelp;
+  }
+
+  // the value portion is derived from postgres server_version_num/100
+  postgresVersions = [
+    { name: '9.3', value: 903 },
+    { name: '9.4', value: 904 },
+    { name: '9.5', value: 905 },
+    { name: '9.6', value: 906 },
+    { name: '10', value: 1000 },
+  ];
+}

+ 28 - 17
public/app/plugins/datasource/postgres/datasource.ts

@@ -1,22 +1,27 @@
 import _ from 'lodash';
 import ResponseParser from './response_parser';
+import PostgresQuery from 'app/plugins/datasource/postgres/postgres_query';
 
 export class PostgresDatasource {
   id: any;
   name: any;
+  jsonData: any;
   responseParser: ResponseParser;
+  queryModel: PostgresQuery;
 
   /** @ngInject */
-  constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
+  constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) {
     this.name = instanceSettings.name;
     this.id = instanceSettings.id;
+    this.jsonData = instanceSettings.jsonData;
     this.responseParser = new ResponseParser(this.$q);
+    this.queryModel = new PostgresQuery({});
   }
 
   interpolateVariable(value, variable) {
     if (typeof value === 'string') {
       if (variable.multi || variable.includeAll) {
-        return "'" + value.replace(/'/g, `''`) + "'";
+        return this.queryModel.quoteLiteral(value);
       } else {
         return value;
       }
@@ -26,23 +31,25 @@ export class PostgresDatasource {
       return value;
     }
 
-    const quotedValues = _.map(value, function(val) {
-      return "'" + val.replace(/'/g, `''`) + "'";
+    const quotedValues = _.map(value, v => {
+      return this.queryModel.quoteLiteral(v);
     });
     return quotedValues.join(',');
   }
 
   query(options) {
-    const queries = _.filter(options.targets, item => {
-      return item.hide !== true;
-    }).map(item => {
+    const queries = _.filter(options.targets, target => {
+      return target.hide !== true;
+    }).map(target => {
+      const queryModel = new PostgresQuery(target, this.templateSrv, options.scopedVars);
+
       return {
-        refId: item.refId,
+        refId: target.refId,
         intervalMs: options.intervalMs,
         maxDataPoints: options.maxDataPoints,
         datasourceId: this.id,
-        rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable),
-        format: item.format,
+        rawSql: queryModel.render(this.interpolateVariable),
+        format: target.format,
       };
     });
 
@@ -103,17 +110,13 @@ export class PostgresDatasource {
       format: 'table',
     };
 
+    const range = this.timeSrv.timeRange();
     const data = {
       queries: [interpolatedQuery],
+      from: range.from.valueOf().toString(),
+      to: range.to.valueOf().toString(),
     };
 
-    if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
-      data['from'] = optionalOptions.range.from.valueOf().toString();
-    }
-    if (optionalOptions && optionalOptions.range && optionalOptions.range.to) {
-      data['to'] = optionalOptions.range.to.valueOf().toString();
-    }
-
     return this.backendSrv
       .datasourceRequest({
         url: '/api/tsdb/query',
@@ -123,6 +126,14 @@ export class PostgresDatasource {
       .then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
   }
 
+  getVersion() {
+    return this.metricFindQuery("SELECT current_setting('server_version_num')::int/100", {});
+  }
+
+  getTimescaleDBVersion() {
+    return this.metricFindQuery("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'", {});
+  }
+
   testDatasource() {
     return this.metricFindQuery('SELECT 1', {})
       .then(res => {

+ 176 - 0
public/app/plugins/datasource/postgres/meta_query.ts

@@ -0,0 +1,176 @@
+export class PostgresMetaQuery {
+  constructor(private target, private queryModel) {}
+
+  getOperators(datatype: string) {
+    switch (datatype) {
+      case 'float4':
+      case 'float8': {
+        return ['=', '!=', '<', '<=', '>', '>='];
+      }
+      case 'text':
+      case 'varchar':
+      case 'char': {
+        return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '~', '~*', '!~', '!~*'];
+      }
+      default: {
+        return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN'];
+      }
+    }
+  }
+
+  // quote identifier as literal to use in metadata queries
+  quoteIdentAsLiteral(value) {
+    return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value));
+  }
+
+  findMetricTable() {
+    // query that returns first table found that has a timestamp(tz) column and a float column
+    const query = `
+SELECT
+	quote_ident(table_name) as table_name,
+	( SELECT
+	    quote_ident(column_name) as column_name
+	  FROM information_schema.columns c
+    WHERE
+      c.table_schema = t.table_schema AND
+      c.table_name = t.table_name AND
+      udt_name IN ('timestamptz','timestamp')
+    ORDER BY ordinal_position LIMIT 1
+  ) AS time_column,
+  ( SELECT
+      quote_ident(column_name) AS column_name
+    FROM information_schema.columns c
+    WHERE
+      c.table_schema = t.table_schema AND
+      c.table_name = t.table_name AND
+      udt_name='float8'
+    ORDER BY ordinal_position LIMIT 1
+  ) AS value_column
+FROM information_schema.tables t
+WHERE
+  table_schema IN (
+		SELECT CASE WHEN trim(unnest) = '"$user"' THEN user ELSE trim(unnest) END
+    FROM unnest(string_to_array(current_setting('search_path'),','))
+  ) AND
+  EXISTS
+  ( SELECT 1
+    FROM information_schema.columns c
+    WHERE
+      c.table_schema = t.table_schema AND
+      c.table_name = t.table_name AND
+      udt_name IN ('timestamptz','timestamp')
+  ) AND
+  EXISTS
+  ( SELECT 1
+    FROM information_schema.columns c
+    WHERE
+      c.table_schema = t.table_schema AND
+      c.table_name = t.table_name AND
+      udt_name='float8'
+  )
+LIMIT 1
+;`;
+    return query;
+  }
+
+  buildSchemaConstraint() {
+    const query = `
+table_schema IN (
+	SELECT CASE WHEN trim(unnest) = \'"$user"\' THEN user ELSE trim(unnest) END
+  FROM unnest(string_to_array(current_setting(\'search_path\'),\',\'))
+)`;
+    return query;
+  }
+
+  buildTableConstraint(table: string) {
+    let query = '';
+
+    // check for schema qualified table
+    if (table.includes('.')) {
+      const parts = table.split('.');
+      query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]);
+      query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]);
+      return query;
+    } else {
+      query = `
+table_schema IN (
+	SELECT CASE WHEN trim(unnest) = \'"$user"\' THEN user ELSE trim(unnest) END
+  FROM unnest(string_to_array(current_setting(\'search_path\'),\',\'))
+)`;
+      query += ' AND table_name = ' + this.quoteIdentAsLiteral(table);
+
+      return query;
+    }
+  }
+
+  buildTableQuery() {
+    let query = 'SELECT quote_ident(table_name) FROM information_schema.tables WHERE ';
+    query += this.buildSchemaConstraint();
+    query += ' ORDER BY table_name';
+    return query;
+  }
+
+  buildColumnQuery(type?: string) {
+    let query = 'SELECT quote_ident(column_name) FROM information_schema.columns WHERE ';
+    query += this.buildTableConstraint(this.target.table);
+
+    switch (type) {
+      case 'time': {
+        query +=
+          " AND data_type IN ('timestamp without time zone','timestamp with time zone','bigint','integer','double precision','real')";
+        break;
+      }
+      case 'metric': {
+        query += " AND data_type IN ('text','character','character varying')";
+        break;
+      }
+      case 'value': {
+        query += " AND data_type IN ('bigint','integer','double precision','real')";
+        query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn);
+        break;
+      }
+      case 'group': {
+        query += " AND data_type IN ('text','character','character varying')";
+        break;
+      }
+    }
+
+    query += ' ORDER BY column_name';
+
+    return query;
+  }
+
+  buildValueQuery(column: string) {
+    let query = 'SELECT DISTINCT quote_literal(' + column + ')';
+    query += ' FROM ' + this.target.table;
+    query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')';
+    query += ' ORDER BY 1 LIMIT 100';
+    return query;
+  }
+
+  buildDatatypeQuery(column: string) {
+    let query = `
+SELECT udt_name
+FROM information_schema.columns
+WHERE
+  table_schema IN (
+  SELECT schema FROM (
+		  SELECT CASE WHEN trim(unnest) = \'"$user"\' THEN user ELSE trim(unnest) END as schema
+      FROM unnest(string_to_array(current_setting(\'search_path\'),\',\'))
+    ) s
+    WHERE EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = s.schema)
+  )
+`;
+    query += ' AND table_name = ' + this.quoteIdentAsLiteral(this.target.table);
+    query += ' AND column_name = ' + this.quoteIdentAsLiteral(column);
+    return query;
+  }
+
+  buildAggregateQuery() {
+    let query = 'SELECT DISTINCT proname FROM pg_aggregate ';
+    query += 'INNER JOIN pg_proc ON pg_aggregate.aggfnoid = pg_proc.oid ';
+    query += 'INNER JOIN pg_type ON pg_type.oid=pg_proc.prorettype ';
+    query += "WHERE pronargs=1 AND typname IN ('float8') AND aggkind='n' ORDER BY 1";
+    return query;
+  }
+}

+ 1 - 11
public/app/plugins/datasource/postgres/module.ts

@@ -1,16 +1,6 @@
 import { PostgresDatasource } from './datasource';
 import { PostgresQueryCtrl } from './query_ctrl';
-
-class PostgresConfigCtrl {
-  static templateUrl = 'partials/config.html';
-
-  current: any;
-
-  /** @ngInject */
-  constructor($scope) {
-    this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full';
-  }
-}
+import { PostgresConfigCtrl } from './config_ctrl';
 
 const defaultQuery = `SELECT
   extract(epoch from time_column) AS time,

+ 27 - 1
public/app/plugins/datasource/postgres/partials/config.html

@@ -42,10 +42,36 @@
 
 <div class="gf-form-group">
 	<div class="gf-form">
-		<gf-form-switch class="gf-form" label="TimescaleDB" tooltip="Use TimescaleDB features (e.g., time_bucket) in Grafana" label-class="width-9" checked="ctrl.current.jsonData.timescaledb" switch-class="max-width-6"></gf-form-switch>
+		<span class="gf-form-label width-9">
+			Version
+			<info-popover mode="right-normal" position="top center">
+				This option controls what functions are available in the PostgreSQL query builder.
+			</info-popover>
+		</span>
+		<span class="gf-form-select-wrapper">
+			<select class="gf-form-input gf-size-auto" ng-model="ctrl.current.jsonData.postgresVersion" ng-options="f.value as f.name for f in ctrl.postgresVersions"></select>
+		</span>
+	</div>
+	<div class="gf-form">
+		<gf-form-switch class="gf-form" label="TimescaleDB" label-class="width-9" checked="ctrl.current.jsonData.timescaledb" switch-class="max-width-6"></gf-form-switch>
+    <label class="gf-form-label query-keyword pointer" ng-click="ctrl.toggleTimescaleDBHelp()">
+      Help&nbsp;
+      <i class="fa fa-caret-down" ng-show="ctrl.showTimescaleDBHelp"></i>
+      <i class="fa fa-caret-right" ng-hide="ctrl.showTimescaleDBHelp">&nbsp;</i>
+    </label>
 	</div>
+
+<div class="grafana-info-box alert alert-info" ng-show="ctrl.showTimescaleDBHelp">
+  <div class="alert-body">
+    <p>
+			<a href="https://github.com/timescale/timescaledb" class="pointer" target="_blank">TimescaleDB</a> is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use <code>time_bucket</code> in the <code>$__timeGroup</code> macro and display TimescaleDB specific aggregate functions in the query builder.
+    </p>
+  </div>
 </div>
 
+</div>
+
+
 <div class="gf-form-group">
 	<div class="grafana-info-box">
 		<h5>User Permission</h5>

+ 133 - 35
public/app/plugins/datasource/postgres/partials/query.editor.html

@@ -1,43 +1,141 @@
-<query-editor-row query-ctrl="ctrl" can-collapse="false">
-	<div class="gf-form-inline">
-		<div class="gf-form gf-form--grow">
-			<code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
-			</code-editor>
-		</div>
-	</div>
+<query-editor-row query-ctrl="ctrl" has-text-edit-mode="true">
+
+  <div ng-if="ctrl.target.rawQuery">
+    <div class="gf-form-inline">
+      <div class="gf-form gf-form--grow">
+        <code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
+        </code-editor>
+      </div>
+    </div>
+  </div>
+
+  <div ng-if="!ctrl.target.rawQuery">
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label query-keyword width-6">FROM</label>
+        <metric-segment segment="ctrl.tableSegment" get-options="ctrl.getTableSegments()" on-change="ctrl.tableChanged()"></metric-segment>
+
+        <label class="gf-form-label query-keyword width-7">Time column</label>
+        <metric-segment segment="ctrl.timeColumnSegment" get-options="ctrl.getTimeColumnSegments()" on-change="ctrl.timeColumnChanged()"></metric-segment>
+
+        <label class="gf-form-label query-keyword width-9">
+          Metric column
+          <info-popover mode="right-normal">Column to be used as metric name for the value column.</info-popover>
+        </label>
+        <metric-segment segment="ctrl.metricColumnSegment" get-options="ctrl.getMetricColumnSegments()" on-change="ctrl.metricColumnChanged()"></metric-segment>
+      </div>
+
+      <div class="gf-form gf-form--grow">
+        <div class="gf-form-label gf-form-label--grow"></div>
+      </div>
+
+    </div>
+
+    <div class="gf-form-inline" ng-repeat="selectParts in ctrl.selectParts">
+      <div class="gf-form">
+        <label class="gf-form-label query-keyword width-6">
+          <span ng-show="$index === 0">SELECT</span>&nbsp;
+        </label>
+      </div>
+
+      <div class="gf-form" ng-repeat="part in selectParts">
+        <sql-part-editor class="gf-form-label sql-part" part="part" handle-event="ctrl.handleSelectPartEvent(selectParts, part, $event)">
+        </sql-part-editor>
+      </div>
+
+      <div class="gf-form">
+        <label class="dropdown"
+                dropdown-typeahead="ctrl.selectMenu"
+                dropdown-typeahead-on-select="ctrl.addSelectPart(selectParts, $item, $subItem)">
+        </label>
+      </div>
+
+      <div class="gf-form gf-form--grow">
+        <div class="gf-form-label gf-form-label--grow"></div>
+      </div>
+    </div>
+
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label query-keyword width-6">WHERE</label>
+      </div>
+
+      <div class="gf-form" ng-repeat="part in ctrl.whereParts">
+        <sql-part-editor class="gf-form-label sql-part" part="part" handle-event="ctrl.handleWherePartEvent(ctrl.whereParts, part, $event, $index)">
+        </sql-part-editor>
+      </div>
+
+      <div class="gf-form">
+        <metric-segment segment="ctrl.whereAdd" get-options="ctrl.getWhereOptions()" on-change="ctrl.addWhereAction(part, $index)"></metric-segment>
+      </div>
+
+      <div class="gf-form gf-form--grow">
+        <div class="gf-form-label gf-form-label--grow"></div>
+      </div>
+
+    </div>
+
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label query-keyword width-6">
+          <span>GROUP BY</span>
+        </label>
+
+        <sql-part-editor  ng-repeat="part in ctrl.groupParts"
+                            part="part" class="gf-form-label sql-part"
+                            handle-event="ctrl.handleGroupPartEvent(part, $index, $event)">
+        </sql-part-editor>
+      </div>
+
+      <div class="gf-form">
+        <metric-segment segment="ctrl.groupAdd" get-options="ctrl.getGroupOptions()" on-change="ctrl.addGroupAction(part, $index)"></metric-segment>
+      </div>
+
+      <div class="gf-form gf-form--grow">
+        <div class="gf-form-label gf-form-label--grow"></div>
+      </div>
+    </div>
+
+  </div>
 
   <div class="gf-form-inline">
     <div class="gf-form">
-			<label class="gf-form-label query-keyword">Format as</label>
-			<div class="gf-form-select-wrapper">
-				<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
-			</div>
-		</div>
-		<div class="gf-form">
-      <label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
+      <label class="gf-form-label query-keyword">Format as</label>
+      <div class="gf-form-select-wrapper">
+        <select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
+      </div>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label query-keyword pointer" ng-click="ctrl.toggleEditorMode()" ng-show="ctrl.panelCtrl.panel.type !== 'table'">
+        <span ng-show="ctrl.target.rawQuery">Query Builder</span>
+        <span ng-hide="ctrl.target.rawQuery">Edit SQL</span>
+      </label>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label query-keyword pointer" ng-click="ctrl.showHelp = !ctrl.showHelp">
         Show Help
         <i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
         <i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
       </label>
-		</div>
-		<div class="gf-form" ng-show="ctrl.lastQueryMeta">
-      <label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
+    </div>
+    <div class="gf-form" ng-show="ctrl.lastQueryMeta">
+      <label class="gf-form-label query-keyword pointer" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
         Generated SQL
         <i class="fa fa-caret-down" ng-show="ctrl.showLastQuerySQL"></i>
         <i class="fa fa-caret-right" ng-hide="ctrl.showLastQuerySQL"></i>
       </label>
-		</div>
-		<div class="gf-form gf-form--grow">
-			<div class="gf-form-label gf-form-label--grow"></div>
-		</div>
-	</div>
-
-	<div class="gf-form" ng-show="ctrl.showLastQuerySQL">
-		<pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
-	</div>
-
-	<div class="gf-form"  ng-show="ctrl.showHelp">
-		<pre class="gf-form-pre alert alert-info">Time series:
+    </div>
+    <div class="gf-form gf-form--grow">
+      <div class="gf-form-label gf-form-label--grow"></div>
+    </div>
+  </div>
+
+  <div class="gf-form" ng-show="ctrl.showLastQuerySQL">
+    <pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
+  </div>
+
+  <div class="gf-form"  ng-show="ctrl.showHelp">
+    <pre class="gf-form-pre alert alert-info">Time series:
 - return column named <i>time</i> (UTC in seconds or timestamp)
 - return column(s) with numeric datatype as values
 Optional: 
@@ -73,13 +171,13 @@ Or build your own conditionals using these macros which just return the values:
 - $__timeTo() -&gt;  '2017-04-21T05:01:17Z'
 - $__unixEpochFrom() -&gt;  1492750877
 - $__unixEpochTo() -&gt;  1492750877
-		</pre>
-	</div>
+    </pre>
+  </div>
 
-	</div>
+  </div>
 
-	<div class="gf-form" ng-show="ctrl.lastQueryError">
-		<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
-	</div>
+  <div class="gf-form" ng-show="ctrl.lastQueryError">
+    <pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
+  </div>
 
 </query-editor-row>

+ 1 - 0
public/app/plugins/datasource/postgres/plugin.json

@@ -19,4 +19,5 @@
   "alerting": true,
   "annotations": true,
   "metrics": true
+
 }

+ 285 - 0
public/app/plugins/datasource/postgres/postgres_query.ts

@@ -0,0 +1,285 @@
+import _ from 'lodash';
+
+export default class PostgresQuery {
+  target: any;
+  templateSrv: any;
+  scopedVars: any;
+
+  /** @ngInject */
+  constructor(target, templateSrv?, scopedVars?) {
+    this.target = target;
+    this.templateSrv = templateSrv;
+    this.scopedVars = scopedVars;
+
+    target.format = target.format || 'time_series';
+    target.timeColumn = target.timeColumn || 'time';
+    target.metricColumn = target.metricColumn || 'none';
+
+    target.group = target.group || [];
+    target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }];
+    target.select = target.select || [[{ type: 'column', params: ['value'] }]];
+
+    // handle pre query gui panels gracefully
+    if (!('rawQuery' in this.target)) {
+      if ('rawSql' in target) {
+        // pre query gui panel
+        target.rawQuery = true;
+      } else {
+        // new panel
+        target.rawQuery = false;
+      }
+    }
+
+    // give interpolateQueryStr access to this
+    this.interpolateQueryStr = this.interpolateQueryStr.bind(this);
+  }
+
+  // remove identifier quoting from identifier to use in metadata queries
+  unquoteIdentifier(value) {
+    if (value[0] === '"' && value[value.length - 1] === '"') {
+      return value.substring(1, value.length - 1).replace(/""/g, '"');
+    } else {
+      return value;
+    }
+  }
+
+  quoteIdentifier(value) {
+    return '"' + value.replace(/"/g, '""') + '"';
+  }
+
+  quoteLiteral(value) {
+    return "'" + value.replace(/'/g, "''") + "'";
+  }
+
+  escapeLiteral(value) {
+    return value.replace(/'/g, "''");
+  }
+
+  hasTimeGroup() {
+    return _.find(this.target.group, (g: any) => g.type === 'time');
+  }
+
+  hasMetricColumn() {
+    return this.target.metricColumn !== 'none';
+  }
+
+  interpolateQueryStr(value, variable, defaultFormatFn) {
+    // if no multi or include all do not regexEscape
+    if (!variable.multi && !variable.includeAll) {
+      return this.escapeLiteral(value);
+    }
+
+    if (typeof value === 'string') {
+      return this.quoteLiteral(value);
+    }
+
+    const escapedValues = _.map(value, this.quoteLiteral);
+    return escapedValues.join(',');
+  }
+
+  render(interpolate?) {
+    const target = this.target;
+
+    // new query with no table set yet
+    if (!this.target.rawQuery && !('table' in this.target)) {
+      return '';
+    }
+
+    if (!target.rawQuery) {
+      target.rawSql = this.buildQuery();
+    }
+
+    if (interpolate) {
+      return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr);
+    } else {
+      return target.rawSql;
+    }
+  }
+
+  hasUnixEpochTimecolumn() {
+    return ['int4', 'int8', 'float4', 'float8', 'numeric'].indexOf(this.target.timeColumnType) > -1;
+  }
+
+  buildTimeColumn(alias = true) {
+    const timeGroup = this.hasTimeGroup();
+    let query;
+    let macro = '$__timeGroup';
+
+    if (timeGroup) {
+      let args;
+      if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') {
+        args = timeGroup.params.join(',');
+      } else {
+        args = timeGroup.params[0];
+      }
+      if (this.hasUnixEpochTimecolumn()) {
+        macro = '$__unixEpochGroup';
+      }
+      if (alias) {
+        macro += 'Alias';
+      }
+      query = macro + '(' + this.target.timeColumn + ',' + args + ')';
+    } else {
+      query = this.target.timeColumn;
+      if (alias) {
+        query += ' AS "time"';
+      }
+    }
+
+    return query;
+  }
+
+  buildMetricColumn() {
+    if (this.hasMetricColumn()) {
+      return this.target.metricColumn + ' AS metric';
+    }
+
+    return '';
+  }
+
+  buildValueColumns() {
+    let query = '';
+    for (const column of this.target.select) {
+      query += ',\n  ' + this.buildValueColumn(column);
+    }
+
+    return query;
+  }
+
+  buildValueColumn(column) {
+    let query = '';
+
+    const columnName = _.find(column, (g: any) => g.type === 'column');
+    query = columnName.params[0];
+
+    const aggregate = _.find(column, (g: any) => g.type === 'aggregate' || g.type === 'percentile');
+    const windows = _.find(column, (g: any) => g.type === 'window' || g.type === 'moving_window');
+
+    if (aggregate) {
+      const func = aggregate.params[0];
+      switch (aggregate.type) {
+        case 'aggregate':
+          if (func === 'first' || func === 'last') {
+            query = func + '(' + query + ',' + this.target.timeColumn + ')';
+          } else {
+            query = func + '(' + query + ')';
+          }
+          break;
+        case 'percentile':
+          query = func + '(' + aggregate.params[1] + ') WITHIN GROUP (ORDER BY ' + query + ')';
+          break;
+      }
+    }
+
+    if (windows) {
+      const overParts = [];
+      if (this.hasMetricColumn()) {
+        overParts.push('PARTITION BY ' + this.target.metricColumn);
+      }
+      overParts.push('ORDER BY ' + this.buildTimeColumn(false));
+
+      const over = overParts.join(' ');
+      let curr: string;
+      let prev: string;
+      switch (windows.type) {
+        case 'window':
+          switch (windows.params[0]) {
+            case 'increase':
+              curr = query;
+              prev = 'lag(' + curr + ') OVER (' + over + ')';
+              query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev + ' ELSE ' + curr + ' END)';
+              break;
+            case 'rate':
+              let timeColumn = this.target.timeColumn;
+              if (aggregate) {
+                timeColumn = 'min(' + timeColumn + ')';
+              }
+
+              curr = query;
+              prev = 'lag(' + curr + ') OVER (' + over + ')';
+              query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev + ' ELSE ' + curr + ' END)';
+              query += '/extract(epoch from ' + timeColumn + ' - lag(' + timeColumn + ') OVER (' + over + '))';
+              break;
+            default:
+              query = windows.params[0] + '(' + query + ') OVER (' + over + ')';
+              break;
+          }
+          break;
+        case 'moving_window':
+          query = windows.params[0] + '(' + query + ') OVER (' + over + ' ROWS ' + windows.params[1] + ' PRECEDING)';
+          break;
+      }
+    }
+
+    const alias = _.find(column, (g: any) => g.type === 'alias');
+    if (alias) {
+      query += ' AS ' + this.quoteIdentifier(alias.params[0]);
+    }
+
+    return query;
+  }
+
+  buildWhereClause() {
+    let query = '';
+    const conditions = _.map(this.target.where, (tag, index) => {
+      switch (tag.type) {
+        case 'macro':
+          return tag.name + '(' + this.target.timeColumn + ')';
+          break;
+        case 'expression':
+          return tag.params.join(' ');
+          break;
+      }
+    });
+
+    if (conditions.length > 0) {
+      query = '\nWHERE\n  ' + conditions.join(' AND\n  ');
+    }
+
+    return query;
+  }
+
+  buildGroupClause() {
+    let query = '';
+    let groupSection = '';
+
+    for (let i = 0; i < this.target.group.length; i++) {
+      const part = this.target.group[i];
+      if (i > 0) {
+        groupSection += ', ';
+      }
+      if (part.type === 'time') {
+        groupSection += '1';
+      } else {
+        groupSection += part.params[0];
+      }
+    }
+
+    if (groupSection.length) {
+      query = '\nGROUP BY ' + groupSection;
+      if (this.hasMetricColumn()) {
+        query += ',2';
+      }
+    }
+    return query;
+  }
+
+  buildQuery() {
+    let query = 'SELECT';
+
+    query += '\n  ' + this.buildTimeColumn();
+    if (this.hasMetricColumn()) {
+      query += ',\n  ' + this.buildMetricColumn();
+    }
+    query += this.buildValueColumns();
+
+    query += '\nFROM ' + this.target.table;
+
+    query += this.buildWhereClause();
+    query += this.buildGroupClause();
+
+    query += '\nORDER BY 1';
+
+    return query;
+  }
+}

+ 589 - 11
public/app/plugins/datasource/postgres/query_ctrl.ts

@@ -1,12 +1,10 @@
 import _ from 'lodash';
+import appEvents from 'app/core/app_events';
+import { PostgresMetaQuery } from './meta_query';
 import { QueryCtrl } from 'app/plugins/sdk';
-
-export interface PostgresQuery {
-  refId: string;
-  format: string;
-  alias: string;
-  rawSql: string;
-}
+import { SqlPart } from 'app/core/components/sql_part/sql_part';
+import PostgresQuery from './postgres_query';
+import sqlPart from './sql_part';
 
 export interface QueryMeta {
   sql: string;
@@ -26,17 +24,29 @@ export class PostgresQueryCtrl extends QueryCtrl {
 
   showLastQuerySQL: boolean;
   formats: any[];
-  target: PostgresQuery;
+  queryModel: PostgresQuery;
+  metaBuilder: PostgresMetaQuery;
   lastQueryMeta: QueryMeta;
   lastQueryError: string;
   showHelp: boolean;
+  tableSegment: any;
+  whereAdd: any;
+  timeColumnSegment: any;
+  metricColumnSegment: any;
+  selectMenu: any[];
+  selectParts: SqlPart[][];
+  groupParts: SqlPart[];
+  whereParts: SqlPart[];
+  groupAdd: any;
 
   /** @ngInject */
-  constructor($scope, $injector) {
+  constructor($scope, $injector, private templateSrv, private $q, private uiSegmentSrv) {
     super($scope, $injector);
+    this.target = this.target;
+    this.queryModel = new PostgresQuery(this.target, templateSrv, this.panel.scopedVars);
+    this.metaBuilder = new PostgresMetaQuery(this.target, this.queryModel);
+    this.updateProjection();
 
-    this.target.format = this.target.format || 'time_series';
-    this.target.alias = '';
     this.formats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
 
     if (!this.target.rawSql) {
@@ -44,15 +54,231 @@ export class PostgresQueryCtrl extends QueryCtrl {
       if (this.panelCtrl.panel.type === 'table') {
         this.target.format = 'table';
         this.target.rawSql = 'SELECT 1';
+        this.target.rawQuery = true;
       } else {
         this.target.rawSql = defaultQuery;
+        this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then(result => {
+          if (result.length > 0) {
+            this.target.table = result[0].text;
+            let segment = this.uiSegmentSrv.newSegment(this.target.table);
+            this.tableSegment.html = segment.html;
+            this.tableSegment.value = segment.value;
+
+            this.target.timeColumn = result[1].text;
+            segment = this.uiSegmentSrv.newSegment(this.target.timeColumn);
+            this.timeColumnSegment.html = segment.html;
+            this.timeColumnSegment.value = segment.value;
+
+            this.target.timeColumnType = 'timestamp';
+            this.target.select = [[{ type: 'column', params: [result[2].text] }]];
+            this.updateProjection();
+            this.panelCtrl.refresh();
+          }
+        });
       }
     }
 
+    if (!this.target.table) {
+      this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true });
+    } else {
+      this.tableSegment = uiSegmentSrv.newSegment(this.target.table);
+    }
+
+    this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn);
+    this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn);
+
+    this.buildSelectMenu();
+    this.whereAdd = this.uiSegmentSrv.newPlusButton();
+    this.groupAdd = this.uiSegmentSrv.newPlusButton();
+
     this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
     this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
   }
 
+  updateProjection() {
+    this.selectParts = _.map(this.target.select, function(parts: any) {
+      return _.map(parts, sqlPart.create).filter(n => n);
+    });
+    this.whereParts = _.map(this.target.where, sqlPart.create).filter(n => n);
+    this.groupParts = _.map(this.target.group, sqlPart.create).filter(n => n);
+  }
+
+  updatePersistedParts() {
+    this.target.select = _.map(this.selectParts, function(selectParts) {
+      return _.map(selectParts, function(part: any) {
+        return { type: part.def.type, datatype: part.datatype, params: part.params };
+      });
+    });
+    this.target.where = _.map(this.whereParts, function(part: any) {
+      return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params };
+    });
+    this.target.group = _.map(this.groupParts, function(part: any) {
+      return { type: part.def.type, datatype: part.datatype, params: part.params };
+    });
+  }
+
+  buildSelectMenu() {
+    this.selectMenu = [];
+    const aggregates = {
+      text: 'Aggregate Functions',
+      value: 'aggregate',
+      submenu: [
+        { text: 'Average', value: 'avg' },
+        { text: 'Count', value: 'count' },
+        { text: 'Maximum', value: 'max' },
+        { text: 'Minimum', value: 'min' },
+        { text: 'Sum', value: 'sum' },
+        { text: 'Standard deviation', value: 'stddev' },
+        { text: 'Variance', value: 'variance' },
+      ],
+    };
+
+    // first and last aggregate are timescaledb specific
+    if (this.datasource.jsonData.timescaledb === true) {
+      aggregates.submenu.push({ text: 'First', value: 'first' });
+      aggregates.submenu.push({ text: 'Last', value: 'last' });
+    }
+
+    this.selectMenu.push(aggregates);
+
+    // ordered set aggregates require postgres 9.4+
+    if (this.datasource.jsonData.postgresVersion >= 904) {
+      const aggregates2 = {
+        text: 'Ordered-Set Aggregate Functions',
+        value: 'percentile',
+        submenu: [
+          { text: 'Percentile (continuous)', value: 'percentile_cont' },
+          { text: 'Percentile (discrete)', value: 'percentile_disc' },
+        ],
+      };
+      this.selectMenu.push(aggregates2);
+    }
+
+    const windows = {
+      text: 'Window Functions',
+      value: 'window',
+      submenu: [
+        { text: 'Increase', value: 'increase' },
+        { text: 'Rate', value: 'rate' },
+        { text: 'Sum', value: 'sum' },
+        { text: 'Moving Average', value: 'avg', type: 'moving_window' },
+      ],
+    };
+    this.selectMenu.push(windows);
+
+    this.selectMenu.push({ text: 'Alias', value: 'alias' });
+    this.selectMenu.push({ text: 'Column', value: 'column' });
+  }
+
+  toggleEditorMode() {
+    if (this.target.rawQuery) {
+      appEvents.emit('confirm-modal', {
+        title: 'Warning',
+        text2: 'Switching to query builder may overwrite your raw SQL.',
+        icon: 'fa-exclamation',
+        yesText: 'Switch',
+        onConfirm: () => {
+          this.target.rawQuery = !this.target.rawQuery;
+        },
+      });
+    } else {
+      this.target.rawQuery = !this.target.rawQuery;
+    }
+  }
+
+  resetPlusButton(button) {
+    const plusButton = this.uiSegmentSrv.newPlusButton();
+    button.html = plusButton.html;
+    button.value = plusButton.value;
+  }
+
+  getTableSegments() {
+    return this.datasource
+      .metricFindQuery(this.metaBuilder.buildTableQuery())
+      .then(this.transformToSegments({}))
+      .catch(this.handleQueryError.bind(this));
+  }
+
+  tableChanged() {
+    this.target.table = this.tableSegment.value;
+    this.target.where = [];
+    this.target.group = [];
+    this.updateProjection();
+
+    const segment = this.uiSegmentSrv.newSegment('none');
+    this.metricColumnSegment.html = segment.html;
+    this.metricColumnSegment.value = segment.value;
+    this.target.metricColumn = 'none';
+
+    const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then(result => {
+      // check if time column is still valid
+      if (result.length > 0 && !_.find(result, (r: any) => r.text === this.target.timeColumn)) {
+        const segment = this.uiSegmentSrv.newSegment(result[0].text);
+        this.timeColumnSegment.html = segment.html;
+        this.timeColumnSegment.value = segment.value;
+      }
+      return this.timeColumnChanged(false);
+    });
+    const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then(result => {
+      if (result.length > 0) {
+        this.target.select = [[{ type: 'column', params: [result[0].text] }]];
+        this.updateProjection();
+      }
+    });
+
+    this.$q.all([task1, task2]).then(() => {
+      this.panelCtrl.refresh();
+    });
+  }
+
+  getTimeColumnSegments() {
+    return this.datasource
+      .metricFindQuery(this.metaBuilder.buildColumnQuery('time'))
+      .then(this.transformToSegments({}))
+      .catch(this.handleQueryError.bind(this));
+  }
+
+  timeColumnChanged(refresh?: boolean) {
+    this.target.timeColumn = this.timeColumnSegment.value;
+    return this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)).then(result => {
+      if (result.length === 1) {
+        if (this.target.timeColumnType !== result[0].text) {
+          this.target.timeColumnType = result[0].text;
+        }
+        let partModel;
+        if (this.queryModel.hasUnixEpochTimecolumn()) {
+          partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] });
+        } else {
+          partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] });
+        }
+
+        if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') {
+          // replace current macro
+          this.whereParts[0] = partModel;
+        } else {
+          this.whereParts.splice(0, 0, partModel);
+        }
+      }
+
+      this.updatePersistedParts();
+      if (refresh !== false) {
+        this.panelCtrl.refresh();
+      }
+    });
+  }
+
+  getMetricColumnSegments() {
+    return this.datasource
+      .metricFindQuery(this.metaBuilder.buildColumnQuery('metric'))
+      .then(this.transformToSegments({ addNone: true }))
+      .catch(this.handleQueryError.bind(this));
+  }
+
+  metricColumnChanged() {
+    this.target.metricColumn = this.metricColumnSegment.value;
+    this.panelCtrl.refresh();
+  }
+
   onDataReceived(dataList) {
     this.lastQueryMeta = null;
     this.lastQueryError = null;
@@ -72,4 +298,356 @@ export class PostgresQueryCtrl extends QueryCtrl {
       }
     }
   }
+
+  transformToSegments(config) {
+    return results => {
+      const segments = _.map(results, segment => {
+        return this.uiSegmentSrv.newSegment({
+          value: segment.text,
+          expandable: segment.expandable,
+        });
+      });
+
+      if (config.addTemplateVars) {
+        for (const variable of this.templateSrv.variables) {
+          let value;
+          value = '$' + variable.name;
+          if (config.templateQuoter && variable.multi === false) {
+            value = config.templateQuoter(value);
+          }
+
+          segments.unshift(
+            this.uiSegmentSrv.newSegment({
+              type: 'template',
+              value: value,
+              expandable: true,
+            })
+          );
+        }
+      }
+
+      if (config.addNone) {
+        segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true }));
+      }
+
+      return segments;
+    };
+  }
+
+  findAggregateIndex(selectParts) {
+    return _.findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile');
+  }
+
+  findWindowIndex(selectParts) {
+    return _.findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window');
+  }
+
+  addSelectPart(selectParts, item, subItem) {
+    let partType = item.value;
+    if (subItem && subItem.type) {
+      partType = subItem.type;
+    }
+    let partModel = sqlPart.create({ type: partType });
+    if (subItem) {
+      partModel.params[0] = subItem.value;
+    }
+    let addAlias = false;
+
+    switch (partType) {
+      case 'column':
+        const parts = _.map(selectParts, function(part: any) {
+          return sqlPart.create({ type: part.def.type, params: _.clone(part.params) });
+        });
+        this.selectParts.push(parts);
+        break;
+      case 'percentile':
+      case 'aggregate':
+        // add group by if no group by yet
+        if (this.target.group.length === 0) {
+          this.addGroup('time', '$__interval');
+        }
+        const aggIndex = this.findAggregateIndex(selectParts);
+        if (aggIndex !== -1) {
+          // replace current aggregation
+          selectParts[aggIndex] = partModel;
+        } else {
+          selectParts.splice(1, 0, partModel);
+        }
+        if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) {
+          addAlias = true;
+        }
+        break;
+      case 'moving_window':
+      case 'window':
+        const windowIndex = this.findWindowIndex(selectParts);
+        if (windowIndex !== -1) {
+          // replace current window function
+          selectParts[windowIndex] = partModel;
+        } else {
+          const aggIndex = this.findAggregateIndex(selectParts);
+          if (aggIndex !== -1) {
+            selectParts.splice(aggIndex + 1, 0, partModel);
+          } else {
+            selectParts.splice(1, 0, partModel);
+          }
+        }
+        if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) {
+          addAlias = true;
+        }
+        break;
+      case 'alias':
+        addAlias = true;
+        break;
+    }
+
+    if (addAlias) {
+      // set initial alias name to column name
+      partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] });
+      if (selectParts[selectParts.length - 1].def.type === 'alias') {
+        selectParts[selectParts.length - 1] = partModel;
+      } else {
+        selectParts.push(partModel);
+      }
+    }
+
+    this.updatePersistedParts();
+    this.panelCtrl.refresh();
+  }
+
+  removeSelectPart(selectParts, part) {
+    if (part.def.type === 'column') {
+      // remove all parts of column unless its last column
+      if (this.selectParts.length > 1) {
+        const modelsIndex = _.indexOf(this.selectParts, selectParts);
+        this.selectParts.splice(modelsIndex, 1);
+      }
+    } else {
+      const partIndex = _.indexOf(selectParts, part);
+      selectParts.splice(partIndex, 1);
+    }
+
+    this.updatePersistedParts();
+  }
+
+  handleSelectPartEvent(selectParts, part, evt) {
+    switch (evt.name) {
+      case 'get-param-options': {
+        switch (part.def.type) {
+          case 'aggregate':
+            return this.datasource
+              .metricFindQuery(this.metaBuilder.buildAggregateQuery())
+              .then(this.transformToSegments({}))
+              .catch(this.handleQueryError.bind(this));
+          case 'column':
+            return this.datasource
+              .metricFindQuery(this.metaBuilder.buildColumnQuery('value'))
+              .then(this.transformToSegments({}))
+              .catch(this.handleQueryError.bind(this));
+        }
+      }
+      case 'part-param-changed': {
+        this.updatePersistedParts();
+        this.panelCtrl.refresh();
+        break;
+      }
+      case 'action': {
+        this.removeSelectPart(selectParts, part);
+        this.panelCtrl.refresh();
+        break;
+      }
+      case 'get-part-actions': {
+        return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
+      }
+    }
+  }
+
+  handleGroupPartEvent(part, index, evt) {
+    switch (evt.name) {
+      case 'get-param-options': {
+        return this.datasource
+          .metricFindQuery(this.metaBuilder.buildColumnQuery())
+          .then(this.transformToSegments({}))
+          .catch(this.handleQueryError.bind(this));
+      }
+      case 'part-param-changed': {
+        this.updatePersistedParts();
+        this.panelCtrl.refresh();
+        break;
+      }
+      case 'action': {
+        this.removeGroup(part, index);
+        this.panelCtrl.refresh();
+        break;
+      }
+      case 'get-part-actions': {
+        return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
+      }
+    }
+  }
+
+  addGroup(partType, value) {
+    let params = [value];
+    if (partType === 'time') {
+      params = ['$__interval', 'none'];
+    }
+    const partModel = sqlPart.create({ type: partType, params: params });
+
+    if (partType === 'time') {
+      // put timeGroup at start
+      this.groupParts.splice(0, 0, partModel);
+    } else {
+      this.groupParts.push(partModel);
+    }
+
+    // add aggregates when adding group by
+    for (const selectParts of this.selectParts) {
+      if (!selectParts.some(part => part.def.type === 'aggregate')) {
+        const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] });
+        selectParts.splice(1, 0, aggregate);
+        if (!selectParts.some(part => part.def.type === 'alias')) {
+          const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] });
+          selectParts.push(alias);
+        }
+      }
+    }
+
+    this.updatePersistedParts();
+  }
+
+  removeGroup(part, index) {
+    if (part.def.type === 'time') {
+      // remove aggregations
+      this.selectParts = _.map(this.selectParts, (s: any) => {
+        return _.filter(s, (part: any) => {
+          if (part.def.type === 'aggregate' || part.def.type === 'percentile') {
+            return false;
+          }
+          return true;
+        });
+      });
+    }
+
+    this.groupParts.splice(index, 1);
+    this.updatePersistedParts();
+  }
+
+  handleWherePartEvent(whereParts, part, evt, index) {
+    switch (evt.name) {
+      case 'get-param-options': {
+        switch (evt.param.name) {
+          case 'left':
+            return this.datasource
+              .metricFindQuery(this.metaBuilder.buildColumnQuery())
+              .then(this.transformToSegments({}))
+              .catch(this.handleQueryError.bind(this));
+          case 'right':
+            if (['int4', 'int8', 'float4', 'float8', 'timestamp', 'timestamptz'].indexOf(part.datatype) > -1) {
+              // don't do value lookups for numerical fields
+              return this.$q.when([]);
+            } else {
+              return this.datasource
+                .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0]))
+                .then(
+                  this.transformToSegments({
+                    addTemplateVars: true,
+                    templateQuoter: (v: string) => {
+                      return this.queryModel.quoteLiteral(v);
+                    },
+                  })
+                )
+                .catch(this.handleQueryError.bind(this));
+            }
+          case 'op':
+            return this.$q.when(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype)));
+          default:
+            return this.$q.when([]);
+        }
+      }
+      case 'part-param-changed': {
+        this.updatePersistedParts();
+        this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => {
+          if (d.length === 1) {
+            part.datatype = d[0].text;
+          }
+        });
+        this.panelCtrl.refresh();
+        break;
+      }
+      case 'action': {
+        // remove element
+        whereParts.splice(index, 1);
+        this.updatePersistedParts();
+        this.panelCtrl.refresh();
+        break;
+      }
+      case 'get-part-actions': {
+        return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
+      }
+    }
+  }
+
+  getWhereOptions() {
+    const options = [];
+    if (this.queryModel.hasUnixEpochTimecolumn()) {
+      options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' }));
+    } else {
+      options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' }));
+    }
+    options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' }));
+    return this.$q.when(options);
+  }
+
+  addWhereAction(part, index) {
+    switch (this.whereAdd.type) {
+      case 'macro': {
+        const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] });
+        if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') {
+          // replace current macro
+          this.whereParts[0] = partModel;
+        } else {
+          this.whereParts.splice(0, 0, partModel);
+        }
+        break;
+      }
+      default: {
+        this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] }));
+      }
+    }
+
+    this.updatePersistedParts();
+    this.resetPlusButton(this.whereAdd);
+    this.panelCtrl.refresh();
+  }
+
+  getGroupOptions() {
+    return this.datasource
+      .metricFindQuery(this.metaBuilder.buildColumnQuery('group'))
+      .then(tags => {
+        const options = [];
+        if (!this.queryModel.hasTimeGroup()) {
+          options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' }));
+        }
+        for (const tag of tags) {
+          options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text }));
+        }
+        return options;
+      })
+      .catch(this.handleQueryError.bind(this));
+  }
+
+  addGroupAction() {
+    switch (this.groupAdd.value) {
+      default: {
+        this.addGroup(this.groupAdd.type, this.groupAdd.value);
+      }
+    }
+
+    this.resetPlusButton(this.groupAdd);
+    this.panelCtrl.refresh();
+  }
+
+  handleQueryError(err) {
+    this.error = err.message || 'Failed to issue metric query';
+    return [];
+  }
 }

+ 30 - 18
public/app/plugins/datasource/postgres/specs/datasource.test.ts

@@ -2,22 +2,33 @@ import moment from 'moment';
 import { PostgresDatasource } from '../datasource';
 import { CustomVariable } from 'app/features/templating/custom_variable';
 
-describe('PostgreSQLDatasource', function() {
+describe('PostgreSQLDatasource', () => {
   const instanceSettings = { name: 'postgresql' };
 
   const backendSrv = {};
   const templateSrv = {
     replace: jest.fn(text => text),
   };
+  const raw = {
+    from: moment.utc('2018-04-25 10:00'),
+    to: moment.utc('2018-04-25 11:00'),
+  };
   const ctx = {
     backendSrv,
+    timeSrvMock: {
+      timeRange: () => ({
+        from: raw.from,
+        to: raw.to,
+        raw: raw,
+      }),
+    },
   } as any;
 
   beforeEach(() => {
-    ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv);
+    ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv, ctx.timeSrvMock);
   });
 
-  describe('When performing annotationQuery', function() {
+  describe('When performing annotationQuery', () => {
     let results;
 
     const annotationName = 'MyAnno';
@@ -51,16 +62,16 @@ describe('PostgreSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.annotationQuery(options).then(function(data) {
+      ctx.ds.annotationQuery(options).then(data => {
         results = data;
       });
     });
 
-    it('should return annotation list', function() {
+    it('should return annotation list', () => {
       expect(results.length).toBe(3);
 
       expect(results[0].text).toBe('some text');
@@ -74,7 +85,7 @@ describe('PostgreSQLDatasource', function() {
     });
   });
 
-  describe('When performing metricFindQuery', function() {
+  describe('When performing metricFindQuery', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -94,23 +105,23 @@ describe('PostgreSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.metricFindQuery(query).then(function(data) {
+      ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of all column values', function() {
+    it('should return list of all column values', () => {
       expect(results.length).toBe(6);
       expect(results[0].text).toBe('aTitle');
       expect(results[5].text).toBe('some text3');
     });
   });
 
-  describe('When performing metricFindQuery with key, value columns', function() {
+  describe('When performing metricFindQuery with key, value columns', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -130,16 +141,16 @@ describe('PostgreSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.metricFindQuery(query).then(function(data) {
+      ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
     });
 
-    it('should return list of as text, value', function() {
+    it('should return list of as text, value', () => {
       expect(results.length).toBe(3);
       expect(results[0].text).toBe('aTitle');
       expect(results[0].value).toBe('value1');
@@ -148,7 +159,7 @@ describe('PostgreSQLDatasource', function() {
     });
   });
 
-  describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() {
+  describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => {
     let results;
     const query = 'select * from atable';
     const response = {
@@ -172,13 +183,13 @@ describe('PostgreSQLDatasource', function() {
       ctx.backendSrv.datasourceRequest = jest.fn(options => {
         return Promise.resolve({ data: response, status: 200 });
       });
-      ctx.ds.metricFindQuery(query).then(function(data) {
+      ctx.ds.metricFindQuery(query).then(data => {
         results = data;
       });
       //ctx.$rootScope.$apply();
     });
 
-    it('should return list of unique keys', function() {
+    it('should return list of unique keys', () => {
       expect(results.length).toBe(1);
       expect(results[0].text).toBe('aTitle');
       expect(results[0].value).toBe('same');
@@ -186,7 +197,7 @@ describe('PostgreSQLDatasource', function() {
   });
 
   describe('When interpolating variables', () => {
-    beforeEach(function() {
+    beforeEach(() => {
       ctx.variable = new CustomVariable({}, {});
     });
 
@@ -219,6 +230,7 @@ describe('PostgreSQLDatasource', function() {
       it('should return a quoted value', () => {
         ctx.variable.multi = true;
         expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a''bc'");
+        expect(ctx.ds.interpolateVariable("a'b'c", ctx.variable)).toEqual("'a''b''c'");
       });
     });
 

+ 155 - 0
public/app/plugins/datasource/postgres/specs/postgres_query.test.ts

@@ -0,0 +1,155 @@
+import PostgresQuery from '../postgres_query';
+
+describe('PostgresQuery', function() {
+  const templateSrv = {
+    replace: jest.fn(text => text),
+  };
+
+  describe('When initializing', function() {
+    it('should not be in SQL mode', function() {
+      const query = new PostgresQuery({}, templateSrv);
+      expect(query.target.rawQuery).toBe(false);
+    });
+    it('should be in SQL mode for pre query builder queries', function() {
+      const query = new PostgresQuery({ rawSql: 'SELECT 1' }, templateSrv);
+      expect(query.target.rawQuery).toBe(true);
+    });
+  });
+
+  describe('When generating time column SQL', function() {
+    const query = new PostgresQuery({}, templateSrv);
+
+    query.target.timeColumn = 'time';
+    expect(query.buildTimeColumn()).toBe('time AS "time"');
+    query.target.timeColumn = '"time"';
+    expect(query.buildTimeColumn()).toBe('"time" AS "time"');
+  });
+
+  describe('When generating time column SQL with group by time', function() {
+    let query = new PostgresQuery(
+      { timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'none'] }] },
+      templateSrv
+    );
+    expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m)');
+    expect(query.buildTimeColumn(false)).toBe('$__timeGroup(time,5m)');
+
+    query = new PostgresQuery({ timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'NULL'] }] }, templateSrv);
+    expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m,NULL)');
+
+    query = new PostgresQuery(
+      { timeColumn: 'time', timeColumnType: 'int4', group: [{ type: 'time', params: ['5m', 'none'] }] },
+      templateSrv
+    );
+    expect(query.buildTimeColumn()).toBe('$__unixEpochGroupAlias(time,5m)');
+    expect(query.buildTimeColumn(false)).toBe('$__unixEpochGroup(time,5m)');
+  });
+
+  describe('When generating metric column SQL', function() {
+    const query = new PostgresQuery({}, templateSrv);
+
+    query.target.metricColumn = 'host';
+    expect(query.buildMetricColumn()).toBe('host AS metric');
+    query.target.metricColumn = '"host"';
+    expect(query.buildMetricColumn()).toBe('"host" AS metric');
+  });
+
+  describe('When generating value column SQL', function() {
+    const query = new PostgresQuery({}, templateSrv);
+
+    let column = [{ type: 'column', params: ['value'] }];
+    expect(query.buildValueColumn(column)).toBe('value');
+    column = [{ type: 'column', params: ['value'] }, { type: 'alias', params: ['alias'] }];
+    expect(query.buildValueColumn(column)).toBe('value AS "alias"');
+    column = [
+      { type: 'column', params: ['v'] },
+      { type: 'alias', params: ['a'] },
+      { type: 'aggregate', params: ['max'] },
+    ];
+    expect(query.buildValueColumn(column)).toBe('max(v) AS "a"');
+    column = [
+      { type: 'column', params: ['v'] },
+      { type: 'alias', params: ['a'] },
+      { type: 'window', params: ['increase'] },
+    ];
+    expect(query.buildValueColumn(column)).toBe(
+      '(CASE WHEN v >= lag(v) OVER (ORDER BY time) THEN v - lag(v) OVER (ORDER BY time) ELSE v END) AS "a"'
+    );
+  });
+
+  describe('When generating value column SQL with metric column', function() {
+    const query = new PostgresQuery({}, templateSrv);
+    query.target.metricColumn = 'host';
+
+    let column = [{ type: 'column', params: ['value'] }];
+    expect(query.buildValueColumn(column)).toBe('value');
+    column = [{ type: 'column', params: ['value'] }, { type: 'alias', params: ['alias'] }];
+    expect(query.buildValueColumn(column)).toBe('value AS "alias"');
+    column = [
+      { type: 'column', params: ['v'] },
+      { type: 'alias', params: ['a'] },
+      { type: 'aggregate', params: ['max'] },
+    ];
+    expect(query.buildValueColumn(column)).toBe('max(v) AS "a"');
+    column = [
+      { type: 'column', params: ['v'] },
+      { type: 'alias', params: ['a'] },
+      { type: 'window', params: ['increase'] },
+    ];
+    expect(query.buildValueColumn(column)).toBe(
+      '(CASE WHEN v >= lag(v) OVER (PARTITION BY host ORDER BY time) THEN v - lag(v) OVER (PARTITION BY host ORDER BY time) ELSE v END) AS "a"'
+    );
+    column = [
+      { type: 'column', params: ['v'] },
+      { type: 'alias', params: ['a'] },
+      { type: 'aggregate', params: ['max'] },
+      { type: 'window', params: ['increase'] },
+    ];
+    expect(query.buildValueColumn(column)).toBe(
+      '(CASE WHEN max(v) >= lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' +
+        'THEN max(v) - lag(max(v)) OVER (PARTITION BY host ORDER BY time) ELSE max(v) END) AS "a"'
+    );
+  });
+
+  describe('When generating WHERE clause', function() {
+    const query = new PostgresQuery({ where: [] }, templateSrv);
+
+    expect(query.buildWhereClause()).toBe('');
+
+    query.target.timeColumn = 't';
+    query.target.where = [{ type: 'macro', name: '$__timeFilter' }];
+    expect(query.buildWhereClause()).toBe('\nWHERE\n  $__timeFilter(t)');
+
+    query.target.where = [{ type: 'expression', params: ['v', '=', '1'] }];
+    expect(query.buildWhereClause()).toBe('\nWHERE\n  v = 1');
+
+    query.target.where = [{ type: 'macro', name: '$__timeFilter' }, { type: 'expression', params: ['v', '=', '1'] }];
+    expect(query.buildWhereClause()).toBe('\nWHERE\n  $__timeFilter(t) AND\n  v = 1');
+  });
+
+  describe('When generating GROUP BY clause', function() {
+    const query = new PostgresQuery({ group: [], metricColumn: 'none' }, templateSrv);
+
+    expect(query.buildGroupClause()).toBe('');
+    query.target.group = [{ type: 'time', params: ['5m'] }];
+    expect(query.buildGroupClause()).toBe('\nGROUP BY 1');
+    query.target.metricColumn = 'm';
+    expect(query.buildGroupClause()).toBe('\nGROUP BY 1,2');
+  });
+
+  describe('When generating complete statement', function() {
+    const target = {
+      timeColumn: 't',
+      table: 'table',
+      select: [[{ type: 'column', params: ['value'] }]],
+      where: [],
+    };
+    let result = 'SELECT\n  t AS "time",\n  value\nFROM table\nORDER BY 1';
+    const query = new PostgresQuery(target, templateSrv);
+
+    expect(query.buildQuery()).toBe(result);
+
+    query.target.metricColumn = 'm';
+    result = 'SELECT\n  t AS "time",\n  m AS metric,\n  value\nFROM table\nORDER BY 1';
+    expect(query.buildQuery()).toBe(result);
+  });
+});

+ 137 - 0
public/app/plugins/datasource/postgres/sql_part.ts

@@ -0,0 +1,137 @@
+import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part';
+
+const index = [];
+
+function createPart(part): any {
+  const def = index[part.type];
+  if (!def) {
+    return null;
+  }
+
+  return new SqlPart(part, def);
+}
+
+function register(options: any) {
+  index[options.type] = new SqlPartDef(options);
+}
+
+register({
+  type: 'column',
+  style: 'label',
+  params: [{ type: 'column', dynamicLookup: true }],
+  defaultParams: ['value'],
+});
+
+register({
+  type: 'expression',
+  style: 'expression',
+  label: 'Expr:',
+  params: [
+    { name: 'left', type: 'string', dynamicLookup: true },
+    { name: 'op', type: 'string', dynamicLookup: true },
+    { name: 'right', type: 'string', dynamicLookup: true },
+  ],
+  defaultParams: ['value', '=', 'value'],
+});
+
+register({
+  type: 'macro',
+  style: 'label',
+  label: 'Macro:',
+  params: [],
+  defaultParams: [],
+});
+
+register({
+  type: 'aggregate',
+  style: 'label',
+  params: [
+    {
+      name: 'name',
+      type: 'string',
+      options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'],
+    },
+  ],
+  defaultParams: ['avg'],
+});
+
+register({
+  type: 'percentile',
+  label: 'Aggregate:',
+  style: 'label',
+  params: [
+    {
+      name: 'name',
+      type: 'string',
+      options: ['percentile_cont', 'percentile_disc'],
+    },
+    {
+      name: 'fraction',
+      type: 'number',
+      options: ['0.5', '0.75', '0.9', '0.95', '0.99'],
+    },
+  ],
+  defaultParams: ['percentile_cont', '0.95'],
+});
+
+register({
+  type: 'alias',
+  style: 'label',
+  params: [{ name: 'name', type: 'string', quote: 'double' }],
+  defaultParams: ['alias'],
+});
+
+register({
+  type: 'time',
+  style: 'function',
+  label: 'time',
+  params: [
+    {
+      name: 'interval',
+      type: 'interval',
+      options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'],
+    },
+    {
+      name: 'fill',
+      type: 'string',
+      options: ['none', 'NULL', 'previous', '0'],
+    },
+  ],
+  defaultParams: ['$__interval', 'none'],
+});
+
+register({
+  type: 'window',
+  style: 'label',
+  params: [
+    {
+      name: 'function',
+      type: 'string',
+      options: ['increase', 'rate', 'sum'],
+    },
+  ],
+  defaultParams: ['increase'],
+});
+
+register({
+  type: 'moving_window',
+  style: 'label',
+  label: 'Moving Window:',
+  params: [
+    {
+      name: 'function',
+      type: 'string',
+      options: ['avg'],
+    },
+    {
+      name: 'window_size',
+      type: 'number',
+      options: ['3', '5', '7', '10', '20'],
+    },
+  ],
+  defaultParams: ['avg', '5'],
+});
+
+export default {
+  create: createPart,
+};

+ 1 - 1
public/app/plugins/datasource/prometheus/specs/completer.test.ts

@@ -4,7 +4,7 @@ import { BackendSrv } from 'app/core/services/backend_srv';
 jest.mock('../datasource');
 jest.mock('app/core/services/backend_srv');
 
-describe('Prometheus editor completer', function() {
+describe('Prometheus editor completer', () => {
   function getSessionStub(data) {
     return {
       getTokenAt: jest.fn(() => data.currentToken),

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

@@ -437,7 +437,7 @@ describe('PrometheusDatasource', () => {
       backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
       ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
 
-      await ctx.ds.query(query).then(function(data) {
+      await ctx.ds.query(query).then(data => {
         results = data;
       });
     });
@@ -487,7 +487,7 @@ describe('PrometheusDatasource', () => {
       backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
       ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
 
-      await ctx.ds.query(query).then(function(data) {
+      await ctx.ds.query(query).then(data => {
         results = data;
       });
     });
@@ -548,7 +548,7 @@ describe('PrometheusDatasource', () => {
       backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
       ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
 
-      await ctx.ds.query(query).then(function(data) {
+      await ctx.ds.query(query).then(data => {
         results = data;
       });
     });
@@ -603,7 +603,7 @@ describe('PrometheusDatasource', () => {
       backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
       ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
 
-      await ctx.ds.annotationQuery(options).then(function(data) {
+      await ctx.ds.annotationQuery(options).then(data => {
         results = data;
       });
     });
@@ -642,7 +642,7 @@ describe('PrometheusDatasource', () => {
 
       backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
       ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
-      await ctx.ds.query(query).then(function(data) {
+      await ctx.ds.query(query).then(data => {
         results = data;
       });
     });
@@ -1156,7 +1156,7 @@ describe('PrometheusDatasource for POST', () => {
       };
       backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response));
       ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv);
-      await ctx.ds.query(query).then(function(data) {
+      await ctx.ds.query(query).then(data => {
         results = data;
       });
     });

+ 1 - 1
public/app/plugins/datasource/prometheus/specs/metric_find_query.test.ts

@@ -3,7 +3,7 @@ import { PrometheusDatasource } from '../datasource';
 import PrometheusMetricFindQuery from '../metric_find_query';
 import q from 'q';
 
-describe('PrometheusMetricFindQuery', function() {
+describe('PrometheusMetricFindQuery', () => {
   const instanceSettings = {
     url: 'proxied',
     directUrl: 'direct',

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio