Jelajahi Sumber

Merge branch 'master' into 12918-only-arrow-functions

Torkel Ödegaard 7 tahun lalu
induk
melakukan
151e950e2c
100 mengubah file dengan 2134 tambahan dan 623 penghapusan
  1. 3 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. 9 5
      pkg/cmd/grafana-server/main.go
  7. 17 0
      pkg/log/file.go
  8. 4 0
      pkg/log/handlers.go
  9. 9 0
      pkg/log/log.go
  10. 37 9
      pkg/services/rendering/http_mode.go
  11. 1 1
      pkg/services/rendering/phantomjs.go
  12. 2 2
      pkg/services/rendering/plugin_mode.go
  13. 30 8
      pkg/services/rendering/rendering.go
  14. 13 0
      pkg/setting/setting.go
  15. 11 0
      pkg/setting/setting_test.go
  16. 4 4
      public/app/app.ts
  17. 1 1
      public/app/containers/Explore/utils/debounce.ts
  18. 71 37
      public/app/containers/Teams/TeamList.tsx
  19. 2 0
      public/app/core/angular_wrappers.ts
  20. 1 1
      public/app/core/components/gf_page.ts
  21. 1 1
      public/app/core/components/scroll/page_scroll.ts
  22. 1 1
      public/app/core/components/scroll/scroll.ts
  23. 96 0
      public/app/core/components/sidemenu/BottomNavLinks.test.tsx
  24. 78 0
      public/app/core/components/sidemenu/BottomNavLinks.tsx
  25. 44 0
      public/app/core/components/sidemenu/BottomSection.test.tsx
  26. 29 0
      public/app/core/components/sidemenu/BottomSection.tsx
  27. 35 0
      public/app/core/components/sidemenu/DropDownChild.test.tsx
  28. 21 0
      public/app/core/components/sidemenu/DropDownChild.tsx
  29. 70 0
      public/app/core/components/sidemenu/SideMenu.test.tsx
  30. 32 0
      public/app/core/components/sidemenu/SideMenu.tsx
  31. 35 0
      public/app/core/components/sidemenu/SideMenuDropDown.test.tsx
  32. 23 0
      public/app/core/components/sidemenu/SideMenuDropDown.tsx
  33. 11 0
      public/app/core/components/sidemenu/SignIn.test.tsx
  34. 23 0
      public/app/core/components/sidemenu/SignIn.tsx
  35. 41 0
      public/app/core/components/sidemenu/TopSection.test.tsx
  36. 19 0
      public/app/core/components/sidemenu/TopSection.tsx
  37. 22 0
      public/app/core/components/sidemenu/TopSectionItem.test.tsx
  38. 23 0
      public/app/core/components/sidemenu/TopSectionItem.tsx
  39. 163 0
      public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap
  40. 34 0
      public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap
  41. 21 0
      public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap
  42. 22 0
      public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
  43. 59 0
      public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
  44. 40 0
      public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap
  45. 33 0
      public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap
  46. 17 0
      public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap
  47. 0 81
      public/app/core/components/sidemenu/sidemenu.html
  48. 0 89
      public/app/core/components/sidemenu/sidemenu.ts
  49. 74 0
      public/app/core/components/sql_part/sql_part.ts
  50. 199 0
      public/app/core/components/sql_part/sql_part_editor.ts
  51. 2 2
      public/app/core/config.ts
  52. 4 4
      public/app/core/controllers/invited_ctrl.ts
  53. 12 12
      public/app/core/controllers/login_ctrl.ts
  54. 4 4
      public/app/core/controllers/reset_password_ctrl.ts
  55. 2 2
      public/app/core/core.ts
  56. 12 12
      public/app/core/filters/filters.ts
  57. 1 1
      public/app/core/lodash_extended.ts
  58. 1 1
      public/app/core/services/alert_srv.ts
  59. 2 0
      public/app/core/services/context_srv.ts
  60. 3 3
      public/app/core/services/popover_srv.ts
  61. 15 15
      public/app/core/services/segment_srv.ts
  62. 3 3
      public/app/core/services/util_srv.ts
  63. 1 1
      public/app/core/specs/backend_srv.test.ts
  64. 6 6
      public/app/core/specs/datemath.test.ts
  65. 1 1
      public/app/core/specs/file_export.test.ts
  66. 81 81
      public/app/core/specs/kbn.test.ts
  67. 72 72
      public/app/core/specs/time_series.test.ts
  68. 10 10
      public/app/core/utils/css_loader.ts
  69. 2 0
      public/app/core/utils/kbn.ts
  70. 1 1
      public/app/features/dashboard/change_tracker.ts
  71. 1 1
      public/app/features/dashboard/dashboard_loader_srv.ts
  72. 2 2
      public/app/features/dashboard/repeat_option/repeat_option.ts
  73. 4 4
      public/app/features/dashboard/shareModalCtrl.ts
  74. 18 18
      public/app/features/dashboard/share_snapshot_ctrl.ts
  75. 3 3
      public/app/features/dashboard/timepicker/input_date.ts
  76. 4 4
      public/app/features/dashboard/upload.ts
  77. 7 7
      public/app/features/dashboard/view_state_srv.ts
  78. 9 9
      public/app/features/dashlinks/module.ts
  79. 2 2
      public/app/features/org/change_password_ctrl.ts
  80. 3 3
      public/app/features/org/new_org_ctrl.ts
  81. 6 6
      public/app/features/org/org_api_keys_ctrl.ts
  82. 5 5
      public/app/features/org/select_org_ctrl.ts
  83. 7 7
      public/app/features/panel/panel_directive.ts
  84. 2 2
      public/app/features/panel/panel_header.ts
  85. 2 2
      public/app/features/panel/query_troubleshooter.ts
  86. 3 3
      public/app/features/panel/solo_panel_ctrl.ts
  87. 8 8
      public/app/features/panellinks/module.ts
  88. 1 1
      public/app/features/playlist/playlist_routes.ts
  89. 4 4
      public/app/features/plugins/ds_edit_ctrl.ts
  90. 11 11
      public/app/features/plugins/plugin_component.ts
  91. 1 1
      public/app/features/templating/all.ts
  92. 1 1
      public/app/features/templating/custom_variable.ts
  93. 16 16
      public/app/features/templating/editor_ctrl.ts
  94. 1 1
      public/app/features/templating/interval_variable.ts
  95. 2 2
      public/app/features/templating/query_variable.ts
  96. 3 3
      public/app/features/templating/template_srv.ts
  97. 8 7
      public/app/features/templating/variable_srv.ts
  98. 63 0
      public/app/plugins/datasource/postgres/config_ctrl.ts
  99. 28 17
      public/app/plugins/datasource/postgres/datasource.ts
  100. 176 0
      public/app/plugins/datasource/postgres/meta_query.ts

+ 3 - 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,7 +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
 ```

+ 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/")
+		})
+
 	})
 }

+ 4 - 4
public/app/app.ts

@@ -21,7 +21,7 @@ import _ from 'lodash';
 import moment from 'moment';
 
 // add move to lodash for backward compatabiltiy
-_.move = function(array, fromIndex, toIndex) {
+_.move = (array, fromIndex, toIndex) => {
   array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
   return array;
 };
@@ -76,9 +76,9 @@ export class GrafanaApp {
       $provide.decorator('$http', [
         '$delegate',
         '$templateCache',
-        function($delegate, $templateCache) {
+        ($delegate, $templateCache) => {
           const get = $delegate.get;
-          $delegate.get = function(url, config) {
+          $delegate.get = (url, config) => {
             if (url.match(/\.html$/)) {
               // some template's already exist in the cache
               if (!$templateCache.get(url)) {
@@ -135,7 +135,7 @@ export class GrafanaApp {
           this.preBootModules = null;
         });
       })
-      .catch(function(err) {
+      .catch(err => {
         console.log('Application boot failed:', err);
       });
   }

+ 1 - 1
public/app/containers/Explore/utils/debounce.ts

@@ -4,7 +4,7 @@ export default function debounce(func, wait) {
   return function(this: any) {
     const context = this;
     const args = arguments;
-    const later = function() {
+    const later = () => {
       timeout = null;
       func.apply(context, args);
     };

+ 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, []);

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

@@ -31,7 +31,7 @@ export function gfPageDirective() {
       header: '?gfPageHeader',
       body: 'gfPageBody',
     },
-    link: function(scope, elem, attrs) {
+    link: (scope, elem, attrs) => {
       console.log(scope);
     },
   };

+ 1 - 1
public/app/core/components/scroll/page_scroll.ts

@@ -4,7 +4,7 @@ import appEvents from 'app/core/app_events';
 export function pageScrollbar() {
   return {
     restrict: 'A',
-    link: function(scope, elem, attrs) {
+    link: (scope, elem, attrs) => {
       let lastPos = 0;
 
       appEvents.on(

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

@@ -14,7 +14,7 @@ const scrollerClass = 'baron__scroller';
 export function geminiScrollbar() {
   return {
     restrict: 'A',
-    link: function(scope, elem, attrs) {
+    link: (scope, elem, attrs) => {
       let scrollRoot = elem.parent();
       const scroller = elem;
 

+ 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: '',

+ 4 - 4
public/app/core/controllers/invited_ctrl.ts

@@ -16,8 +16,8 @@ export class InvitedCtrl {
       },
     };
 
-    $scope.init = function() {
-      backendSrv.get('/api/user/invite/' + $routeParams.code).then(function(invite) {
+    $scope.init = () => {
+      backendSrv.get('/api/user/invite/' + $routeParams.code).then(invite => {
         $scope.formModel.name = invite.name;
         $scope.formModel.email = invite.email;
         $scope.formModel.username = invite.email;
@@ -28,12 +28,12 @@ export class InvitedCtrl {
       });
     };
 
-    $scope.submit = function() {
+    $scope.submit = () => {
       if (!$scope.inviteForm.$valid) {
         return;
       }
 
-      backendSrv.post('/api/user/invite/complete', $scope.formModel).then(function() {
+      backendSrv.post('/api/user/invite/complete', $scope.formModel).then(() => {
         window.location.href = config.appSubUrl + '/';
       });
     };

+ 12 - 12
public/app/core/controllers/login_ctrl.ts

@@ -29,7 +29,7 @@ export class LoginCtrl {
     $scope.loginMode = true;
     $scope.submitBtnText = 'Log in';
 
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.$watch('loginMode', $scope.loginModeChanged);
 
       if (config.loginError) {
@@ -37,7 +37,7 @@ export class LoginCtrl {
       }
     };
 
-    $scope.submit = function() {
+    $scope.submit = () => {
       if ($scope.loginMode) {
         $scope.login();
       } else {
@@ -45,7 +45,7 @@ export class LoginCtrl {
       }
     };
 
-    $scope.changeView = function() {
+    $scope.changeView = () => {
       const loginView = document.querySelector('#login-view');
       const changePasswordView = document.querySelector('#change-password-view');
 
@@ -65,7 +65,7 @@ export class LoginCtrl {
       }, 400);
     };
 
-    $scope.changePassword = function() {
+    $scope.changePassword = () => {
       $scope.command.oldPassword = 'admin';
 
       if ($scope.command.newPassword !== $scope.command.confirmNew) {
@@ -73,25 +73,25 @@ export class LoginCtrl {
         return;
       }
 
-      backendSrv.put('/api/user/password', $scope.command).then(function() {
+      backendSrv.put('/api/user/password', $scope.command).then(() => {
         $scope.toGrafana();
       });
     };
 
-    $scope.skip = function() {
+    $scope.skip = () => {
       $scope.toGrafana();
     };
 
-    $scope.loginModeChanged = function(newValue) {
+    $scope.loginModeChanged = newValue => {
       $scope.submitBtnText = newValue ? 'Log in' : 'Sign up';
     };
 
-    $scope.signUp = function() {
+    $scope.signUp = () => {
       if (!$scope.loginForm.$valid) {
         return;
       }
 
-      backendSrv.post('/api/user/signup', $scope.formModel).then(function(result) {
+      backendSrv.post('/api/user/signup', $scope.formModel).then(result => {
         if (result.status === 'SignUpCreated') {
           $location.path('/signup').search({ email: $scope.formModel.email });
         } else {
@@ -100,7 +100,7 @@ export class LoginCtrl {
       });
     };
 
-    $scope.login = function() {
+    $scope.login = () => {
       delete $scope.loginError;
 
       if (!$scope.loginForm.$valid) {
@@ -110,7 +110,7 @@ export class LoginCtrl {
 
       backendSrv
         .post('/login', $scope.formModel)
-        .then(function(result) {
+        .then(result => {
           $scope.result = result;
 
           if ($scope.formModel.password !== 'admin' || $scope.ldapEnabled || $scope.authProxyEnabled) {
@@ -125,7 +125,7 @@ export class LoginCtrl {
         });
     };
 
-    $scope.toGrafana = function() {
+    $scope.toGrafana = () => {
       const params = $location.search();
 
       if (params.redirect && params.redirect[0] === '/') {

+ 4 - 4
public/app/core/controllers/reset_password_ctrl.ts

@@ -22,16 +22,16 @@ export class ResetPasswordCtrl {
       },
     };
 
-    $scope.sendResetEmail = function() {
+    $scope.sendResetEmail = () => {
       if (!$scope.sendResetForm.$valid) {
         return;
       }
-      backendSrv.post('/api/user/password/send-reset-email', $scope.formModel).then(function() {
+      backendSrv.post('/api/user/password/send-reset-email', $scope.formModel).then(() => {
         $scope.mode = 'email-sent';
       });
     };
 
-    $scope.submitReset = function() {
+    $scope.submitReset = () => {
       if (!$scope.resetForm.$valid) {
         return;
       }
@@ -41,7 +41,7 @@ export class ResetPasswordCtrl {
         return;
       }
 
-      backendSrv.post('/api/user/password/reset', $scope.formModel).then(function() {
+      backendSrv.post('/api/user/password/reset', $scope.formModel).then(() => {
         $location.path('login');
       });
     };

+ 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,

+ 12 - 12
public/app/core/filters/filters.ts

@@ -3,22 +3,22 @@ import angular from 'angular';
 import moment from 'moment';
 import coreModule from '../core_module';
 
-coreModule.filter('stringSort', function() {
-  return function(input) {
+coreModule.filter('stringSort', () => {
+  return input => {
     return input.sort();
   };
 });
 
-coreModule.filter('slice', function() {
-  return function(arr, start, end) {
+coreModule.filter('slice', () => {
+  return (arr, start, end) => {
     if (!_.isUndefined(arr)) {
       return arr.slice(start, end);
     }
   };
 });
 
-coreModule.filter('stringify', function() {
-  return function(arr) {
+coreModule.filter('stringify', () => {
+  return arr => {
     if (_.isObject(arr) && !_.isArray(arr)) {
       return angular.toJson(arr);
     } else {
@@ -27,8 +27,8 @@ coreModule.filter('stringify', function() {
   };
 });
 
-coreModule.filter('moment', function() {
-  return function(date, mode) {
+coreModule.filter('moment', () => {
+  return (date, mode) => {
     switch (mode) {
       case 'ago':
         return moment(date).fromNow();
@@ -37,8 +37,8 @@ coreModule.filter('moment', function() {
   };
 });
 
-coreModule.filter('noXml', function() {
-  const noXml = function(text) {
+coreModule.filter('noXml', () => {
+  const noXml = text => {
     return _.isString(text)
       ? text
           .replace(/&/g, '&amp;')
@@ -48,14 +48,14 @@ coreModule.filter('noXml', function() {
           .replace(/"/g, '&quot;')
       : text;
   };
-  return function(text) {
+  return text => {
     return _.isArray(text) ? _.map(text, noXml) : noXml(text);
   };
 });
 
 /** @ngInject */
 function interpolateTemplateVars(templateSrv) {
-  const filterFunc: any = function(text, scope) {
+  const filterFunc: any = (text, scope) => {
     let scopedVars;
     if (scope.ctrl) {
       scopedVars = (scope.ctrl.panel || scope.ctrl.row).scopedVars;

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

@@ -4,7 +4,7 @@ import _ from 'lodash';
   Mixins :)
 */
 _.mixin({
-  move: function(array, fromIndex, toIndex) {
+  move: (array, fromIndex, toIndex) => {
     array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
     return array;
   },

+ 1 - 1
public/app/core/services/alert_srv.ts

@@ -70,7 +70,7 @@ export class AlertSrv {
     const newAlertJson = angular.toJson(newAlert);
 
     // remove same alert if it already exists
-    _.remove(this.list, function(value) {
+    _.remove(this.list, value => {
       return angular.toJson(value) === newAlertJson;
     });
 

+ 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;

+ 3 - 3
public/app/core/services/popover_srv.ts

@@ -6,13 +6,13 @@ import Drop from 'tether-drop';
 function popoverSrv(this: any, $compile, $rootScope, $timeout) {
   let openDrop = null;
 
-  this.close = function() {
+  this.close = () => {
     if (openDrop) {
       openDrop.close();
     }
   };
 
-  this.show = function(options) {
+  this.show = options => {
     if (openDrop) {
       openDrop.close();
       openDrop = null;
@@ -68,7 +68,7 @@ function popoverSrv(this: any, $compile, $rootScope, $timeout) {
     }, 100);
 
     // return close function
-    return function() {
+    return () => {
       if (drop) {
         drop.close();
       }

+ 15 - 15
public/app/core/services/segment_srv.ts

@@ -42,48 +42,48 @@ export function uiSegmentSrv(this: any, $sce, templateSrv) {
     }
   };
 
-  this.newSelectMeasurement = function() {
+  this.newSelectMeasurement = () => {
     return new MetricSegment({ value: 'select measurement', fake: true });
   };
 
-  this.newFake = function(text, type, cssClass) {
+  this.newFake = (text, type, cssClass) => {
     return new MetricSegment({ value: text, fake: true, type: type, cssClass: cssClass });
   };
 
-  this.newSegment = function(options) {
+  this.newSegment = options => {
     return new MetricSegment(options);
   };
 
-  this.newKey = function(key) {
+  this.newKey = key => {
     return new MetricSegment({ value: key, type: 'key', cssClass: 'query-segment-key' });
   };
 
-  this.newKeyValue = function(value) {
+  this.newKeyValue = value => {
     return new MetricSegment({ value: value, type: 'value', cssClass: 'query-segment-value' });
   };
 
-  this.newCondition = function(condition) {
+  this.newCondition = condition => {
     return new MetricSegment({ value: condition, type: 'condition', cssClass: 'query-keyword' });
   };
 
-  this.newOperator = function(op) {
+  this.newOperator = op => {
     return new MetricSegment({ value: op, type: 'operator', cssClass: 'query-segment-operator' });
   };
 
-  this.newOperators = function(ops) {
-    return _.map(ops, function(op) {
+  this.newOperators = ops => {
+    return _.map(ops, op => {
       return new MetricSegment({ value: op, type: 'operator', cssClass: 'query-segment-operator' });
     });
   };
 
-  this.transformToSegments = function(addTemplateVars, variableTypeFilter) {
-    return function(results) {
-      const segments = _.map(results, function(segment) {
+  this.transformToSegments = (addTemplateVars, variableTypeFilter) => {
+    return results => {
+      const segments = _.map(results, segment => {
         return self.newSegment({ value: segment.text, expandable: segment.expandable });
       });
 
       if (addTemplateVars) {
-        _.each(templateSrv.variables, function(variable) {
+        _.each(templateSrv.variables, variable => {
           if (variableTypeFilter === void 0 || variableTypeFilter === variable.type) {
             segments.unshift(self.newSegment({ type: 'value', value: '$' + variable.name, expandable: true }));
           }
@@ -94,11 +94,11 @@ export function uiSegmentSrv(this: any, $sce, templateSrv) {
     };
   };
 
-  this.newSelectMetric = function() {
+  this.newSelectMetric = () => {
     return new MetricSegment({ value: 'select metric', fake: true });
   };
 
-  this.newPlusButton = function() {
+  this.newPlusButton = () => {
     return new MetricSegment({
       fake: true,
       html: '<i class="fa fa-plus "></i>',

+ 3 - 3
public/app/core/services/util_srv.ts

@@ -44,7 +44,7 @@ export class UtilSrv {
       backdrop: options.backdrop,
     });
 
-    Promise.resolve(modal).then(function(modalEl) {
+    Promise.resolve(modal).then(modalEl => {
       modalEl.modal('show');
     });
   }
@@ -52,12 +52,12 @@ export class UtilSrv {
   showConfirmModal(payload) {
     const scope = this.$rootScope.$new();
 
-    scope.onConfirm = function() {
+    scope.onConfirm = () => {
       payload.onConfirm();
       scope.dismiss();
     };
 
-    scope.updateConfirmText = function(value) {
+    scope.updateConfirmText = value => {
       scope.confirmTextValid = payload.confirmText.toLowerCase() === value.toLowerCase();
     };
 

+ 1 - 1
public/app/core/specs/backend_srv.test.ts

@@ -1,7 +1,7 @@
 import { BackendSrv } from 'app/core/services/backend_srv';
 jest.mock('app/core/store');
 
-describe('backend_srv', function() {
+describe('backend_srv', () => {
   const _httpBackend = options => {
     if (options.url === 'gateway-error') {
       return Promise.reject({ status: 502 });

+ 6 - 6
public/app/core/specs/datemath.test.ts

@@ -91,11 +91,11 @@ describe('DateMath', () => {
     });
 
     _.each(spans, span => {
-      it('should round now to the beginning of the ' + span, function() {
+      it('should round now to the beginning of the ' + span, () => {
         expect(dateMath.parse('now/' + span).format(format)).toEqual(now.startOf(span).format(format));
       });
 
-      it('should round now to the end of the ' + span, function() {
+      it('should round now to the end of the ' + span, () => {
         expect(dateMath.parse('now/' + span, true).format(format)).toEqual(now.endOf(span).format(format));
       });
     });
@@ -114,18 +114,18 @@ describe('DateMath', () => {
     });
   });
 
-  describe('relative time to date parsing', function() {
-    it('should handle negative time', function() {
+  describe('relative time to date parsing', () => {
+    it('should handle negative time', () => {
       const date = dateMath.parseDateMath('-2d', moment([2014, 1, 5]));
       expect(date.valueOf()).toEqual(moment([2014, 1, 3]).valueOf());
     });
 
-    it('should handle multiple math expressions', function() {
+    it('should handle multiple math expressions', () => {
       const date = dateMath.parseDateMath('-2d-6h', moment([2014, 1, 5]));
       expect(date.valueOf()).toEqual(moment([2014, 1, 2, 18]).valueOf());
     });
 
-    it('should return false when invalid expression', function() {
+    it('should return false when invalid expression', () => {
       const date = dateMath.parseDateMath('2', moment([2014, 1, 5]));
       expect(date).toEqual(undefined);
     });

+ 1 - 1
public/app/core/specs/file_export.test.ts

@@ -101,7 +101,7 @@ describe('file_export', () => {
       expect(returnedText).toBe(expectedText);
     });
 
-    it('should decode HTML encoded characters', function() {
+    it('should decode HTML encoded characters', () => {
       const inputTable = {
         columns: [{ text: 'string_value' }],
         rows: [

+ 81 - 81
public/app/core/specs/kbn.test.ts

@@ -2,27 +2,27 @@ import kbn from '../utils/kbn';
 import * as dateMath from '../utils/datemath';
 import moment from 'moment';
 
-describe('unit format menu', function() {
+describe('unit format menu', () => {
   const menu = kbn.getUnitFormats();
-  menu.map(function(submenu) {
-    describe('submenu ' + submenu.text, function() {
-      it('should have a title', function() {
+  menu.map(submenu => {
+    describe('submenu ' + submenu.text, () => {
+      it('should have a title', () => {
         expect(typeof submenu.text).toBe('string');
       });
 
-      it('should have a submenu', function() {
+      it('should have a submenu', () => {
         expect(Array.isArray(submenu.submenu)).toBe(true);
       });
 
-      submenu.submenu.map(function(entry) {
-        describe('entry ' + entry.text, function() {
-          it('should have a title', function() {
+      submenu.submenu.map(entry => {
+        describe('entry ' + entry.text, () => {
+          it('should have a title', () => {
             expect(typeof entry.text).toBe('string');
           });
-          it('should have a format', function() {
+          it('should have a format', () => {
             expect(typeof entry.value).toBe('string');
           });
-          it('should have a valid format', function() {
+          it('should have a valid format', () => {
             expect(typeof kbn.valueFormats[entry.value]).toBe('function');
           });
         });
@@ -32,8 +32,8 @@ describe('unit format menu', function() {
 });
 
 function describeValueFormat(desc, value, tickSize, tickDecimals, result) {
-  describe('value format: ' + desc, function() {
-    it('should translate ' + value + ' as ' + result, function() {
+  describe('value format: ' + desc, () => {
+    it('should translate ' + value + ' as ' + result, () => {
       const scaledDecimals = tickDecimals - Math.floor(Math.log(tickSize) / Math.LN10);
       const str = kbn.valueFormats[desc](value, tickDecimals, scaledDecimals);
       expect(str).toBe(result);
@@ -100,85 +100,85 @@ describeValueFormat('d', 3, 1, 0, '3 day');
 describeValueFormat('d', 245, 100, 0, '35 week');
 describeValueFormat('d', 2456, 10, 0, '6.73 year');
 
-describe('date time formats', function() {
+describe('date time formats', () => {
   const epoch = 1505634997920;
   const utcTime = moment.utc(epoch);
   const browserTime = moment(epoch);
 
-  it('should format as iso date', function() {
+  it('should format as iso date', () => {
     const expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
     const actual = kbn.valueFormats.dateTimeAsIso(epoch);
     expect(actual).toBe(expected);
   });
 
-  it('should format as iso date (in UTC)', function() {
+  it('should format as iso date (in UTC)', () => {
     const expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
     const actual = kbn.valueFormats.dateTimeAsIso(epoch, true);
     expect(actual).toBe(expected);
   });
 
-  it('should format as iso date and skip date when today', function() {
+  it('should format as iso date and skip date when today', () => {
     const now = moment();
     const expected = now.format('HH:mm:ss');
     const actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), false);
     expect(actual).toBe(expected);
   });
 
-  it('should format as iso date (in UTC) and skip date when today', function() {
+  it('should format as iso date (in UTC) and skip date when today', () => {
     const now = moment.utc();
     const expected = now.format('HH:mm:ss');
     const actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), true);
     expect(actual).toBe(expected);
   });
 
-  it('should format as US date', function() {
+  it('should format as US date', () => {
     const expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
     const actual = kbn.valueFormats.dateTimeAsUS(epoch, false);
     expect(actual).toBe(expected);
   });
 
-  it('should format as US date (in UTC)', function() {
+  it('should format as US date (in UTC)', () => {
     const expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
     const actual = kbn.valueFormats.dateTimeAsUS(epoch, true);
     expect(actual).toBe(expected);
   });
 
-  it('should format as US date and skip date when today', function() {
+  it('should format as US date and skip date when today', () => {
     const now = moment();
     const expected = now.format('h:mm:ss a');
     const actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), false);
     expect(actual).toBe(expected);
   });
 
-  it('should format as US date (in UTC) and skip date when today', function() {
+  it('should format as US date (in UTC) and skip date when today', () => {
     const now = moment.utc();
     const expected = now.format('h:mm:ss a');
     const actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), true);
     expect(actual).toBe(expected);
   });
 
-  it('should format as from now with days', function() {
+  it('should format as from now with days', () => {
     const daysAgo = moment().add(-7, 'd');
     const expected = '7 days ago';
     const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
     expect(actual).toBe(expected);
   });
 
-  it('should format as from now with days (in UTC)', function() {
+  it('should format as from now with days (in UTC)', () => {
     const daysAgo = moment.utc().add(-7, 'd');
     const expected = '7 days ago';
     const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
     expect(actual).toBe(expected);
   });
 
-  it('should format as from now with minutes', function() {
+  it('should format as from now with minutes', () => {
     const daysAgo = moment().add(-2, 'm');
     const expected = '2 minutes ago';
     const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
     expect(actual).toBe(expected);
   });
 
-  it('should format as from now with minutes (in UTC)', function() {
+  it('should format as from now with minutes (in UTC)', () => {
     const daysAgo = moment.utc().add(-2, 'm');
     const expected = '2 minutes ago';
     const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
@@ -186,92 +186,92 @@ describe('date time formats', function() {
   });
 });
 
-describe('kbn.toFixed and negative decimals', function() {
-  it('should treat as zero decimals', function() {
+describe('kbn.toFixed and negative decimals', () => {
+  it('should treat as zero decimals', () => {
     const str = kbn.toFixed(186.123, -2);
     expect(str).toBe('186');
   });
 });
 
-describe('kbn ms format when scaled decimals is null do not use it', function() {
-  it('should use specified decimals', function() {
+describe('kbn ms format when scaled decimals is null do not use it', () => {
+  it('should use specified decimals', () => {
     const str = kbn.valueFormats['ms'](10000086.123, 1, null);
     expect(str).toBe('2.8 hour');
   });
 });
 
-describe('kbn kbytes format when scaled decimals is null do not use it', function() {
-  it('should use specified decimals', function() {
+describe('kbn kbytes format when scaled decimals is null do not use it', () => {
+  it('should use specified decimals', () => {
     const str = kbn.valueFormats['kbytes'](10000000, 3, null);
     expect(str).toBe('9.537 GiB');
   });
 });
 
-describe('kbn deckbytes format when scaled decimals is null do not use it', function() {
-  it('should use specified decimals', function() {
+describe('kbn deckbytes format when scaled decimals is null do not use it', () => {
+  it('should use specified decimals', () => {
     const str = kbn.valueFormats['deckbytes'](10000000, 3, null);
     expect(str).toBe('10.000 GB');
   });
 });
 
-describe('kbn roundValue', function() {
-  it('should should handle null value', function() {
+describe('kbn roundValue', () => {
+  it('should should handle null value', () => {
     const str = kbn.roundValue(null, 2);
     expect(str).toBe(null);
   });
-  it('should round value', function() {
+  it('should round value', () => {
     const str = kbn.roundValue(200.877, 2);
     expect(str).toBe(200.88);
   });
 });
 
-describe('calculateInterval', function() {
-  it('1h 100 resultion', function() {
+describe('calculateInterval', () => {
+  it('1h 100 resultion', () => {
     const range = { from: dateMath.parse('now-1h'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 100, null);
     expect(res.interval).toBe('30s');
   });
 
-  it('10m 1600 resolution', function() {
+  it('10m 1600 resolution', () => {
     const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 1600, null);
     expect(res.interval).toBe('500ms');
     expect(res.intervalMs).toBe(500);
   });
 
-  it('fixed user min interval', function() {
+  it('fixed user min interval', () => {
     const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 1600, '10s');
     expect(res.interval).toBe('10s');
     expect(res.intervalMs).toBe(10000);
   });
 
-  it('short time range and user low limit', function() {
+  it('short time range and user low limit', () => {
     const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 1600, '>10s');
     expect(res.interval).toBe('10s');
   });
 
-  it('large time range and user low limit', function() {
+  it('large time range and user low limit', () => {
     const range = { from: dateMath.parse('now-14d'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 1000, '>10s');
     expect(res.interval).toBe('20m');
   });
 
-  it('10s 900 resolution and user low limit in ms', function() {
+  it('10s 900 resolution and user low limit in ms', () => {
     const range = { from: dateMath.parse('now-10s'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 900, '>15ms');
     expect(res.interval).toBe('15ms');
   });
 
-  it('1d 1 resolution', function() {
+  it('1d 1 resolution', () => {
     const range = { from: dateMath.parse('now-1d'), to: dateMath.parse('now') };
     const res = kbn.calculateInterval(range, 1, null);
     expect(res.interval).toBe('1d');
     expect(res.intervalMs).toBe(86400000);
   });
 
-  it('86399s 1 resolution', function() {
+  it('86399s 1 resolution', () => {
     const range = {
       from: dateMath.parse('now-86390s'),
       to: dateMath.parse('now'),
@@ -282,140 +282,140 @@ describe('calculateInterval', function() {
   });
 });
 
-describe('hex', function() {
-  it('positive integer', function() {
+describe('hex', () => {
+  it('positive integer', () => {
     const str = kbn.valueFormats.hex(100, 0);
     expect(str).toBe('64');
   });
-  it('negative integer', function() {
+  it('negative integer', () => {
     const str = kbn.valueFormats.hex(-100, 0);
     expect(str).toBe('-64');
   });
-  it('null', function() {
+  it('null', () => {
     const str = kbn.valueFormats.hex(null, 0);
     expect(str).toBe('');
   });
-  it('positive float', function() {
+  it('positive float', () => {
     const str = kbn.valueFormats.hex(50.52, 1);
     expect(str).toBe('32.8');
   });
-  it('negative float', function() {
+  it('negative float', () => {
     const str = kbn.valueFormats.hex(-50.333, 2);
     expect(str).toBe('-32.547AE147AE14');
   });
 });
 
-describe('hex 0x', function() {
-  it('positive integeter', function() {
+describe('hex 0x', () => {
+  it('positive integeter', () => {
     const str = kbn.valueFormats.hex0x(7999, 0);
     expect(str).toBe('0x1F3F');
   });
-  it('negative integer', function() {
+  it('negative integer', () => {
     const str = kbn.valueFormats.hex0x(-584, 0);
     expect(str).toBe('-0x248');
   });
-  it('null', function() {
+  it('null', () => {
     const str = kbn.valueFormats.hex0x(null, 0);
     expect(str).toBe('');
   });
-  it('positive float', function() {
+  it('positive float', () => {
     const str = kbn.valueFormats.hex0x(74.443, 3);
     expect(str).toBe('0x4A.716872B020C4');
   });
-  it('negative float', function() {
+  it('negative float', () => {
     const str = kbn.valueFormats.hex0x(-65.458, 1);
     expect(str).toBe('-0x41.8');
   });
 });
 
-describe('duration', function() {
-  it('null', function() {
+describe('duration', () => {
+  it('null', () => {
     const str = kbn.toDuration(null, 0, 'millisecond');
     expect(str).toBe('');
   });
-  it('0 milliseconds', function() {
+  it('0 milliseconds', () => {
     const str = kbn.toDuration(0, 0, 'millisecond');
     expect(str).toBe('0 milliseconds');
   });
-  it('1 millisecond', function() {
+  it('1 millisecond', () => {
     const str = kbn.toDuration(1, 0, 'millisecond');
     expect(str).toBe('1 millisecond');
   });
-  it('-1 millisecond', function() {
+  it('-1 millisecond', () => {
     const str = kbn.toDuration(-1, 0, 'millisecond');
     expect(str).toBe('1 millisecond ago');
   });
-  it('seconds', function() {
+  it('seconds', () => {
     const str = kbn.toDuration(1, 0, 'second');
     expect(str).toBe('1 second');
   });
-  it('minutes', function() {
+  it('minutes', () => {
     const str = kbn.toDuration(1, 0, 'minute');
     expect(str).toBe('1 minute');
   });
-  it('hours', function() {
+  it('hours', () => {
     const str = kbn.toDuration(1, 0, 'hour');
     expect(str).toBe('1 hour');
   });
-  it('days', function() {
+  it('days', () => {
     const str = kbn.toDuration(1, 0, 'day');
     expect(str).toBe('1 day');
   });
-  it('weeks', function() {
+  it('weeks', () => {
     const str = kbn.toDuration(1, 0, 'week');
     expect(str).toBe('1 week');
   });
-  it('months', function() {
+  it('months', () => {
     const str = kbn.toDuration(1, 0, 'month');
     expect(str).toBe('1 month');
   });
-  it('years', function() {
+  it('years', () => {
     const str = kbn.toDuration(1, 0, 'year');
     expect(str).toBe('1 year');
   });
-  it('decimal days', function() {
+  it('decimal days', () => {
     const str = kbn.toDuration(1.5, 2, 'day');
     expect(str).toBe('1 day, 12 hours, 0 minutes');
   });
-  it('decimal months', function() {
+  it('decimal months', () => {
     const str = kbn.toDuration(1.5, 3, 'month');
     expect(str).toBe('1 month, 2 weeks, 1 day, 0 hours');
   });
-  it('no decimals', function() {
+  it('no decimals', () => {
     const str = kbn.toDuration(38898367008, 0, 'millisecond');
     expect(str).toBe('1 year');
   });
-  it('1 decimal', function() {
+  it('1 decimal', () => {
     const str = kbn.toDuration(38898367008, 1, 'millisecond');
     expect(str).toBe('1 year, 2 months');
   });
-  it('too many decimals', function() {
+  it('too many decimals', () => {
     const str = kbn.toDuration(38898367008, 20, 'millisecond');
     expect(str).toBe('1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds');
   });
-  it('floating point error', function() {
+  it('floating point error', () => {
     const str = kbn.toDuration(36993906007, 8, 'millisecond');
     expect(str).toBe('1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds');
   });
 });
 
-describe('volume', function() {
-  it('1000m3', function() {
+describe('volume', () => {
+  it('1000m3', () => {
     const str = kbn.valueFormats['m3'](1000, 1, null);
     expect(str).toBe('1000.0 m³');
   });
 });
 
-describe('hh:mm:ss', function() {
-  it('00:04:06', function() {
+describe('hh:mm:ss', () => {
+  it('00:04:06', () => {
     const str = kbn.valueFormats['dthms'](246, 1);
     expect(str).toBe('00:04:06');
   });
-  it('24:00:00', function() {
+  it('24:00:00', () => {
     const str = kbn.valueFormats['dthms'](86400, 1);
     expect(str).toBe('24:00:00');
   });
-  it('6824413:53:20', function() {
+  it('6824413:53:20', () => {
     const str = kbn.valueFormats['dthms'](24567890000, 1);
     expect(str).toBe('6824413:53:20');
   });

+ 72 - 72
public/app/core/specs/time_series.test.ts

@@ -1,33 +1,33 @@
 import TimeSeries from 'app/core/time_series2';
 import { updateLegendValues } from 'app/core/time_series2';
 
-describe('TimeSeries', function() {
+describe('TimeSeries', () => {
   let points, series;
   const yAxisFormats = ['short', 'ms'];
   let testData;
 
-  beforeEach(function() {
+  beforeEach(() => {
     testData = {
       alias: 'test',
       datapoints: [[1, 2], [null, 3], [10, 4], [8, 5]],
     };
   });
 
-  describe('when getting flot pairs', function() {
-    it('with connected style, should ignore nulls', function() {
+  describe('when getting flot pairs', () => {
+    it('with connected style, should ignore nulls', () => {
       series = new TimeSeries(testData);
       points = series.getFlotPairs('connected', yAxisFormats);
       expect(points.length).toBe(3);
     });
 
-    it('with null as zero style, should replace nulls with zero', function() {
+    it('with null as zero style, should replace nulls with zero', () => {
       series = new TimeSeries(testData);
       points = series.getFlotPairs('null as zero', yAxisFormats);
       expect(points.length).toBe(4);
       expect(points[1][1]).toBe(0);
     });
 
-    it('if last is null current should pick next to last', function() {
+    it('if last is null current should pick next to last', () => {
       series = new TimeSeries({
         datapoints: [[10, 1], [null, 2]],
       });
@@ -35,7 +35,7 @@ describe('TimeSeries', function() {
       expect(series.stats.current).toBe(10);
     });
 
-    it('max value should work for negative values', function() {
+    it('max value should work for negative values', () => {
       series = new TimeSeries({
         datapoints: [[-10, 1], [-4, 2]],
       });
@@ -43,13 +43,13 @@ describe('TimeSeries', function() {
       expect(series.stats.max).toBe(-4);
     });
 
-    it('average value should ignore nulls', function() {
+    it('average value should ignore nulls', () => {
       series = new TimeSeries(testData);
       series.getFlotPairs('null', yAxisFormats);
       expect(series.stats.avg).toBe(6.333333333333333);
     });
 
-    it('the delta value should account for nulls', function() {
+    it('the delta value should account for nulls', () => {
       series = new TimeSeries({
         datapoints: [[1, 2], [3, 3], [null, 4], [10, 5], [15, 6]],
       });
@@ -57,7 +57,7 @@ describe('TimeSeries', function() {
       expect(series.stats.delta).toBe(14);
     });
 
-    it('the delta value should account for nulls on first', function() {
+    it('the delta value should account for nulls on first', () => {
       series = new TimeSeries({
         datapoints: [[null, 2], [1, 3], [10, 4], [15, 5]],
       });
@@ -65,7 +65,7 @@ describe('TimeSeries', function() {
       expect(series.stats.delta).toBe(14);
     });
 
-    it('the delta value should account for nulls on last', function() {
+    it('the delta value should account for nulls on last', () => {
       series = new TimeSeries({
         datapoints: [[1, 2], [5, 3], [10, 4], [null, 5]],
       });
@@ -73,7 +73,7 @@ describe('TimeSeries', function() {
       expect(series.stats.delta).toBe(9);
     });
 
-    it('the delta value should account for resets', function() {
+    it('the delta value should account for resets', () => {
       series = new TimeSeries({
         datapoints: [[1, 2], [5, 3], [10, 4], [0, 5], [10, 6]],
       });
@@ -81,7 +81,7 @@ describe('TimeSeries', function() {
       expect(series.stats.delta).toBe(19);
     });
 
-    it('the delta value should account for resets on last', function() {
+    it('the delta value should account for resets on last', () => {
       series = new TimeSeries({
         datapoints: [[1, 2], [2, 3], [10, 4], [8, 5]],
       });
@@ -89,13 +89,13 @@ describe('TimeSeries', function() {
       expect(series.stats.delta).toBe(17);
     });
 
-    it('the range value should be max - min', function() {
+    it('the range value should be max - min', () => {
       series = new TimeSeries(testData);
       series.getFlotPairs('null', yAxisFormats);
       expect(series.stats.range).toBe(9);
     });
 
-    it('first value should ingone nulls', function() {
+    it('first value should ingone nulls', () => {
       series = new TimeSeries(testData);
       series.getFlotPairs('null', yAxisFormats);
       expect(series.stats.first).toBe(1);
@@ -106,13 +106,13 @@ describe('TimeSeries', function() {
       expect(series.stats.first).toBe(1);
     });
 
-    it('with null as zero style, average value should treat nulls as 0', function() {
+    it('with null as zero style, average value should treat nulls as 0', () => {
       series = new TimeSeries(testData);
       series.getFlotPairs('null as zero', yAxisFormats);
       expect(series.stats.avg).toBe(4.75);
     });
 
-    it('average value should be null if all values is null', function() {
+    it('average value should be null if all values is null', () => {
       series = new TimeSeries({
         datapoints: [[null, 2], [null, 3], [null, 4], [null, 5]],
       });
@@ -120,7 +120,7 @@ describe('TimeSeries', function() {
       expect(series.stats.avg).toBe(null);
     });
 
-    it('calculates timeStep', function() {
+    it('calculates timeStep', () => {
       series = new TimeSeries({
         datapoints: [[null, 1], [null, 2], [null, 3]],
       });
@@ -135,190 +135,190 @@ describe('TimeSeries', function() {
     });
   });
 
-  describe('When checking if ms resolution is needed', function() {
-    describe('msResolution with second resolution timestamps', function() {
-      beforeEach(function() {
+  describe('When checking if ms resolution is needed', () => {
+    describe('msResolution with second resolution timestamps', () => {
+      beforeEach(() => {
         series = new TimeSeries({
           datapoints: [[45, 1234567890], [60, 1234567899]],
         });
       });
 
-      it('should set hasMsResolution to false', function() {
+      it('should set hasMsResolution to false', () => {
         expect(series.hasMsResolution).toBe(false);
       });
     });
 
-    describe('msResolution with millisecond resolution timestamps', function() {
-      beforeEach(function() {
+    describe('msResolution with millisecond resolution timestamps', () => {
+      beforeEach(() => {
         series = new TimeSeries({
           datapoints: [[55, 1236547890001], [90, 1234456709000]],
         });
       });
 
-      it('should show millisecond resolution tooltip', function() {
+      it('should show millisecond resolution tooltip', () => {
         expect(series.hasMsResolution).toBe(true);
       });
     });
 
-    describe('msResolution with millisecond resolution timestamps but with trailing zeroes', function() {
-      beforeEach(function() {
+    describe('msResolution with millisecond resolution timestamps but with trailing zeroes', () => {
+      beforeEach(() => {
         series = new TimeSeries({
           datapoints: [[45, 1234567890000], [60, 1234567899000]],
         });
       });
 
-      it('should not show millisecond resolution tooltip', function() {
+      it('should not show millisecond resolution tooltip', () => {
         expect(series.hasMsResolution).toBe(false);
       });
     });
   });
 
-  describe('can detect if series contains ms precision', function() {
+  describe('can detect if series contains ms precision', () => {
     let fakedata;
 
-    beforeEach(function() {
+    beforeEach(() => {
       fakedata = testData;
     });
 
-    it('missing datapoint with ms precision', function() {
+    it('missing datapoint with ms precision', () => {
       fakedata.datapoints[0] = [1337, 1234567890000];
       series = new TimeSeries(fakedata);
       expect(series.isMsResolutionNeeded()).toBe(false);
     });
 
-    it('contains datapoint with ms precision', function() {
+    it('contains datapoint with ms precision', () => {
       fakedata.datapoints[0] = [1337, 1236547890001];
       series = new TimeSeries(fakedata);
       expect(series.isMsResolutionNeeded()).toBe(true);
     });
   });
 
-  describe('series overrides', function() {
+  describe('series overrides', () => {
     let series;
-    beforeEach(function() {
+    beforeEach(() => {
       series = new TimeSeries(testData);
     });
 
-    describe('fill & points', function() {
-      beforeEach(function() {
+    describe('fill & points', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', fill: 0, points: true }]);
       });
 
-      it('should set fill zero, and enable points', function() {
+      it('should set fill zero, and enable points', () => {
         expect(series.lines.fill).toBe(0.001);
         expect(series.points.show).toBe(true);
       });
     });
 
-    describe('series option overrides, bars, true & lines false', function() {
-      beforeEach(function() {
+    describe('series option overrides, bars, true & lines false', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', bars: true, lines: false }]);
       });
 
-      it('should disable lines, and enable bars', function() {
+      it('should disable lines, and enable bars', () => {
         expect(series.lines.show).toBe(false);
         expect(series.bars.show).toBe(true);
       });
     });
 
-    describe('series option overrides, linewidth, stack', function() {
-      beforeEach(function() {
+    describe('series option overrides, linewidth, stack', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', linewidth: 5, stack: false }]);
       });
 
-      it('should disable stack, and set lineWidth', function() {
+      it('should disable stack, and set lineWidth', () => {
         expect(series.stack).toBe(false);
         expect(series.lines.lineWidth).toBe(5);
       });
     });
 
-    describe('series option overrides, dashes and lineWidth', function() {
-      beforeEach(function() {
+    describe('series option overrides, dashes and lineWidth', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', linewidth: 5, dashes: true }]);
       });
 
-      it('should enable dashes, set dashes lineWidth to 5 and lines lineWidth to 0', function() {
+      it('should enable dashes, set dashes lineWidth to 5 and lines lineWidth to 0', () => {
         expect(series.dashes.show).toBe(true);
         expect(series.dashes.lineWidth).toBe(5);
         expect(series.lines.lineWidth).toBe(0);
       });
     });
 
-    describe('series option overrides, fill below to', function() {
-      beforeEach(function() {
+    describe('series option overrides, fill below to', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', fillBelowTo: 'min' }]);
       });
 
-      it('should disable line fill and add fillBelowTo', function() {
+      it('should disable line fill and add fillBelowTo', () => {
         expect(series.fillBelowTo).toBe('min');
       });
     });
 
-    describe('series option overrides, pointradius, steppedLine', function() {
-      beforeEach(function() {
+    describe('series option overrides, pointradius, steppedLine', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', pointradius: 5, steppedLine: true }]);
       });
 
-      it('should set pointradius, and set steppedLine', function() {
+      it('should set pointradius, and set steppedLine', () => {
         expect(series.points.radius).toBe(5);
         expect(series.lines.steps).toBe(true);
       });
     });
 
-    describe('override match on regex', function() {
-      beforeEach(function() {
+    describe('override match on regex', () => {
+      beforeEach(() => {
         series.alias = 'test_01';
         series.applySeriesOverrides([{ alias: '/.*01/', lines: false }]);
       });
 
-      it('should match second series', function() {
+      it('should match second series', () => {
         expect(series.lines.show).toBe(false);
       });
     });
 
-    describe('override series y-axis, and z-index', function() {
-      beforeEach(function() {
+    describe('override series y-axis, and z-index', () => {
+      beforeEach(() => {
         series.alias = 'test';
         series.applySeriesOverrides([{ alias: 'test', yaxis: 2, zindex: 2 }]);
       });
 
-      it('should set yaxis', function() {
+      it('should set yaxis', () => {
         expect(series.yaxis).toBe(2);
       });
 
-      it('should set zindex', function() {
+      it('should set zindex', () => {
         expect(series.zindex).toBe(2);
       });
     });
 
-    describe('override color', function() {
-      beforeEach(function() {
+    describe('override color', () => {
+      beforeEach(() => {
         series.applySeriesOverrides([{ alias: 'test', color: '#112233' }]);
       });
 
-      it('should set color', function() {
+      it('should set color', () => {
         expect(series.color).toBe('#112233');
       });
 
-      it('should set bars.fillColor', function() {
+      it('should set bars.fillColor', () => {
         expect(series.bars.fillColor).toBe('#112233');
       });
     });
   });
 
-  describe('value formatter', function() {
+  describe('value formatter', () => {
     let series;
-    beforeEach(function() {
+    beforeEach(() => {
       series = new TimeSeries(testData);
     });
 
-    it('should format non-numeric values as empty string', function() {
+    it('should format non-numeric values as empty string', () => {
       expect(series.formatValue(null)).toBe('');
       expect(series.formatValue(undefined)).toBe('');
       expect(series.formatValue(NaN)).toBe('');
@@ -327,10 +327,10 @@ describe('TimeSeries', function() {
     });
   });
 
-  describe('legend decimals', function() {
+  describe('legend decimals', () => {
     let series, panel;
     const height = 200;
-    beforeEach(function() {
+    beforeEach(() => {
       testData = {
         alias: 'test',
         datapoints: [[1, 2], [0, 3], [10, 4], [8, 5]],
@@ -347,14 +347,14 @@ describe('TimeSeries', function() {
       };
     });
 
-    it('should set decimals based on Y axis (expect calculated decimals = 1)', function() {
+    it('should set decimals based on Y axis (expect calculated decimals = 1)', () => {
       const data = [series];
       // Expect ticks with this data will have decimals = 1
       updateLegendValues(data, panel, height);
       expect(data[0].decimals).toBe(2);
     });
 
-    it('should set decimals based on Y axis to 0 if calculated decimals = 0)', function() {
+    it('should set decimals based on Y axis to 0 if calculated decimals = 0)', () => {
       testData.datapoints = [[10, 2], [0, 3], [100, 4], [80, 5]];
       series = new TimeSeries(testData);
       series.getFlotPairs();
@@ -363,14 +363,14 @@ describe('TimeSeries', function() {
       expect(data[0].decimals).toBe(0);
     });
 
-    it('should set decimals to Y axis decimals + 1', function() {
+    it('should set decimals to Y axis decimals + 1', () => {
       panel.yaxes[0].decimals = 2;
       const data = [series];
       updateLegendValues(data, panel, height);
       expect(data[0].decimals).toBe(3);
     });
 
-    it('should set decimals to legend decimals value if it was set explicitly', function() {
+    it('should set decimals to legend decimals value if it was set explicitly', () => {
       panel.decimals = 3;
       const data = [series];
       updateLegendValues(data, panel, height);

+ 10 - 10
public/app/core/utils/css_loader.ts

@@ -9,8 +9,8 @@ for (let i = 0; i < links.length; i++) {
 }
 
 const isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/);
-const webkitLoadCheck = function(link, callback) {
-  setTimeout(function() {
+const webkitLoadCheck = (link, callback) => {
+  setTimeout(() => {
     for (let i = 0; i < document.styleSheets.length; i++) {
       const sheet = document.styleSheets[i];
       if (sheet.href === link.href) {
@@ -21,19 +21,19 @@ const webkitLoadCheck = function(link, callback) {
   }, 10);
 };
 
-const noop = function() {};
+const noop = () => {};
 
-const loadCSS = function(url) {
-  return new Promise(function(resolve, reject) {
+const loadCSS = url => {
+  return new Promise((resolve, reject) => {
     const link = document.createElement('link');
-    const timeout = setTimeout(function() {
+    const timeout = setTimeout(() => {
       reject('Unable to load CSS');
     }, waitSeconds * 1000);
 
-    const _callback = function(error) {
+    const _callback = error => {
       clearTimeout(timeout);
       link.onload = link.onerror = noop;
-      setTimeout(function() {
+      setTimeout(() => {
         if (error) {
           reject(error);
         } else {
@@ -47,14 +47,14 @@ const loadCSS = function(url) {
     link.href = url;
 
     if (!isWebkit) {
-      link.onload = function() {
+      link.onload = () => {
         _callback(undefined);
       };
     } else {
       webkitLoadCheck(link, _callback);
     }
 
-    link.onerror = function(evt: any) {
+    link.onerror = (evt: any) => {
       _callback(evt.error || new Error('Error loading CSS file.'));
     };
 

+ 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/dashboard/change_tracker.ts

@@ -128,7 +128,7 @@ export class ChangeTracker {
     });
 
     // ignore template variable values
-    _.each(dash.templating.list, function(value) {
+    _.each(dash.templating.list, value => {
       value.current = null;
       value.options = null;
       value.filters = null;

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

@@ -59,7 +59,7 @@ export class DashboardLoaderSrv {
         });
     }
 
-    promise.then(function(result) {
+    promise.then(result => {
       if (result.meta.dashboardNotFound !== true) {
         impressionSrv.addDashboardImpression(result.dashboard.id);
       }

+ 2 - 2
public/app/features/dashboard/repeat_option/repeat_option.ts

@@ -15,7 +15,7 @@ function dashRepeatOptionDirective(variableSrv) {
     scope: {
       panel: '=',
     },
-    link: function(scope, element) {
+    link: (scope, element) => {
       element.css({ display: 'block', width: '100%' });
 
       scope.variables = variableSrv.variables.map(item => {
@@ -36,7 +36,7 @@ function dashRepeatOptionDirective(variableSrv) {
         scope.panel.repeatDirection = 'h';
       }
 
-      scope.optionChanged = function() {
+      scope.optionChanged = () => {
         if (scope.panel.repeat) {
           scope.panel.repeatDirection = 'h';
         }

+ 4 - 4
public/app/features/dashboard/shareModalCtrl.ts

@@ -11,7 +11,7 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv,
   };
   $scope.editor = { index: $scope.tabIndex || 0 };
 
-  $scope.init = function() {
+  $scope.init = () => {
     $scope.modeSharePanel = $scope.panel ? true : false;
 
     $scope.tabs = [{ title: 'Link', src: 'shareLink.html' }];
@@ -34,7 +34,7 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv,
     $scope.buildUrl();
   };
 
-  $scope.buildUrl = function() {
+  $scope.buildUrl = () => {
     let baseUrl = $location.absUrl();
     const queryStart = baseUrl.indexOf('?');
 
@@ -90,7 +90,7 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv,
 
   // This function will try to return the proper full name of the local timezone
   // Chrome does not handle the timezone offset (but phantomjs does)
-  $scope.getLocalTimeZone = function() {
+  $scope.getLocalTimeZone = () => {
     const utcOffset = '&tz=UTC' + encodeURIComponent(moment().format('Z'));
 
     // Older browser does not the internationalization API
@@ -111,7 +111,7 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv,
     return '&tz=' + encodeURIComponent(options.timeZone);
   };
 
-  $scope.getShareUrl = function() {
+  $scope.getShareUrl = () => {
     return $scope.shareUrl;
   };
 }

+ 18 - 18
public/app/features/dashboard/share_snapshot_ctrl.ts

@@ -25,8 +25,8 @@ export class ShareSnapshotCtrl {
       { text: 'Public on the web', value: 3 },
     ];
 
-    $scope.init = function() {
-      backendSrv.get('/api/snapshot/shared-options').then(function(options) {
+    $scope.init = () => {
+      backendSrv.get('/api/snapshot/shared-options').then(options => {
         $scope.externalUrl = options['externalSnapshotURL'];
         $scope.sharingButtonText = options['externalSnapshotName'];
         $scope.externalEnabled = options['externalEnabled'];
@@ -35,7 +35,7 @@ export class ShareSnapshotCtrl {
 
     $scope.apiUrl = '/api/snapshots';
 
-    $scope.createSnapshot = function(external) {
+    $scope.createSnapshot = external => {
       $scope.dashboard.snapshot = {
         timestamp: new Date(),
       };
@@ -49,12 +49,12 @@ export class ShareSnapshotCtrl {
 
       $rootScope.$broadcast('refresh');
 
-      $timeout(function() {
+      $timeout(() => {
         $scope.saveSnapshot(external);
       }, $scope.snapshot.timeoutSeconds * 1000);
     };
 
-    $scope.saveSnapshot = function(external) {
+    $scope.saveSnapshot = external => {
       const dash = $scope.dashboard.getSaveModelClone();
       $scope.scrubDashboard(dash);
 
@@ -67,7 +67,7 @@ export class ShareSnapshotCtrl {
       const postUrl = external ? $scope.externalUrl + $scope.apiUrl : $scope.apiUrl;
 
       backendSrv.post(postUrl, cmdData).then(
-        function(results) {
+        results => {
           $scope.loading = false;
 
           if (external) {
@@ -88,17 +88,17 @@ export class ShareSnapshotCtrl {
 
           $scope.step = 2;
         },
-        function() {
+        () => {
           $scope.loading = false;
         }
       );
     };
 
-    $scope.getSnapshotUrl = function() {
+    $scope.getSnapshotUrl = () => {
       return $scope.snapshotUrl;
     };
 
-    $scope.scrubDashboard = function(dash) {
+    $scope.scrubDashboard = dash => {
       // change title
       dash.title = $scope.snapshot.name;
 
@@ -106,7 +106,7 @@ export class ShareSnapshotCtrl {
       dash.time = timeSrv.timeRange();
 
       // remove panel queries & links
-      _.each(dash.panels, function(panel) {
+      _.each(dash.panels, panel => {
         panel.targets = [];
         panel.links = [];
         panel.datasource = null;
@@ -114,10 +114,10 @@ export class ShareSnapshotCtrl {
 
       // remove annotation queries
       dash.annotations.list = _.chain(dash.annotations.list)
-        .filter(function(annotation) {
+        .filter(annotation => {
           return annotation.enable;
         })
-        .map(function(annotation) {
+        .map(annotation => {
           return {
             name: annotation.name,
             enable: annotation.enable,
@@ -131,7 +131,7 @@ export class ShareSnapshotCtrl {
         .value();
 
       // remove template queries
-      _.each(dash.templating.list, function(variable) {
+      _.each(dash.templating.list, variable => {
         variable.query = '';
         variable.options = variable.current;
         variable.refresh = false;
@@ -149,21 +149,21 @@ export class ShareSnapshotCtrl {
 
       // cleanup snapshotData
       delete $scope.dashboard.snapshot;
-      $scope.dashboard.forEachPanel(function(panel) {
+      $scope.dashboard.forEachPanel(panel => {
         delete panel.snapshotData;
       });
-      _.each($scope.dashboard.annotations.list, function(annotation) {
+      _.each($scope.dashboard.annotations.list, annotation => {
         delete annotation.snapshotData;
       });
     };
 
-    $scope.deleteSnapshot = function() {
-      backendSrv.get($scope.deleteUrl).then(function() {
+    $scope.deleteSnapshot = () => {
+      backendSrv.get($scope.deleteUrl).then(() => {
         $scope.step = 3;
       });
     };
 
-    $scope.saveExternalSnapshotRef = function(cmdData, results) {
+    $scope.saveExternalSnapshotRef = (cmdData, results) => {
       // save external in local instance as well
       cmdData.external = true;
       cmdData.key = results.key;

+ 3 - 3
public/app/features/dashboard/timepicker/input_date.ts

@@ -5,10 +5,10 @@ export function inputDateDirective() {
   return {
     restrict: 'A',
     require: 'ngModel',
-    link: function($scope, $elem, attrs, ngModel) {
+    link: ($scope, $elem, attrs, ngModel) => {
       const format = 'YYYY-MM-DD HH:mm:ss';
 
-      const fromUser = function(text) {
+      const fromUser = text => {
         if (text.indexOf('now') !== -1) {
           if (!dateMath.isValid(text)) {
             ngModel.$setValidity('error', false);
@@ -34,7 +34,7 @@ export function inputDateDirective() {
         return parsed;
       };
 
-      const toUser = function(currentValue) {
+      const toUser = currentValue => {
         if (moment.isMoment(currentValue)) {
           return currentValue.format(format);
         } else {

+ 4 - 4
public/app/features/dashboard/upload.ts

@@ -16,11 +16,11 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
     scope: {
       onUpload: '&',
     },
-    link: function(scope) {
+    link: scope => {
       function file_selected(evt) {
         const files = evt.target.files; // FileList object
-        const readerOnload = function() {
-          return function(e) {
+        const readerOnload = () => {
+          return e => {
             let dash;
             try {
               dash = JSON.parse(e.target.result);
@@ -30,7 +30,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
               return;
             }
 
-            scope.$apply(function() {
+            scope.$apply(() => {
               scope.onUpload({ dash: dash });
             });
           };

+ 7 - 7
public/app/features/dashboard/view_state_srv.ts

@@ -22,18 +22,18 @@ export class DashboardViewState {
     self.$scope = $scope;
     self.dashboard = $scope.dashboard;
 
-    $scope.onAppEvent('$routeUpdate', function() {
+    $scope.onAppEvent('$routeUpdate', () => {
       const urlState = self.getQueryStringState();
       if (self.needsSync(urlState)) {
         self.update(urlState, true);
       }
     });
 
-    $scope.onAppEvent('panel-change-view', function(evt, payload) {
+    $scope.onAppEvent('panel-change-view', (evt, payload) => {
       self.update(payload);
     });
 
-    $scope.onAppEvent('panel-initialized', function(evt, payload) {
+    $scope.onAppEvent('panel-initialized', (evt, payload) => {
       self.registerPanel(payload.scope);
     });
 
@@ -156,7 +156,7 @@ export class DashboardViewState {
   }
 
   getPanelScope(id) {
-    return _.find(this.panelScopes, function(panelScope) {
+    return _.find(this.panelScopes, panelScope => {
       return panelScope.ctrl.panel.id === id;
     });
   }
@@ -176,7 +176,7 @@ export class DashboardViewState {
       return false;
     }
 
-    this.$timeout(function() {
+    this.$timeout(() => {
       if (self.oldTimeRange !== ctrl.range) {
         self.$rootScope.$broadcast('refresh');
       } else {
@@ -216,7 +216,7 @@ export class DashboardViewState {
       }
     }
 
-    const unbind = panelScope.$on('$destroy', function() {
+    const unbind = panelScope.$on('$destroy', () => {
       self.panelScopes = _.without(self.panelScopes, panelScope);
       unbind();
     });
@@ -226,7 +226,7 @@ export class DashboardViewState {
 /** @ngInject */
 export function dashboardViewStateSrv($location, $timeout, $rootScope) {
   return {
-    create: function($scope) {
+    create: $scope => {
       return new DashboardViewState($scope, $location, $timeout, $rootScope);
     },
   };

+ 9 - 9
public/app/features/dashlinks/module.ts

@@ -10,7 +10,7 @@ function dashLinksContainer() {
     restrict: 'E',
     controller: 'DashLinksContainerCtrl',
     template: '<dash-link ng-repeat="link in generatedLinks" link="link"></dash-link>',
-    link: function() {},
+    link: () => {},
   };
 }
 
@@ -18,7 +18,7 @@ function dashLinksContainer() {
 function dashLink($compile, $sanitize, linkSrv) {
   return {
     restrict: 'E',
-    link: function(scope, elem) {
+    link: (scope, elem) => {
       const link = scope.link;
       let template =
         '<div class="gf-form">' +
@@ -130,16 +130,16 @@ export class DashLinksContainerCtrl {
     function updateDashLinks() {
       const promises = _.map($scope.links, buildLinks);
 
-      $q.all(promises).then(function(results) {
+      $q.all(promises).then(results => {
         $scope.generatedLinks = _.flatten(results);
       });
     }
 
-    $scope.searchDashboards = function(link, limit) {
-      return backendSrv.search({ tag: link.tags, limit: limit }).then(function(results) {
+    $scope.searchDashboards = (link, limit) => {
+      return backendSrv.search({ tag: link.tags, limit: limit }).then(results => {
         return _.reduce(
           results,
-          function(memo, dash) {
+          (memo, dash) => {
             // do not add current dashboard
             if (dash.id !== currentDashId) {
               memo.push({
@@ -158,9 +158,9 @@ export class DashLinksContainerCtrl {
       });
     };
 
-    $scope.fillDropdown = function(link) {
-      $scope.searchDashboards(link, 100).then(function(results) {
-        _.each(results, function(hit) {
+    $scope.fillDropdown = link => {
+      $scope.searchDashboards(link, 100).then(results => {
+        _.each(results, hit => {
           hit.url = linkSrv.getLinkUrl(hit);
         });
         link.searchHits = results;

+ 2 - 2
public/app/features/org/change_password_ctrl.ts

@@ -9,7 +9,7 @@ export class ChangePasswordCtrl {
     $scope.ldapEnabled = config.ldapEnabled;
     $scope.navModel = navModelSrv.getNav('profile', 'change-password', 0);
 
-    $scope.changePassword = function() {
+    $scope.changePassword = () => {
       if (!$scope.userForm.$valid) {
         return;
       }
@@ -19,7 +19,7 @@ export class ChangePasswordCtrl {
         return;
       }
 
-      backendSrv.put('/api/user/password', $scope.command).then(function() {
+      backendSrv.put('/api/user/password', $scope.command).then(() => {
         $location.path('profile');
       });
     };

+ 3 - 3
public/app/features/org/new_org_ctrl.ts

@@ -7,9 +7,9 @@ export class NewOrgCtrl {
     $scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1);
     $scope.newOrg = { name: '' };
 
-    $scope.createOrg = function() {
-      backendSrv.post('/api/orgs/', $scope.newOrg).then(function(result) {
-        backendSrv.post('/api/user/using/' + result.orgId).then(function() {
+    $scope.createOrg = () => {
+      backendSrv.post('/api/orgs/', $scope.newOrg).then(result => {
+        backendSrv.post('/api/user/using/' + result.orgId).then(() => {
           window.location.href = config.appSubUrl + '/org';
         });
       });

+ 6 - 6
public/app/features/org/org_api_keys_ctrl.ts

@@ -8,22 +8,22 @@ export class OrgApiKeysCtrl {
     $scope.roleTypes = ['Viewer', 'Editor', 'Admin'];
     $scope.token = { role: 'Viewer' };
 
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.getTokens();
     };
 
-    $scope.getTokens = function() {
-      backendSrv.get('/api/auth/keys').then(function(tokens) {
+    $scope.getTokens = () => {
+      backendSrv.get('/api/auth/keys').then(tokens => {
         $scope.tokens = tokens;
       });
     };
 
-    $scope.removeToken = function(id) {
+    $scope.removeToken = id => {
       backendSrv.delete('/api/auth/keys/' + id).then($scope.getTokens);
     };
 
-    $scope.addToken = function() {
-      backendSrv.post('/api/auth/keys', $scope.token).then(function(result) {
+    $scope.addToken = () => {
+      backendSrv.post('/api/auth/keys', $scope.token).then(result => {
         const modalScope = $scope.$new(true);
         modalScope.key = result.key;
         modalScope.rootPath = window.location.origin + $scope.$root.appSubUrl;

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

@@ -14,18 +14,18 @@ export class SelectOrgCtrl {
       },
     };
 
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.getUserOrgs();
     };
 
-    $scope.getUserOrgs = function() {
-      backendSrv.get('/api/user/orgs').then(function(orgs) {
+    $scope.getUserOrgs = () => {
+      backendSrv.get('/api/user/orgs').then(orgs => {
         $scope.orgs = orgs;
       });
     };
 
-    $scope.setUsingOrg = function(org) {
-      backendSrv.post('/api/user/using/' + org.orgId).then(function() {
+    $scope.setUsingOrg = org => {
+      backendSrv.post('/api/user/using/' + org.orgId).then(() => {
         window.location.href = config.appSubUrl + '/';
       });
     };

+ 7 - 7
public/app/features/panel/panel_directive.ts

@@ -54,13 +54,13 @@ const panelTemplate = `
   </div>
 `;
 
-module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
+module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
   return {
     restrict: 'E',
     template: panelTemplate,
     transclude: true,
     scope: { ctrl: '=' },
-    link: function(scope, elem) {
+    link: (scope, elem) => {
       const panelContainer = elem.find('.panel-container');
       const panelContent = elem.find('.panel-content');
       const cornerInfoElem = elem.find('.panel-info-corner');
@@ -184,7 +184,7 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
 
           infoDrop = new Drop({
             target: cornerInfoElem[0],
-            content: function() {
+            content: () => {
               return ctrl.getInfoContent({ mode: 'tooltip' });
             },
             classes: ctrl.error ? 'drop-error' : 'drop-help',
@@ -208,7 +208,7 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
       scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo);
       scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo);
 
-      cornerInfoElem.on('click', function() {
+      cornerInfoElem.on('click', () => {
         infoDrop.close();
         scope.$apply(ctrl.openInspector.bind(ctrl));
       });
@@ -216,7 +216,7 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
       elem.on('mouseenter', mouseEnter);
       elem.on('mouseleave', mouseLeave);
 
-      scope.$on('$destroy', function() {
+      scope.$on('$destroy', () => {
         elem.off();
         cornerInfoElem.off();
 
@@ -232,7 +232,7 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
   };
 });
 
-module.directive('panelHelpCorner', function($rootScope) {
+module.directive('panelHelpCorner', $rootScope => {
   return {
     restrict: 'E',
     template: `
@@ -242,6 +242,6 @@ module.directive('panelHelpCorner', function($rootScope) {
     </span>
     </span>
     `,
-    link: function(scope, elem) {},
+    link: (scope, elem) => {},
   };
 });

+ 2 - 2
public/app/features/panel/panel_header.ts

@@ -85,12 +85,12 @@ function panelHeader($compile) {
   return {
     restrict: 'E',
     template: template,
-    link: function(scope, elem, attrs) {
+    link: (scope, elem, attrs) => {
       const menuElem = elem.find('.panel-menu');
       let menuScope;
       let isDragged;
 
-      elem.click(function(evt) {
+      elem.click(evt => {
         const targetClass = evt.target.className;
 
         // remove existing scope

+ 2 - 2
public/app/features/panel/query_troubleshooter.ts

@@ -170,8 +170,8 @@ export function queryTroubleshooter() {
       panelCtrl: '=',
       isOpen: '=',
     },
-    link: function(scope, elem, attrs, ctrl) {
-      ctrl.renderJsonExplorer = function(data) {
+    link: (scope, elem, attrs, ctrl) => {
+      ctrl.renderJsonExplorer = data => {
         const jsonElem = elem.find('.query-troubleshooter-json');
 
         ctrl.jsonExplorer = new JsonExplorer(data, 3, {

+ 3 - 3
public/app/features/panel/solo_panel_ctrl.ts

@@ -7,7 +7,7 @@ export class SoloPanelCtrl {
   constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
     let panelId;
 
-    $scope.init = function() {
+    $scope.init = () => {
       contextSrv.sidemenu = false;
       appEvents.emit('toggle-sidemenu-hidden');
 
@@ -27,13 +27,13 @@ export class SoloPanelCtrl {
         return;
       }
 
-      dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
+      dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(result => {
         result.meta.soloMode = true;
         $scope.initDashboard(result, $scope);
       });
     };
 
-    $scope.initPanelScope = function() {
+    $scope.initPanelScope = () => {
       const panelInfo = $scope.dashboard.getPanelInfoById(panelId);
 
       // fake row ctrl scope

+ 8 - 8
public/app/features/panellinks/module.ts

@@ -10,7 +10,7 @@ function panelLinksEditor() {
     restrict: 'E',
     controller: 'PanelLinksEditorCtrl',
     templateUrl: 'public/app/features/panellinks/module.html',
-    link: function() {},
+    link: () => {},
   };
 }
 
@@ -19,15 +19,15 @@ export class PanelLinksEditorCtrl {
   constructor($scope, backendSrv) {
     $scope.panel.links = $scope.panel.links || [];
 
-    $scope.addLink = function() {
+    $scope.addLink = () => {
       $scope.panel.links.push({
         type: 'dashboard',
       });
     };
 
-    $scope.searchDashboards = function(queryStr, callback) {
-      backendSrv.search({ query: queryStr }).then(function(hits) {
-        const dashboards = _.map(hits, function(dash) {
+    $scope.searchDashboards = (queryStr, callback) => {
+      backendSrv.search({ query: queryStr }).then(hits => {
+        const dashboards = _.map(hits, dash => {
           return dash.title;
         });
 
@@ -35,8 +35,8 @@ export class PanelLinksEditorCtrl {
       });
     };
 
-    $scope.dashboardChanged = function(link) {
-      backendSrv.search({ query: link.dashboard }).then(function(hits) {
+    $scope.dashboardChanged = link => {
+      backendSrv.search({ query: link.dashboard }).then(hits => {
         const dashboard = _.find(hits, { title: link.dashboard });
         if (dashboard) {
           if (dashboard.url) {
@@ -50,7 +50,7 @@ export class PanelLinksEditorCtrl {
       });
     };
 
-    $scope.deleteLink = function(link) {
+    $scope.deleteLink = link => {
       $scope.panel.links = _.without($scope.panel.links, link);
     };
   }

+ 1 - 1
public/app/features/playlist/playlist_routes.ts

@@ -21,7 +21,7 @@ function grafanaRoutes($routeProvider) {
     .when('/playlists/play/:id', {
       template: '',
       resolve: {
-        init: function(playlistSrv, $route) {
+        init: (playlistSrv, $route) => {
           const playlistId = $route.current.params.id;
           playlistSrv.start(playlistId);
         },

+ 4 - 4
public/app/features/plugins/ds_edit_ctrl.ts

@@ -200,7 +200,7 @@ export class DataSourceEditCtrl {
 
 coreModule.controller('DataSourceEditCtrl', DataSourceEditCtrl);
 
-coreModule.directive('datasourceHttpSettings', function() {
+coreModule.directive('datasourceHttpSettings', () => {
   return {
     scope: {
       current: '=',
@@ -209,15 +209,15 @@ coreModule.directive('datasourceHttpSettings', function() {
     },
     templateUrl: 'public/app/features/plugins/partials/ds_http_settings.html',
     link: {
-      pre: function($scope, elem, attrs) {
+      pre: ($scope, elem, attrs) => {
         // do not show access option if direct access is disabled
         $scope.showAccessOption = $scope.noDirectAccess !== 'true';
         $scope.showAccessHelp = false;
-        $scope.toggleAccessHelp = function() {
+        $scope.toggleAccessHelp = () => {
           $scope.showAccessHelp = !$scope.showAccessHelp;
         };
 
-        $scope.getSuggestUrls = function() {
+        $scope.getSuggestUrls = () => {
           return [$scope.suggestUrl];
         };
       },

+ 11 - 11
public/app/features/plugins/plugin_component.ts

@@ -36,7 +36,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
     // handle relative template urls for plugin templates
     options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl);
 
-    return function() {
+    return () => {
       return {
         templateUrl: options.Component.templateUrl,
         template: options.Component.template,
@@ -71,12 +71,12 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
     const panelInfo = config.panels[scope.panel.type];
     let panelCtrlPromise = Promise.resolve(UnknownPanelCtrl);
     if (panelInfo) {
-      panelCtrlPromise = importPluginModule(panelInfo.module).then(function(panelModule) {
+      panelCtrlPromise = importPluginModule(panelInfo.module).then(panelModule => {
         return panelModule.PanelCtrl;
       });
     }
 
-    return panelCtrlPromise.then(function(PanelCtrl: any) {
+    return panelCtrlPromise.then((PanelCtrl: any) => {
       componentInfo.Component = PanelCtrl;
 
       if (!PanelCtrl || PanelCtrl.registered) {
@@ -128,7 +128,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
       }
       // Annotations
       case 'annotations-query-ctrl': {
-        return importPluginModule(scope.ctrl.currentDatasource.meta.module).then(function(dsModule) {
+        return importPluginModule(scope.ctrl.currentDatasource.meta.module).then(dsModule => {
           return {
             baseUrl: scope.ctrl.currentDatasource.meta.baseUrl,
             name: 'annotations-query-ctrl-' + scope.ctrl.currentDatasource.meta.id,
@@ -144,7 +144,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
       // Datasource ConfigCtrl
       case 'datasource-config-ctrl': {
         const dsMeta = scope.ctrl.datasourceMeta;
-        return importPluginModule(dsMeta.module).then(function(dsModule): any {
+        return importPluginModule(dsMeta.module).then((dsModule): any => {
           if (!dsModule.ConfigCtrl) {
             return { notFound: true };
           }
@@ -161,7 +161,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
       // AppConfigCtrl
       case 'app-config-ctrl': {
         const model = scope.ctrl.model;
-        return importPluginModule(model.module).then(function(appModule) {
+        return importPluginModule(model.module).then(appModule => {
           return {
             baseUrl: model.baseUrl,
             name: 'app-config-' + model.id,
@@ -174,7 +174,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
       // App Page
       case 'app-page': {
         const appModel = scope.ctrl.appModel;
-        return importPluginModule(appModel.module).then(function(appModule) {
+        return importPluginModule(appModel.module).then(appModule => {
           return {
             baseUrl: appModel.baseUrl,
             name: 'app-page-' + appModel.id + '-' + scope.ctrl.page.slug,
@@ -206,9 +206,9 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
     elem.empty();
 
     // let a binding digest cycle complete before adding to dom
-    setTimeout(function() {
+    setTimeout(() => {
       elem.append(child);
-      scope.$applyAsync(function() {
+      scope.$applyAsync(() => {
         scope.$broadcast('component-did-mount');
         scope.$broadcast('refresh');
       });
@@ -239,9 +239,9 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
 
   return {
     restrict: 'E',
-    link: function(scope, elem, attrs) {
+    link: (scope, elem, attrs) => {
       getModule(scope, attrs)
-        .then(function(componentInfo) {
+        .then(componentInfo => {
           registerPluginComponent(scope, elem, attrs, componentInfo);
         })
         .catch(err => {

+ 1 - 1
public/app/features/templating/all.ts

@@ -10,7 +10,7 @@ import { CustomVariable } from './custom_variable';
 import { ConstantVariable } from './constant_variable';
 import { AdhocVariable } from './adhoc_variable';
 
-coreModule.factory('templateSrv', function() {
+coreModule.factory('templateSrv', () => {
   return templateSrv;
 });
 

+ 1 - 1
public/app/features/templating/custom_variable.ts

@@ -39,7 +39,7 @@ export class CustomVariable implements Variable {
 
   updateOptions() {
     // extract options in comma separated string
-    this.options = _.map(this.query.split(/[,]+/), function(text) {
+    this.options = _.map(this.query.split(/[,]+/), text => {
       return { text: text.trim(), value: text.trim() };
     });
 

+ 16 - 16
public/app/features/templating/editor_ctrl.ts

@@ -30,31 +30,31 @@ export class VariableEditorCtrl {
 
     $scope.hideOptions = [{ value: 0, text: '' }, { value: 1, text: 'Label' }, { value: 2, text: 'Variable' }];
 
-    $scope.init = function() {
+    $scope.init = () => {
       $scope.mode = 'list';
 
       $scope.variables = variableSrv.variables;
       $scope.reset();
 
-      $scope.$watch('mode', function(val) {
+      $scope.$watch('mode', val => {
         if (val === 'new') {
           $scope.reset();
         }
       });
     };
 
-    $scope.setMode = function(mode) {
+    $scope.setMode = mode => {
       $scope.mode = mode;
     };
 
-    $scope.add = function() {
+    $scope.add = () => {
       if ($scope.isValid()) {
         variableSrv.addVariable($scope.current);
         $scope.update();
       }
     };
 
-    $scope.isValid = function() {
+    $scope.isValid = () => {
       if (!$scope.ctrl.form.$valid) {
         return false;
       }
@@ -84,7 +84,7 @@ export class VariableEditorCtrl {
       return true;
     };
 
-    $scope.validate = function() {
+    $scope.validate = () => {
       $scope.infoText = '';
       if ($scope.current.type === 'adhoc' && $scope.current.datasource !== null) {
         $scope.infoText = 'Adhoc filters are applied automatically to all queries that target this datasource';
@@ -96,7 +96,7 @@ export class VariableEditorCtrl {
       }
     };
 
-    $scope.runQuery = function() {
+    $scope.runQuery = () => {
       $scope.optionsLimit = 20;
       return variableSrv.updateOptions($scope.current).catch(err => {
         if (err.data && err.data.message) {
@@ -106,23 +106,23 @@ export class VariableEditorCtrl {
       });
     };
 
-    $scope.edit = function(variable) {
+    $scope.edit = variable => {
       $scope.current = variable;
       $scope.currentIsNew = false;
       $scope.mode = 'edit';
       $scope.validate();
     };
 
-    $scope.duplicate = function(variable) {
+    $scope.duplicate = variable => {
       const clone = _.cloneDeep(variable.getSaveModel());
       $scope.current = variableSrv.createVariableFromModel(clone);
       $scope.current.name = 'copy_of_' + variable.name;
       variableSrv.addVariable($scope.current);
     };
 
-    $scope.update = function() {
+    $scope.update = () => {
       if ($scope.isValid()) {
-        $scope.runQuery().then(function() {
+        $scope.runQuery().then(() => {
           $scope.reset();
           $scope.mode = 'list';
           templateSrv.updateTemplateData();
@@ -130,18 +130,18 @@ export class VariableEditorCtrl {
       }
     };
 
-    $scope.reset = function() {
+    $scope.reset = () => {
       $scope.currentIsNew = true;
       $scope.current = variableSrv.createVariableFromModel({ type: 'query' });
 
       // this is done here in case a new data source type variable was added
-      $scope.datasources = _.filter(datasourceSrv.getMetricSources(), function(ds) {
+      $scope.datasources = _.filter(datasourceSrv.getMetricSources(), ds => {
         return !ds.meta.mixed && ds.value !== null;
       });
 
       $scope.datasourceTypes = _($scope.datasources)
         .uniqBy('meta.id')
-        .map(function(ds) {
+        .map(ds => {
           return { text: ds.meta.name, value: ds.meta.id };
         })
         .value();
@@ -164,11 +164,11 @@ export class VariableEditorCtrl {
       $scope.validate();
     };
 
-    $scope.removeVariable = function(variable) {
+    $scope.removeVariable = variable => {
       variableSrv.removeVariable(variable);
     };
 
-    $scope.showMoreOptions = function() {
+    $scope.showMoreOptions = () => {
       $scope.optionsLimit += 20;
     };
   }

+ 1 - 1
public/app/features/templating/interval_variable.ts

@@ -65,7 +65,7 @@ export class IntervalVariable implements Variable {
 
   updateOptions() {
     // extract options between quotes and/or comma
-    this.options = _.map(this.query.match(/(["'])(.*?)\1|\w+/g), function(text) {
+    this.options = _.map(this.query.match(/(["'])(.*?)\1|\w+/g), text => {
       text = text.replace(/["']+/g, '');
       return { text: text.trim(), value: text.trim() };
     });

+ 2 - 2
public/app/features/templating/query_variable.ts

@@ -106,8 +106,8 @@ export class QueryVariable implements Variable {
   getValuesForTag(tagKey) {
     return this.datasourceSrv.get(this.datasource).then(datasource => {
       const query = this.tagValuesQuery.replace('$tag', tagKey);
-      return this.metricFindQuery(datasource, query).then(function(results) {
-        return _.map(results, function(value) {
+      return this.metricFindQuery(datasource, query).then(results => {
+        return _.map(results, value => {
           return value.text;
         });
       });

+ 3 - 3
public/app/features/templating/template_srv.ts

@@ -77,7 +77,7 @@ export class TemplateSrv {
     if (value instanceof Array && value.length === 0) {
       return '__empty__';
     }
-    const quotedValues = _.map(value, function(val) {
+    const quotedValues = _.map(value, val => {
       return '"' + luceneEscape(val) + '"';
     });
     return '(' + quotedValues.join(' OR ') + ')';
@@ -248,7 +248,7 @@ export class TemplateSrv {
   }
 
   fillVariableValuesForUrl(params, scopedVars) {
-    _.each(this.variables, function(variable) {
+    _.each(this.variables, variable => {
       if (scopedVars && scopedVars[variable.name] !== void 0) {
         if (scopedVars[variable.name].skipUrlSync) {
           return;
@@ -264,7 +264,7 @@ export class TemplateSrv {
   }
 
   distributeVariable(value, variable) {
-    value = _.map(value, function(val, index) {
+    value = _.map(value, (val, index) => {
       if (index !== 0) {
         return variable + '=' + val;
       } else {

+ 8 - 7
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) {
@@ -174,10 +175,10 @@ export class VariableSrv {
         selected = variable.options[0];
       } else {
         selected = {
-          value: _.map(selected, function(val) {
+          value: _.map(selected, val => {
             return val.value;
           }),
-          text: _.map(selected, function(val) {
+          text: _.map(selected, val => {
             return val.text;
           }).join(' + '),
         };
@@ -249,7 +250,7 @@ export class VariableSrv {
     const params = this.$location.search();
 
     // remove variable params
-    _.each(params, function(value, key) {
+    _.each(params, (value, key) => {
       if (key.indexOf('var-') === 0) {
         delete params[key];
       }

+ 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;
+  }
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini