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

Merge branch 'cached-signed-in-user' into ds_cache_refactor

Marcus Efraimsson 7 лет назад
Родитель
Сommit
940f510856
67 измененных файлов с 3092 добавлено и 1155 удалено
  1. 45 20
      .circleci/config.yml
  2. 3 1
      CHANGELOG.md
  3. 52 0
      docs/sources/features/datasources/mysql.md
  4. 1 0
      package.json
  5. 1 1
      pkg/cmd/grafana-server/main.go
  6. 27 3
      pkg/metrics/metrics.go
  7. 7 3
      pkg/services/sqlstore/sqlstore.go
  8. 11 4
      pkg/services/sqlstore/user.go
  9. 12 5
      public/app/core/components/Animations/SlideDown.tsx
  10. 1 0
      public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx
  11. 2 1
      public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
  12. 1 0
      public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap
  13. 64 33
      public/app/core/components/colorpicker/SeriesColorPicker.tsx
  14. 70 0
      public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
  15. 1 1
      public/app/core/core.ts
  16. 14 0
      public/app/core/logs_model.ts
  17. 11 5
      public/app/features/api-keys/ApiKeysPage.test.tsx
  18. 139 97
      public/app/features/api-keys/ApiKeysPage.tsx
  19. 20 302
      public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
  20. 2 0
      public/app/features/api-keys/state/selectors.ts
  21. 1 0
      public/app/features/dashboard/panel_model.ts
  22. 62 40
      public/app/features/explore/Explore.tsx
  23. 1 1
      public/app/features/explore/Graph.tsx
  24. 21 25
      public/app/features/explore/Logs.tsx
  25. 21 19
      public/app/features/explore/QueryField.tsx
  26. 7 9
      public/app/features/explore/QueryRows.tsx
  27. 8 8
      public/app/features/explore/QueryTransactionStatus.tsx
  28. 4 4
      public/app/features/explore/TimePicker.test.tsx
  29. 0 5
      public/app/features/panel/panel_ctrl.ts
  30. 4 2
      public/app/features/plugins/datasource_srv.ts
  31. 29 0
      public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx
  32. 205 0
      public/app/plugins/datasource/logging/components/LoggingQueryField.tsx
  33. 60 0
      public/app/plugins/datasource/logging/components/LoggingStartPage.tsx
  34. 6 1
      public/app/plugins/datasource/logging/datasource.ts
  35. 211 0
      public/app/plugins/datasource/logging/language_provider.ts
  36. 9 1
      public/app/plugins/datasource/logging/module.ts
  37. 20 16
      public/app/plugins/datasource/mysql/datasource.ts
  38. 142 0
      public/app/plugins/datasource/mysql/meta_query.ts
  39. 233 0
      public/app/plugins/datasource/mysql/mysql_query.ts
  40. 134 36
      public/app/plugins/datasource/mysql/partials/query.editor.html
  41. 559 11
      public/app/plugins/datasource/mysql/query_ctrl.ts
  42. 12 1
      public/app/plugins/datasource/mysql/specs/datasource.test.ts
  43. 86 0
      public/app/plugins/datasource/mysql/sql_part.ts
  44. 35 0
      public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx
  45. 0 0
      public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx
  46. 21 53
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  47. 60 0
      public/app/plugins/datasource/prometheus/components/PromStart.tsx
  48. 1 37
      public/app/plugins/datasource/prometheus/language_provider.ts
  49. 5 0
      public/app/plugins/datasource/prometheus/module.ts
  50. 321 0
      public/app/plugins/panel/graph/Legend/Legend.tsx
  51. 196 0
      public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx
  52. 36 16
      public/app/plugins/panel/graph/graph.ts
  53. 0 306
      public/app/plugins/panel/graph/legend.ts
  54. 13 49
      public/app/plugins/panel/graph/module.ts
  55. 1 1
      public/app/plugins/panel/graph/series_overrides_ctrl.ts
  56. 1 0
      public/app/plugins/panel/table/column_options.ts
  57. 5 1
      public/app/types/datasources.ts
  58. 1 0
      public/app/types/explore.ts
  59. 8 0
      public/app/types/plugins.ts
  60. 12 28
      public/sass/components/_panel_graph.scss
  61. 26 4
      public/sass/pages/_explore.scss
  62. 5 0
      scripts/build/ci-deploy/Dockerfile
  63. 7 0
      scripts/build/ci-deploy/build-deploy.sh
  64. 2 2
      scripts/grunt/default_task.js
  65. 8 2
      scripts/grunt/options/exec.js
  66. 1 1
      scripts/webpack/webpack.common.js
  67. 8 0
      yarn.lock

+ 45 - 20
.circleci/config.yml

@@ -319,39 +319,49 @@ jobs:
 
   deploy-enterprise-master:
     docker:
-      - image: circleci/python:2.7-stretch
+      - image: grafana/grafana-ci-deploy:1.0.0
     steps:
       - attach_workspace:
           at: .
       - run:
-          name: install awscli
-          command: 'sudo pip install awscli'
+          name: gcp credentials
+          command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+      - run:
+          name: sign in to gcp
+          command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
       - run:
           name: deploy to s3
           command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master'
+      - run:
+          name: deploy to gcp
+          command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/master'
+
 
   deploy-enterprise-release:
     docker:
-    - image: circleci/python:2.7-stretch
+    - image: grafana/grafana-ci-deploy:1.0.0
     steps:
-    - attach_workspace:
-        at: .
-    - run:
-        name: install awscli
-        command: 'sudo pip install awscli'
-    - run:
-        name: deploy to s3
-        command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
+      - attach_workspace:
+         at: .
+      - run:
+          name: gcp credentials
+          command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+      - run:
+          name: sign in to gcp
+          command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
+      - run:
+          name: deploy to s3
+          command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
+      - run:
+          name: deploy to gcp
+          command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release'
 
   deploy-master:
     docker:
-      - image: circleci/python:2.7-stretch
+      - image: grafana/grafana-ci-deploy:1.0.0
     steps:
       - attach_workspace:
           at: .
-      - run:
-          name: install awscli
-          command: 'sudo pip install awscli'
       - run:
           name: deploy to s3
           command: |
@@ -361,6 +371,15 @@ jobs:
       - run:
           name: Trigger Windows build
           command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master'
+      - run:
+          name: gcp credentials
+          command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+      - run:
+          name: sign in to gcp
+          command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
+      - run:
+          name: deploy to gcp
+          command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://$GCP_BUCKET_NAME/oss/master'
       - run:
           name: Publish to Grafana.com
           command: |
@@ -369,16 +388,22 @@ jobs:
 
   deploy-release:
     docker:
-      - image: circleci/python:2.7-stretch
+      - image: grafana/grafana-ci-deploy:1.0.0
     steps:
       - attach_workspace:
           at: .
-      - run:
-          name: install awscli
-          command: 'sudo pip install awscli'
       - run:
           name: deploy to s3
           command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release'
+      - run:
+          name: gcp credentials
+          command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+      - run:
+          name: sign in to gcp
+          command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
+      - run:
+          name: deploy to gcp
+          command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://R/oss/release'
       - run:
           name: Deploy to Grafana.com
           command: './scripts/build/publish.sh'

+ 3 - 1
CHANGELOG.md

@@ -4,8 +4,9 @@
 
 * **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat)
 * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset)
-* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
+* **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm)
 * **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
 * **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669)
 
 ### Minor
@@ -14,6 +15,7 @@
 * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
 * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
 * **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
 
 ### Breaking changes
 

+ 52 - 0
docs/sources/features/datasources/mysql.md

@@ -73,6 +73,58 @@ Example:
 
 You can use wildcards (`*`)  in place of database or table if you want to grant access to more databases and tables.
 
+## Query Editor
+
+> Only available in Grafana v5.4+.
+
+{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v54/mysql_query.gif" >}}
+
+You find the MySQL query editor in the metrics tab in a panel's edit mode. You enter edit mode by clicking the
+panel title, then edit.
+
+The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed.
+
+### Select table, time column and metric column (FROM)
+
+When you enter edit mode for the first time or add a new query Grafana will try to prefill the query builder with the first table that has a timestamp column and a numeric column.
+
+In the FROM field, Grafana will suggest tables that are in the configured database. To select a table or view in another database that your database user has access to you can manually enter a fully qualified name (database.table) like `otherDb.metrics`.
+
+The Time column field refers to the name of the column holding your time values. Selecting a value for the Metric column field is optional. If a value is selected, the Metric column field will be used as the series name.
+
+The metric column suggestions will only contain columns with a text datatype (text, tinytext, mediumtext, longtext, varchar, char).
+If you want to use a column with a different datatype as metric column you may enter the column name with a cast: `CAST(numericColumn as CHAR)`.
+You may also enter arbitrary SQL expressions in the metric column field that evaluate to a text datatype like
+`CONCAT(column1, " ", CAST(numericColumn as CHAR))`.
+
+### Columns and Aggregation functions (SELECT)
+
+In the `SELECT` row you can specify what columns and functions you want to use.
+In the column field you may write arbitrary expressions instead of a column name like `column1 * column2 / column3`.
+
+If you use aggregate functions you need to group your resultset. The editor will automatically add a `GROUP BY time` if you add an aggregate function.
+
+You may add further value columns by clicking the plus button and selecting `Column` from the menu. Multiple value columns will be plotted as separate series in the graph panel.
+
+### Filter data (WHERE)
+To add a filter click the plus icon to the right of the `WHERE` condition. You can remove filters by clicking on
+the filter and selecting `Remove`. A filter for the current selected timerange is automatically added to new queries.
+
+### Group By
+To group by time or any other columns click the plus icon at the end of the GROUP BY row. The suggestion dropdown will only show text columns of your currently selected table but you may manually enter any column.
+You can remove the group by clicking on the item and then selecting `Remove`.
+
+If you add any grouping, all selected columns need to have an aggregate function applied. The query builder will automatically add aggregate functions to all columns without aggregate functions when you add groupings.
+
+#### Gap Filling
+
+Grafana can fill in missing values when you group by time. The time function accepts two arguments. The first argument is the time window that you would like to group by, and the second argument is the value you want Grafana to fill missing items with.
+
+### Text Editor Mode (RAW)
+You can switch to the raw query editor mode by clicking the hamburger icon and selecting `Switch editor mode` or by clicking `Edit SQL` below the query.
+
+> If you use the raw query editor, be sure your query at minimum has `ORDER BY time` and a filter on the returned time range.
+
 ## Macros
 
 To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros.

+ 1 - 0
package.json

@@ -47,6 +47,7 @@
     "grunt-contrib-copy": "~1.0.0",
     "grunt-contrib-cssmin": "~1.0.2",
     "grunt-exec": "^1.0.1",
+    "grunt-newer": "^1.3.0",
     "grunt-notify": "^0.4.5",
     "grunt-postcss": "^0.8.0",
     "grunt-sass": "^2.0.0",

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

@@ -80,7 +80,7 @@ func main() {
 	setting.BuildBranch = buildBranch
 	setting.IsEnterprise = extensions.IsEnterprise
 
-	metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
+	metrics.SetBuildInformation(version, commit, buildBranch)
 
 	server := NewGrafanaServer()
 

+ 27 - 3
pkg/metrics/metrics.go

@@ -58,7 +58,14 @@ var (
 	M_StatActive_Users       prometheus.Gauge
 	M_StatTotal_Orgs         prometheus.Gauge
 	M_StatTotal_Playlists    prometheus.Gauge
-	M_Grafana_Version        *prometheus.GaugeVec
+
+	// M_Grafana_Version is a gauge that contains build info about this binary
+	//
+	// Deprecated: use M_Grafana_Build_Version instead.
+	M_Grafana_Version *prometheus.GaugeVec
+
+	// grafanaBuildVersion is a gauge that contains build info about this binary
+	grafanaBuildVersion *prometheus.GaugeVec
 )
 
 func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec {
@@ -293,9 +300,25 @@ func init() {
 
 	M_Grafana_Version = prometheus.NewGaugeVec(prometheus.GaugeOpts{
 		Name:      "info",
-		Help:      "Information about the Grafana",
+		Help:      "Information about the Grafana. This metric is deprecated. please use `grafana_build_info`",
 		Namespace: exporterName,
 	}, []string{"version"})
+
+	grafanaBuildVersion = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Name:      "build_info",
+		Help:      "A metric with a constant '1' value labeled by version, revision, branch, and goversion from which Grafana was built.",
+		Namespace: exporterName,
+	}, []string{"version", "revision", "branch", "goversion"})
+}
+
+// SetBuildInformation sets the build information for this binary
+func SetBuildInformation(version, revision, branch string) {
+	// We export this info twice for backwards compability.
+	// Once this have been released for some time we should be able to remote `M_Grafana_Version`
+	// The reason we added a new one is that its common practice in the prometheus community
+	// to name this metric `*_build_info` so its easy to do aggregation on all programs.
+	M_Grafana_Version.WithLabelValues(version).Set(1)
+	grafanaBuildVersion.WithLabelValues(version, revision, branch, runtime.Version()).Set(1)
 }
 
 func initMetricVars() {
@@ -334,7 +357,8 @@ func initMetricVars() {
 		M_StatActive_Users,
 		M_StatTotal_Orgs,
 		M_StatTotal_Playlists,
-		M_Grafana_Version)
+		M_Grafana_Version,
+		grafanaBuildVersion)
 
 }
 

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

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

+ 11 - 4
pkg/services/sqlstore/user.go

@@ -15,8 +15,9 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-func init() {
-	//bus.AddHandler("sql", CreateUser)
+func (ss *SqlStore) addUserQueryAndCommandHandlers() {
+	ss.Bus.AddHandler(ss.GetSignedInUser)
+
 	bus.AddHandler("sql", GetUserById)
 	bus.AddHandler("sql", UpdateUser)
 	bus.AddHandler("sql", ChangeUserPassword)
@@ -25,7 +26,6 @@ func init() {
 	bus.AddHandler("sql", SetUsingOrg)
 	bus.AddHandler("sql", UpdateUserLastSeenAt)
 	bus.AddHandler("sql", GetUserProfile)
-	bus.AddHandler("sql", GetSignedInUser)
 	bus.AddHandler("sql", SearchUsers)
 	bus.AddHandler("sql", GetUserOrgList)
 	bus.AddHandler("sql", DeleteUser)
@@ -345,12 +345,18 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error {
 	return err
 }
 
-func GetSignedInUser(query *m.GetSignedInUserQuery) error {
+func (ss *SqlStore) GetSignedInUser(query *m.GetSignedInUserQuery) error {
 	orgId := "u.org_id"
 	if query.OrgId > 0 {
 		orgId = strconv.FormatInt(query.OrgId, 10)
 	}
 
+	cacheKey := fmt.Sprintf("signed-in-user-%d-%s", query.UserId, query.OrgId)
+	if cached, found := ss.CacheService.Get(cacheKey); found {
+		query.Result = cached.(*m.SignedInUser)
+		return nil
+	}
+
 	var rawSql = `SELECT
 		u.id             as user_id,
 		u.is_admin       as is_grafana_admin,
@@ -401,6 +407,7 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error {
 	}
 
 	query.Result = &user
+	ss.CacheService.Set(cacheKey, &user, time.Second*5)
 	return err
 }
 

+ 12 - 5
public/app/core/components/Animations/SlideDown.tsx

@@ -1,15 +1,22 @@
-import React from 'react';
+import React from 'react';
 import Transition from 'react-transition-group/Transition';
 
-const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
+interface Style {
+  transition?: string;
+  overflow?: string;
+}
+
+// When animating using max-height we need to use a static value.
 // If this is not enough, pass in <SlideDown maxHeight="....
+const defaultMaxHeight = '200px';
 const defaultDuration = 200;
-const defaultStyle = {
+
+export const defaultStyle: Style = {
   transition: `max-height ${defaultDuration}ms ease-in-out`,
   overflow: 'hidden',
 };
 
-export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
+export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = defaultStyle }) => {
   // There are 4 main states a Transition can be in:
   // ENTERING, ENTERED, EXITING, EXITED
   // https://reactcommunity.org/react-transition-group/
@@ -25,7 +32,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
       {state => (
         <div
           style={{
-            ...defaultStyle,
+            ...style,
             ...transitionStyles[state],
           }}
         >

+ 1 - 0
public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx

@@ -7,6 +7,7 @@ const model = {
   buttonIcon: 'ga css class',
   buttonLink: 'http://url/to/destination',
   buttonTitle: 'Click me',
+  onClick: jest.fn(),
   proTip: 'This is a tip',
   proTipLink: 'http://url/to/tip/destination',
   proTipLinkTitle: 'Learn more',

+ 2 - 1
public/app/core/components/EmptyListCTA/EmptyListCTA.tsx

@@ -11,6 +11,7 @@ class EmptyListCTA extends Component<Props, any> {
       buttonIcon,
       buttonLink,
       buttonTitle,
+      onClick,
       proTip,
       proTipLink,
       proTipLinkTitle,
@@ -19,7 +20,7 @@ class EmptyListCTA extends Component<Props, any> {
     return (
       <div className="empty-list-cta">
         <div className="empty-list-cta__title">{title}</div>
-        <a href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success">
+        <a onClick={onClick} href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success">
           <i className={buttonIcon} />
           {buttonTitle}
         </a>

+ 1 - 0
public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap

@@ -12,6 +12,7 @@ exports[`EmptyListCTA renders correctly 1`] = `
   <a
     className="empty-list-cta__button btn btn-xlarge btn-success"
     href="http://url/to/destination"
+    onClick={[MockFunction]}
   >
     <i
       className="ga css class"

+ 64 - 33
public/app/core/components/colorpicker/SeriesColorPicker.tsx

@@ -1,53 +1,84 @@
 import React from 'react';
-import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
+import ReactDOM from 'react-dom';
+import Drop from 'tether-drop';
+import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
 
-export interface Props {
-  series: any;
-  onColorChange: (color: string) => void;
-  onToggleAxis: () => void;
+export interface SeriesColorPickerProps {
+  color: string;
+  yaxis?: number;
+  optionalClass?: string;
+  onColorChange: (newColor: string) => void;
+  onToggleAxis?: () => void;
 }
 
-export class SeriesColorPicker extends React.Component<Props, any> {
+export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
+  pickerElem: any;
+  colorPickerDrop: any;
+
+  static defaultProps = {
+    optionalClass: '',
+    yaxis: undefined,
+    onToggleAxis: () => {},
+  };
+
   constructor(props) {
     super(props);
-    this.onColorChange = this.onColorChange.bind(this);
-    this.onToggleAxis = this.onToggleAxis.bind(this);
-  }
-
-  onColorChange(color) {
-    this.props.onColorChange(color);
   }
 
-  onToggleAxis() {
-    this.props.onToggleAxis();
+  componentWillUnmount() {
+    this.destroyDrop();
   }
 
-  renderAxisSelection() {
-    const leftButtonClass = this.props.series.yaxis === 1 ? 'btn-success' : 'btn-inverse';
-    const rightButtonClass = this.props.series.yaxis === 2 ? 'btn-success' : 'btn-inverse';
+  onClickToOpen = () => {
+    if (this.colorPickerDrop) {
+      this.destroyDrop();
+    }
 
-    return (
-      <div className="p-b-1">
-        <label className="small p-r-1">Y Axis:</label>
-        <button onClick={this.onToggleAxis} className={'btn btn-small ' + leftButtonClass}>
-          Left
-        </button>
-        <button onClick={this.onToggleAxis} className={'btn btn-small ' + rightButtonClass}>
-          Right
-        </button>
-      </div>
+    const { color, yaxis, onColorChange, onToggleAxis } = this.props;
+    const dropContent = (
+      <SeriesColorPickerPopover color={color} yaxis={yaxis} onColorChange={onColorChange} onToggleAxis={onToggleAxis} />
     );
+    const dropContentElem = document.createElement('div');
+    ReactDOM.render(dropContent, dropContentElem);
+
+    const drop = new Drop({
+      target: this.pickerElem,
+      content: dropContentElem,
+      position: 'top center',
+      classes: 'drop-popover',
+      openOn: 'hover',
+      hoverCloseDelay: 200,
+      remove: true,
+      tetherOptions: {
+        constraints: [{ to: 'scrollParent', attachment: 'none both' }],
+      },
+    });
+
+    drop.on('close', this.closeColorPicker.bind(this));
+
+    this.colorPickerDrop = drop;
+    this.colorPickerDrop.open();
+  };
+
+  closeColorPicker() {
+    setTimeout(() => {
+      this.destroyDrop();
+    }, 100);
+  }
+
+  destroyDrop() {
+    if (this.colorPickerDrop && this.colorPickerDrop.tether) {
+      this.colorPickerDrop.destroy();
+      this.colorPickerDrop = null;
+    }
   }
 
   render() {
+    const { optionalClass, children } = this.props;
     return (
-      <div className="graph-legend-popover">
-        {this.props.series.yaxis && this.renderAxisSelection()}
-        <ColorPickerPopover color={this.props.series.color} onColorSelect={this.onColorChange} />
+      <div className={optionalClass} ref={e => (this.pickerElem = e)} onClick={this.onClickToOpen}>
+        {children}
       </div>
     );
   }
 }
-
-react2AngularDirective('seriesColorPicker', SeriesColorPicker, ['series', 'onColorChange', 'onToggleAxis']);

+ 70 - 0
public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx

@@ -0,0 +1,70 @@
+import React from 'react';
+import { ColorPickerPopover } from './ColorPickerPopover';
+import { react2AngularDirective } from 'app/core/utils/react2angular';
+
+export interface SeriesColorPickerPopoverProps {
+  color: string;
+  yaxis?: number;
+  onColorChange: (color: string) => void;
+  onToggleAxis?: () => void;
+}
+
+export class SeriesColorPickerPopover extends React.PureComponent<SeriesColorPickerPopoverProps, any> {
+  render() {
+    return (
+      <div className="graph-legend-popover">
+        {this.props.yaxis && <AxisSelector yaxis={this.props.yaxis} onToggleAxis={this.props.onToggleAxis} />}
+        <ColorPickerPopover color={this.props.color} onColorSelect={this.props.onColorChange} />
+      </div>
+    );
+  }
+}
+
+interface AxisSelectorProps {
+  yaxis: number;
+  onToggleAxis: () => void;
+}
+
+interface AxisSelectorState {
+  yaxis: number;
+}
+
+export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSelectorState> {
+  constructor(props) {
+    super(props);
+    this.state = {
+      yaxis: this.props.yaxis,
+    };
+    this.onToggleAxis = this.onToggleAxis.bind(this);
+  }
+
+  onToggleAxis() {
+    this.setState({
+      yaxis: this.state.yaxis === 2 ? 1 : 2,
+    });
+    this.props.onToggleAxis();
+  }
+
+  render() {
+    const leftButtonClass = this.state.yaxis === 1 ? 'btn-success' : 'btn-inverse';
+    const rightButtonClass = this.state.yaxis === 2 ? 'btn-success' : 'btn-inverse';
+
+    return (
+      <div className="p-b-1">
+        <label className="small p-r-1">Y Axis:</label>
+        <button onClick={this.onToggleAxis} className={'btn btn-small ' + leftButtonClass}>
+          Left
+        </button>
+        <button onClick={this.onToggleAxis} className={'btn btn-small ' + rightButtonClass}>
+          Right
+        </button>
+      </div>
+    );
+  }
+}
+
+react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+  'series',
+  'onColorChange',
+  'onToggleAxis',
+]);

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

@@ -14,7 +14,7 @@ import './components/jsontree/jsontree';
 import './components/code_editor/code_editor';
 import './utils/outline';
 import './components/colorpicker/ColorPicker';
-import './components/colorpicker/SeriesColorPicker';
+import './components/colorpicker/SeriesColorPickerPopover';
 import './components/colorpicker/spectrum_picker';
 import './services/search_srv';
 import './services/ng_react';

+ 14 - 0
public/app/core/logs_model.ts

@@ -1,3 +1,5 @@
+import _ from 'lodash';
+
 export enum LogLevel {
   crit = 'crit',
   warn = 'warn',
@@ -27,3 +29,15 @@ export interface LogRow {
 export interface LogsModel {
   rows: LogRow[];
 }
+
+export function mergeStreams(streams: LogsModel[], limit?: number): LogsModel {
+  const combinedEntries = streams.reduce((acc, stream) => {
+    return [...acc, ...stream.rows];
+  }, []);
+  const sortedEntries = _.chain(combinedEntries)
+    .sortBy('timestamp')
+    .reverse()
+    .slice(0, limit || combinedEntries.length)
+    .value();
+  return { rows: sortedEntries };
+}

+ 11 - 5
public/app/features/api-keys/ApiKeysPage.test.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React from 'react';
 import { shallow } from 'enzyme';
 import { Props, ApiKeysPage } from './ApiKeysPage';
 import { NavModel, ApiKey } from 'app/types';
@@ -14,6 +14,7 @@ const setup = (propOverrides?: object) => {
     deleteApiKey: jest.fn(),
     setSearchQuery: jest.fn(),
     addApiKey: jest.fn(),
+    apiKeysCount: 0,
   };
 
   Object.assign(props, propOverrides);
@@ -28,14 +29,19 @@ const setup = (propOverrides?: object) => {
 };
 
 describe('Render', () => {
-  it('should render component', () => {
-    const { wrapper } = setup();
+  it('should render API keys table if there are any keys', () => {
+    const { wrapper } = setup({
+      apiKeys: getMultipleMockKeys(5),
+      apiKeysCount: 5,
+    });
+
     expect(wrapper).toMatchSnapshot();
   });
 
-  it('should render API keys table', () => {
+  it('should render CTA if there are no API keys', () => {
     const { wrapper } = setup({
-      apiKeys: getMultipleMockKeys(5),
+      apiKeys: getMultipleMockKeys(0),
+      apiKeysCount: 0,
       hasFetched: true,
     });
 

+ 139 - 97
public/app/features/api-keys/ApiKeysPage.tsx

@@ -1,17 +1,19 @@
-import React, { PureComponent } from 'react';
+import React, { PureComponent } from 'react';
 import ReactDOMServer from 'react-dom/server';
 import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
 import { getNavModel } from 'app/core/selectors/navModel';
-import { getApiKeys } from './state/selectors';
+import { getApiKeys, getApiKeysCount } from './state/selectors';
 import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
-import SlideDown from 'app/core/components/Animations/SlideDown';
 import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import SlideDown from 'app/core/components/Animations/SlideDown';
 import ApiKeysAddedModal from './ApiKeysAddedModal';
 import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
+import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
 
 export interface Props {
   navModel: NavModel;
@@ -22,6 +24,7 @@ export interface Props {
   deleteApiKey: typeof deleteApiKey;
   setSearchQuery: typeof setSearchQuery;
   addApiKey: typeof addApiKey;
+  apiKeysCount: number;
 }
 
 export interface State {
@@ -82,6 +85,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
       return {
         ...prevState,
         newApiKey: initialApiKeyState,
+        isAdding: false,
       };
     });
   };
@@ -101,115 +105,152 @@ export class ApiKeysPage extends PureComponent<Props, any> {
     });
   };
 
-  renderTable() {
-    const { apiKeys } = this.props;
-
-    return [
-      <h3 key="header" className="page-heading">
-        Existing Keys
-      </h3>,
-      <table key="table" className="filter-table">
-        <thead>
-          <tr>
-            <th>Name</th>
-            <th>Role</th>
-            <th style={{ width: '34px' }} />
-          </tr>
-        </thead>
-        {apiKeys.length > 0 && (
-          <tbody>
-            {apiKeys.map(key => {
-              return (
-                <tr key={key.id}>
-                  <td>{key.name}</td>
-                  <td>{key.role}</td>
-                  <td>
-                    <a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
-                      <i className="fa fa-remove" />
-                    </a>
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
+  renderEmptyList() {
+    const { isAdding } = this.state;
+    return (
+      <div className="page-container page-body">
+        {!isAdding && (
+          <EmptyListCTA
+            model={{
+              title: "You haven't added any API Keys yet.",
+              buttonIcon: 'fa fa-plus',
+              buttonLink: '#',
+              onClick: this.onToggleAdding,
+              buttonTitle: ' New API Key',
+              proTip: 'Remember you can provide view-only API access to other applications.',
+              proTipLink: '',
+              proTipLinkTitle: '',
+              proTipTarget: '_blank',
+            }}
+          />
         )}
-      </table>,
-    ];
+        {this.renderAddApiKeyForm()}
+      </div>
+    );
   }
 
-  render() {
+  renderAddApiKeyForm() {
     const { newApiKey, isAdding } = this.state;
-    const { hasFetched, navModel, searchQuery } = this.props;
 
     return (
-      <div>
-        <PageHeader model={navModel} />
-        <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">
+      <SlideDown in={isAdding}>
+        <div className="cta-form">
+          <button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
+            <i className="fa fa-close" />
+          </button>
+          <h5>Add API Key</h5>
+          <form className="gf-form-group" onSubmit={this.onAddApiKey}>
+            <div className="gf-form-inline">
+              <div className="gf-form max-width-21">
+                <span className="gf-form-label">Key name</span>
                 <input
                   type="text"
                   className="gf-form-input"
-                  placeholder="Search keys"
-                  value={searchQuery}
-                  onChange={this.onSearchQueryChange}
+                  value={newApiKey.name}
+                  placeholder="Name"
+                  onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)}
                 />
-                <i className="gf-form-input-icon fa fa-search" />
-              </label>
+              </div>
+              <div className="gf-form">
+                <span className="gf-form-label">Role</span>
+                <span className="gf-form-select-wrapper">
+                  <select
+                    className="gf-form-input gf-size-auto"
+                    value={newApiKey.role}
+                    onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Role)}
+                  >
+                    {Object.keys(OrgRole).map(role => {
+                      return (
+                        <option key={role} label={role} value={role}>
+                          {role}
+                        </option>
+                      );
+                    })}
+                  </select>
+                </span>
+              </div>
+              <div className="gf-form">
+                <button className="btn gf-form-btn btn-success">Add</button>
+              </div>
             </div>
+          </form>
+        </div>
+      </SlideDown>
+    );
+  }
+
+  renderApiKeyList() {
+    const { isAdding } = this.state;
+    const { apiKeys, searchQuery } = this.props;
 
-            <div className="page-action-bar__spacer" />
-            <button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
-              <i className="fa fa-plus" /> Add API Key
-            </button>
+    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 keys"
+                value={searchQuery}
+                onChange={this.onSearchQueryChange}
+              />
+              <i className="gf-form-input-icon fa fa-search" />
+            </label>
           </div>
 
-          <SlideDown in={isAdding}>
-            <div className="cta-form">
-              <button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
-                <i className="fa fa-close" />
-              </button>
-              <h5>Add API Key</h5>
-              <form className="gf-form-group" onSubmit={this.onAddApiKey}>
-                <div className="gf-form-inline">
-                  <div className="gf-form max-width-21">
-                    <span className="gf-form-label">Key name</span>
-                    <input
-                      type="text"
-                      className="gf-form-input"
-                      value={newApiKey.name}
-                      placeholder="Name"
-                      onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)}
-                    />
-                  </div>
-                  <div className="gf-form">
-                    <span className="gf-form-label">Role</span>
-                    <span className="gf-form-select-wrapper">
-                      <select
-                        className="gf-form-input gf-size-auto"
-                        value={newApiKey.role}
-                        onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Role)}
-                      >
-                        {Object.keys(OrgRole).map(role => {
-                          return (
-                            <option key={role} label={role} value={role}>
-                              {role}
-                            </option>
-                          );
-                        })}
-                      </select>
-                    </span>
-                  </div>
-                  <div className="gf-form">
-                    <button className="btn gf-form-btn btn-success">Add</button>
-                  </div>
-                </div>
-              </form>
-            </div>
-          </SlideDown>
-          {hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
+          <div className="page-action-bar__spacer" />
+          <button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
+            <i className="fa fa-plus" /> Add API Key
+          </button>
         </div>
+
+        {this.renderAddApiKeyForm()}
+
+        <h3 className="page-heading">Existing Keys</h3>
+        <table className="filter-table">
+          <thead>
+            <tr>
+              <th>Name</th>
+              <th>Role</th>
+              <th style={{ width: '34px' }} />
+            </tr>
+          </thead>
+          {apiKeys.length > 0 ? (
+            <tbody>
+              {apiKeys.map(key => {
+                return (
+                  <tr key={key.id}>
+                    <td>{key.name}</td>
+                    <td>{key.role}</td>
+                    <td>
+                      <DeleteButton onConfirmDelete={() => this.onDeleteApiKey(key)} />
+                    </td>
+                  </tr>
+                );
+              })}
+            </tbody>
+          ) : null}
+        </table>
+      </div>
+    );
+  }
+
+  render() {
+    const { hasFetched, navModel, apiKeysCount } = this.props;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        {hasFetched ? (
+          apiKeysCount > 0 ? (
+            this.renderApiKeyList()
+          ) : (
+            this.renderEmptyList()
+          )
+        ) : (
+          <PageLoader pageName="Api keys" />
+        )}
       </div>
     );
   }
@@ -220,6 +261,7 @@ function mapStateToProps(state) {
     navModel: getNavModel(state.navIndex, 'apikeys'),
     apiKeys: getApiKeys(state.apiKeys),
     searchQuery: state.apiKeys.searchQuery,
+    apiKeysCount: getApiKeysCount(state.apiKeys),
     hasFetched: state.apiKeys.hasFetched,
   };
 }

+ 20 - 302
public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap

@@ -1,276 +1,17 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Render should render API keys table 1`] = `
+exports[`Render should render API keys table if there are any keys 1`] = `
 <div>
   <PageHeader
     model={Object {}}
   />
-  <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
-            className="gf-form-input"
-            onChange={[Function]}
-            placeholder="Search keys"
-            type="text"
-            value=""
-          />
-          <i
-            className="gf-form-input-icon fa fa-search"
-          />
-        </label>
-      </div>
-      <div
-        className="page-action-bar__spacer"
-      />
-      <button
-        className="btn btn-success pull-right"
-        disabled={false}
-        onClick={[Function]}
-      >
-        <i
-          className="fa fa-plus"
-        />
-         Add API Key
-      </button>
-    </div>
-    <Component
-      in={false}
-    >
-      <div
-        className="cta-form"
-      >
-        <button
-          className="cta-form__close btn btn-transparent"
-          onClick={[Function]}
-        >
-          <i
-            className="fa fa-close"
-          />
-        </button>
-        <h5>
-          Add API Key
-        </h5>
-        <form
-          className="gf-form-group"
-          onSubmit={[Function]}
-        >
-          <div
-            className="gf-form-inline"
-          >
-            <div
-              className="gf-form max-width-21"
-            >
-              <span
-                className="gf-form-label"
-              >
-                Key name
-              </span>
-              <input
-                className="gf-form-input"
-                onChange={[Function]}
-                placeholder="Name"
-                type="text"
-                value=""
-              />
-            </div>
-            <div
-              className="gf-form"
-            >
-              <span
-                className="gf-form-label"
-              >
-                Role
-              </span>
-              <span
-                className="gf-form-select-wrapper"
-              >
-                <select
-                  className="gf-form-input gf-size-auto"
-                  onChange={[Function]}
-                  value="Viewer"
-                >
-                  <option
-                    key="Viewer"
-                    label="Viewer"
-                    value="Viewer"
-                  >
-                    Viewer
-                  </option>
-                  <option
-                    key="Editor"
-                    label="Editor"
-                    value="Editor"
-                  >
-                    Editor
-                  </option>
-                  <option
-                    key="Admin"
-                    label="Admin"
-                    value="Admin"
-                  >
-                    Admin
-                  </option>
-                </select>
-              </span>
-            </div>
-            <div
-              className="gf-form"
-            >
-              <button
-                className="btn gf-form-btn btn-success"
-              >
-                Add
-              </button>
-            </div>
-          </div>
-        </form>
-      </div>
-    </Component>
-    <h3
-      className="page-heading"
-      key="header"
-    >
-      Existing Keys
-    </h3>
-    <table
-      className="filter-table"
-      key="table"
-    >
-      <thead>
-        <tr>
-          <th>
-            Name
-          </th>
-          <th>
-            Role
-          </th>
-          <th
-            style={
-              Object {
-                "width": "34px",
-              }
-            }
-          />
-        </tr>
-      </thead>
-      <tbody>
-        <tr
-          key="1"
-        >
-          <td>
-            test-1
-          </td>
-          <td>
-            Viewer
-          </td>
-          <td>
-            <a
-              className="btn btn-danger btn-mini"
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-remove"
-              />
-            </a>
-          </td>
-        </tr>
-        <tr
-          key="2"
-        >
-          <td>
-            test-2
-          </td>
-          <td>
-            Viewer
-          </td>
-          <td>
-            <a
-              className="btn btn-danger btn-mini"
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-remove"
-              />
-            </a>
-          </td>
-        </tr>
-        <tr
-          key="3"
-        >
-          <td>
-            test-3
-          </td>
-          <td>
-            Viewer
-          </td>
-          <td>
-            <a
-              className="btn btn-danger btn-mini"
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-remove"
-              />
-            </a>
-          </td>
-        </tr>
-        <tr
-          key="4"
-        >
-          <td>
-            test-4
-          </td>
-          <td>
-            Viewer
-          </td>
-          <td>
-            <a
-              className="btn btn-danger btn-mini"
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-remove"
-              />
-            </a>
-          </td>
-        </tr>
-        <tr
-          key="5"
-        >
-          <td>
-            test-5
-          </td>
-          <td>
-            Viewer
-          </td>
-          <td>
-            <a
-              className="btn btn-danger btn-mini"
-              onClick={[Function]}
-            >
-              <i
-                className="fa fa-remove"
-              />
-            </a>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-  </div>
+  <PageLoader
+    pageName="Api keys"
+  />
 </div>
 `;
 
-exports[`Render should render component 1`] = `
+exports[`Render should render CTA if there are no API keys 1`] = `
 <div>
   <PageHeader
     model={Object {}}
@@ -278,41 +19,21 @@ exports[`Render should render component 1`] = `
   <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
-            className="gf-form-input"
-            onChange={[Function]}
-            placeholder="Search keys"
-            type="text"
-            value=""
-          />
-          <i
-            className="gf-form-input-icon fa fa-search"
-          />
-        </label>
-      </div>
-      <div
-        className="page-action-bar__spacer"
-      />
-      <button
-        className="btn btn-success pull-right"
-        disabled={false}
-        onClick={[Function]}
-      >
-        <i
-          className="fa fa-plus"
-        />
-         Add API Key
-      </button>
-    </div>
+    <EmptyListCTA
+      model={
+        Object {
+          "buttonIcon": "fa fa-plus",
+          "buttonLink": "#",
+          "buttonTitle": " New API Key",
+          "onClick": [Function],
+          "proTip": "Remember you can provide view-only API access to other applications.",
+          "proTipLink": "",
+          "proTipLinkTitle": "",
+          "proTipTarget": "_blank",
+          "title": "You haven't added any API Keys yet.",
+        }
+      }
+    />
     <Component
       in={false}
     >
@@ -406,9 +127,6 @@ exports[`Render should render component 1`] = `
         </form>
       </div>
     </Component>
-    <PageLoader
-      pageName="Api keys"
-    />
   </div>
 </div>
 `;

+ 2 - 0
public/app/features/api-keys/state/selectors.ts

@@ -1,5 +1,7 @@
 import { ApiKeysState } from 'app/types';
 
+export const getApiKeysCount = (state: ApiKeysState) => state.keys.length;
+
 export const getApiKeys = (state: ApiKeysState) => {
   const regex = RegExp(state.searchQuery, 'i');
 

+ 1 - 0
public/app/features/dashboard/panel_model.ts

@@ -133,6 +133,7 @@ export class PanelModel {
   }
 
   destroy() {
+    this.events.emit('panel-teardown');
     this.events.removeAllListeners();
   }
 }

+ 62 - 40
public/app/features/explore/Explore.tsx

@@ -4,6 +4,7 @@ import Select from 'react-select';
 import _ from 'lodash';
 
 import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
+import { RawTimeRange } from 'app/types/series';
 import kbn from 'app/core/utils/kbn';
 import colors from 'app/core/utils/colors';
 import store from 'app/core/store';
@@ -16,14 +17,15 @@ import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'
 import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 
-import ErrorBoundary from './ErrorBoundary';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
 import Logs from './Logs';
 import Table from './Table';
+import ErrorBoundary from './ErrorBoundary';
 import TimePicker from './TimePicker';
 import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
-import { RawTimeRange } from 'app/types/series';
+import { DataSource } from 'app/types/datasources';
+import { mergeStreams } from 'app/core/logs_model';
 
 const MAX_HISTORY_ITEMS = 100;
 
@@ -148,7 +150,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     }
   }
 
-  async setDatasource(datasource) {
+  async setDatasource(datasource: DataSource) {
     const supportsGraph = datasource.meta.metrics;
     const supportsLogs = datasource.meta.logs;
     const supportsTable = datasource.meta.metrics;
@@ -176,8 +178,12 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       query: this.queryExpressions[i],
     }));
 
+    // Custom components
+    const StartPage = datasource.pluginExports.ExploreStartPage;
+
     this.setState(
       {
+        StartPage,
         datasource,
         datasourceError,
         history,
@@ -330,6 +336,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     );
   };
 
+  // Use this in help pages to set page to a single query
+  onClickQuery = query => {
+    const nextQueries = [{ query, key: generateQueryKey() }];
+    this.queryExpressions = nextQueries.map(q => q.query);
+    this.setState({ queries: nextQueries }, this.onSubmit);
+  };
+
   onClickSplit = () => {
     const { onChangeSplit } = this.props;
     if (onChangeSplit) {
@@ -721,6 +734,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   render() {
     const { position, split } = this.props;
     const {
+      StartPage,
       datasource,
       datasourceError,
       datasourceLoading,
@@ -756,10 +770,12 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       new TableModel(),
       ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
     );
-    const logsResult = _.flatten(
+    const logsResult = mergeStreams(
       queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
     );
     const loading = queryTransactions.some(qt => !qt.done);
+    const showStartPages = StartPage && queryTransactions.length === 0;
+    const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length;
 
     return (
       <div className={exploreClass} ref={this.getRef}>
@@ -844,46 +860,52 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
               onClickHintFix={this.onModifyQueries}
               onExecuteQuery={this.onSubmit}
               onRemoveQueryRow={this.onRemoveQueryRow}
-              supportsLogs={supportsLogs}
               transactions={queryTransactions}
             />
-            <div className="result-options">
-              {supportsGraph ? (
-                <button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
-                  Graph
-                </button>
-              ) : null}
-              {supportsTable ? (
-                <button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
-                  Table
-                </button>
-              ) : null}
-              {supportsLogs ? (
-                <button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
-                  Logs
-                </button>
-              ) : null}
-            </div>
-
             <main className="m-t-2">
               <ErrorBoundary>
-                {supportsGraph &&
-                  showingGraph && (
-                    <Graph
-                      data={graphResult}
-                      height={graphHeight}
-                      loading={graphLoading}
-                      id={`explore-graph-${position}`}
-                      range={graphRange}
-                      split={split}
-                    />
-                  )}
-                {supportsTable && showingTable ? (
-                  <div className="panel-container m-t-2">
-                    <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
-                  </div>
-                ) : null}
-                {supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
+                {showStartPages && <StartPage onClickQuery={this.onClickQuery} />}
+                {!showStartPages && (
+                  <>
+                    {viewModeCount > 1 && (
+                      <div className="result-options">
+                        {supportsGraph ? (
+                          <button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
+                            Graph
+                          </button>
+                        ) : null}
+                        {supportsTable ? (
+                          <button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
+                            Table
+                          </button>
+                        ) : null}
+                        {supportsLogs ? (
+                          <button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
+                            Logs
+                          </button>
+                        ) : null}
+                      </div>
+                    )}
+
+                    {supportsGraph &&
+                      showingGraph && (
+                        <Graph
+                          data={graphResult}
+                          height={graphHeight}
+                          loading={graphLoading}
+                          id={`explore-graph-${position}`}
+                          range={graphRange}
+                          split={split}
+                        />
+                      )}
+                    {supportsTable && showingTable ? (
+                      <div className="panel-container m-t-2">
+                        <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
+                      </div>
+                    ) : null}
+                    {supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
+                  </>
+                )}
               </ErrorBoundary>
             </main>
           </div>

+ 1 - 1
public/app/features/explore/Graph.tsx

@@ -169,7 +169,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
 
     return (
       <div className="panel-container">
-        {loading && <div className="explore-graph__loader" />}
+        {loading && <div className="explore-panel__loader" />}
         {this.props.data &&
           this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
           !this.state.showAllTimeSeries && (

+ 21 - 25
public/app/features/explore/Logs.tsx

@@ -10,37 +10,33 @@ interface LogsProps {
   loading: boolean;
 }
 
-const EXAMPLE_QUERY = '{job="default/prometheus"}';
-
 export default class Logs extends PureComponent<LogsProps, {}> {
   render() {
-    const { className = '', data } = this.props;
+    const { className = '', data, loading = false } = this.props;
     const hasData = data && data.rows && data.rows.length > 0;
     return (
       <div className={`${className} logs`}>
-        {hasData ? (
-          <div className="logs-entries panel-container">
-            {data.rows.map(row => (
-              <Fragment key={row.key}>
-                <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
-                <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
-                <div>
-                  <Highlighter
-                    textToHighlight={row.entry}
-                    searchWords={row.searchWords}
-                    findChunks={findHighlightChunksInText}
-                    highlightClassName="logs-row-match-highlight"
-                  />
-                </div>
-              </Fragment>
-            ))}
-          </div>
-        ) : null}
-        {!hasData ? (
-          <div className="panel-container">
-            Enter a query like <code>{EXAMPLE_QUERY}</code>
+        <div className="panel-container">
+          {loading && <div className="explore-panel__loader" />}
+          <div className="logs-entries">
+            {hasData &&
+              data.rows.map(row => (
+                <Fragment key={row.key}>
+                  <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
+                  <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
+                  <div>
+                    <Highlighter
+                      textToHighlight={row.entry}
+                      searchWords={row.searchWords}
+                      findChunks={findHighlightChunksInText}
+                      highlightClassName="logs-row-match-highlight"
+                    />
+                  </div>
+                </Fragment>
+              ))}
           </div>
-        ) : null}
+          {!loading && !hasData && 'No data was returned.'}
+        </div>
       </div>
     );
   }

+ 21 - 19
public/app/features/explore/QueryField.tsx

@@ -27,7 +27,7 @@ function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
   return suggestions && suggestions.length > 0;
 }
 
-interface TypeaheadFieldProps {
+interface QueryFieldProps {
   additionalPlugins?: any[];
   cleanText?: (text: string) => string;
   initialValue: string | null;
@@ -35,14 +35,14 @@ interface TypeaheadFieldProps {
   onFocus?: () => void;
   onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
   onValueChanged?: (value: Value) => void;
-  onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
+  onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
   placeholder?: string;
   portalOrigin?: string;
   syntax?: string;
   syntaxLoaded?: boolean;
 }
 
-export interface TypeaheadFieldState {
+export interface QueryFieldState {
   suggestions: CompletionItemGroup[];
   typeaheadContext: string | null;
   typeaheadIndex: number;
@@ -60,7 +60,7 @@ export interface TypeaheadInput {
   wrapperNode: Element;
 }
 
-class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
+export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
   menuEl: HTMLElement | null;
   placeholdersBuffer: PlaceholdersBuffer;
   plugins: any[];
@@ -72,7 +72,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
     this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
 
     // Base plugins
-    this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
+    this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p);
 
     this.state = {
       suggestions: [],
@@ -102,7 +102,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
     }
   }
 
-  componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
+  componentWillReceiveProps(nextProps: QueryFieldProps) {
     if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
       // Need a bogus edit to re-render the editor after syntax has fully loaded
       const change = this.state.value
@@ -434,19 +434,21 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
 
   render() {
     return (
-      <div className="slate-query-field">
-        {this.renderMenu()}
-        <Editor
-          autoCorrect={false}
-          onBlur={this.handleBlur}
-          onKeyDown={this.onKeyDown}
-          onChange={this.onChange}
-          onFocus={this.handleFocus}
-          placeholder={this.props.placeholder}
-          plugins={this.plugins}
-          spellCheck={false}
-          value={this.state.value}
-        />
+      <div className="slate-query-field-wrapper">
+        <div className="slate-query-field">
+          {this.renderMenu()}
+          <Editor
+            autoCorrect={false}
+            onBlur={this.handleBlur}
+            onKeyDown={this.onKeyDown}
+            onChange={this.onChange}
+            onFocus={this.handleFocus}
+            placeholder={this.props.placeholder}
+            plugins={this.plugins}
+            spellCheck={false}
+            value={this.state.value}
+          />
+        </div>
       </div>
     );
   }

+ 7 - 9
public/app/features/explore/QueryRows.tsx

@@ -2,9 +2,9 @@ import React, { PureComponent } from 'react';
 
 import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
 
-// TODO make this datasource-plugin-dependent
-import QueryField from './PromQueryField';
-import QueryTransactions from './QueryTransactions';
+import DefaultQueryField from './QueryField';
+import QueryTransactionStatus from './QueryTransactionStatus';
+import { DataSource } from 'app/types';
 
 function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
   const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
@@ -24,10 +24,8 @@ interface QueryRowEventHandlers {
 
 interface QueryRowCommonProps {
   className?: string;
-  datasource: any;
+  datasource: DataSource;
   history: HistoryItem[];
-  // Temporarily
-  supportsLogs?: boolean;
   transactions: QueryTransaction[];
 }
 
@@ -78,14 +76,15 @@ class QueryRow extends PureComponent<QueryRowProps> {
   };
 
   render() {
-    const { datasource, history, query, supportsLogs, transactions } = this.props;
+    const { datasource, history, query, transactions } = this.props;
     const transactionWithError = transactions.find(t => t.error !== undefined);
     const hint = getFirstHintFromTransactions(transactions);
     const queryError = transactionWithError ? transactionWithError.error : null;
+    const QueryField = datasource.pluginExports.ExploreQueryField || DefaultQueryField;
     return (
       <div className="query-row">
         <div className="query-row-status">
-          <QueryTransactions transactions={transactions} />
+          <QueryTransactionStatus transactions={transactions} />
         </div>
         <div className="query-row-field">
           <QueryField
@@ -97,7 +96,6 @@ class QueryRow extends PureComponent<QueryRowProps> {
             onClickHintFix={this.onClickHintFix}
             onPressEnter={this.onPressEnter}
             onQueryChange={this.onChangeQuery}
-            supportsLogs={supportsLogs}
           />
         </div>
         <div className="query-row-tools">

+ 8 - 8
public/app/features/explore/QueryTransactions.tsx → public/app/features/explore/QueryTransactionStatus.tsx

@@ -1,17 +1,17 @@
 import React, { PureComponent } from 'react';
 
-import { QueryTransaction as QueryTransactionModel } from 'app/types/explore';
+import { QueryTransaction } from 'app/types/explore';
 import ElapsedTime from './ElapsedTime';
 
 function formatLatency(value) {
   return `${(value / 1000).toFixed(1)}s`;
 }
 
-interface QueryTransactionProps {
-  transaction: QueryTransactionModel;
+interface QueryTransactionStatusItemProps {
+  transaction: QueryTransaction;
 }
 
-class QueryTransaction extends PureComponent<QueryTransactionProps> {
+class QueryTransactionStatusItem extends PureComponent<QueryTransactionStatusItemProps> {
   render() {
     const { transaction } = this.props;
     const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading';
@@ -26,16 +26,16 @@ class QueryTransaction extends PureComponent<QueryTransactionProps> {
   }
 }
 
-interface QueryTransactionsProps {
-  transactions: QueryTransactionModel[];
+interface QueryTransactionStatusProps {
+  transactions: QueryTransaction[];
 }
 
-export default class QueryTransactions extends PureComponent<QueryTransactionsProps> {
+export default class QueryTransactionStatus extends PureComponent<QueryTransactionStatusProps> {
   render() {
     const { transactions } = this.props;
     return (
       <div className="query-transactions">
-        {transactions.map((t, i) => <QueryTransaction key={`${t.query}:${t.resultType}`} transaction={t} />)}
+        {transactions.map((t, i) => <QueryTransactionStatusItem key={`${t.query}:${t.resultType}`} transaction={t} />)}
       </div>
     );
   }

+ 4 - 4
public/app/features/explore/TimePicker.test.tsx

@@ -33,8 +33,8 @@ describe('<TimePicker />', () => {
       to: '1000',
     };
     const rangeString = rangeUtil.describeTimeRange({
-      from: parseTime(range.from),
-      to: parseTime(range.to),
+      from: parseTime(range.from, true),
+      to: parseTime(range.to, true),
     });
     const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
     expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
@@ -50,8 +50,8 @@ describe('<TimePicker />', () => {
       to: '4000',
     };
     const rangeString = rangeUtil.describeTimeRange({
-      from: parseTime(range.from),
-      to: parseTime(range.to),
+      from: parseTime(range.from, true),
+      to: parseTime(range.to, true),
     });
 
     const onChangeTime = sinon.spy();

+ 0 - 5
public/app/features/panel/panel_ctrl.ts

@@ -48,11 +48,6 @@ export class PanelCtrl {
     }
 
     $scope.$on('component-did-mount', () => this.panelDidMount());
-
-    $scope.$on('$destroy', () => {
-      this.events.emit('panel-teardown');
-      this.events.removeAllListeners();
-    });
   }
 
   panelDidMount() {

+ 4 - 2
public/app/features/plugins/datasource_srv.ts

@@ -8,9 +8,10 @@ import { importPluginModule } from './plugin_loader';
 
 // Types
 import { DataSourceApi } from 'app/types/series';
+import { DataSource } from 'app/types';
 
 export class DatasourceSrv {
-  datasources: any;
+  datasources: { [name: string]: DataSource };
 
   /** @ngInject */
   constructor(private $q, private $injector, private $rootScope, private templateSrv) {
@@ -61,9 +62,10 @@ export class DatasourceSrv {
           throw new Error('Plugin module is missing Datasource constructor');
         }
 
-        const instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
+        const instance: DataSource = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
         instance.meta = pluginDef;
         instance.name = name;
+        instance.pluginExports = plugin;
         this.datasources[name] = instance;
         deferred.resolve(instance);
       })

+ 29 - 0
public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+
+const CHEAT_SHEET_ITEMS = [
+  {
+    title: 'Logs From a Job',
+    expression: '{job="default/prometheus"}',
+    label: 'Returns all log lines emitted by instances of this job.',
+  },
+  {
+    title: 'Search For Text',
+    expression: '{app="cassandra"} Maximum memory usage',
+    label: 'Returns all log lines for the selector and highlights the given text in the results.',
+  },
+];
+
+export default (props: any) => (
+  <div>
+    <h1>Logging Cheat Sheet</h1>
+    {CHEAT_SHEET_ITEMS.map(item => (
+      <div className="cheat-sheet-item" key={item.expression}>
+        <div className="cheat-sheet-item__title">{item.title}</div>
+        <div className="cheat-sheet-item__expression" onClick={e => props.onClickQuery(item.expression)}>
+          <code>{item.expression}</code>
+        </div>
+        <div className="cheat-sheet-item__label">{item.label}</div>
+      </div>
+    ))}
+  </div>
+);

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

@@ -0,0 +1,205 @@
+import _ from 'lodash';
+import React from 'react';
+import Cascader from 'rc-cascader';
+import PluginPrism from 'slate-prism';
+import Prism from 'prismjs';
+
+import { TypeaheadOutput } from 'app/types/explore';
+
+// dom also includes Element polyfills
+import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
+import BracesPlugin from 'app/features/explore/slate-plugins/braces';
+import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
+import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
+
+const PRISM_SYNTAX = 'promql';
+
+export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
+  // Modify suggestion based on context
+  switch (typeaheadContext) {
+    case 'context-labels': {
+      const nextChar = getNextCharacter();
+      if (!nextChar || nextChar === '}' || nextChar === ',') {
+        suggestion += '=';
+      }
+      break;
+    }
+
+    case 'context-label-values': {
+      // Always add quotes and remove existing ones instead
+      if (!typeaheadText.match(/^(!?=~?"|")/)) {
+        suggestion = `"${suggestion}`;
+      }
+      if (getNextCharacter() !== '"') {
+        suggestion = `${suggestion}"`;
+      }
+      break;
+    }
+
+    default:
+  }
+  return suggestion;
+}
+
+interface CascaderOption {
+  label: string;
+  value: string;
+  children?: CascaderOption[];
+  disabled?: boolean;
+}
+
+interface LoggingQueryFieldProps {
+  datasource: any;
+  error?: string | JSX.Element;
+  hint?: any;
+  history?: any[];
+  initialQuery?: string | null;
+  onClickHintFix?: (action: any) => void;
+  onPressEnter?: () => void;
+  onQueryChange?: (value: string, override?: boolean) => void;
+}
+
+interface LoggingQueryFieldState {
+  logLabelOptions: any[];
+  syntaxLoaded: boolean;
+}
+
+class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, LoggingQueryFieldState> {
+  plugins: any[];
+  languageProvider: any;
+
+  constructor(props: LoggingQueryFieldProps, context) {
+    super(props, context);
+
+    if (props.datasource.languageProvider) {
+      this.languageProvider = props.datasource.languageProvider;
+    }
+
+    this.plugins = [
+      BracesPlugin(),
+      RunnerPlugin({ handler: props.onPressEnter }),
+      PluginPrism({
+        onlyIn: node => node.type === 'code_block',
+        getSyntax: node => 'promql',
+      }),
+    ];
+
+    this.state = {
+      logLabelOptions: [],
+      syntaxLoaded: false,
+    };
+  }
+
+  componentDidMount() {
+    if (this.languageProvider) {
+      this.languageProvider.start().then(() => this.onReceiveMetrics());
+    }
+  }
+
+  onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
+    let query;
+    if (selectedOptions.length === 1) {
+      if (selectedOptions[0].children.length === 0) {
+        query = selectedOptions[0].value;
+      } else {
+        // Ignore click on group
+        return;
+      }
+    } else {
+      const key = selectedOptions[0].value;
+      const value = selectedOptions[1].value;
+      query = `{${key}="${value}"}`;
+    }
+    this.onChangeQuery(query, true);
+  };
+
+  onChangeQuery = (value: string, override?: boolean) => {
+    // Send text change to parent
+    const { onQueryChange } = this.props;
+    if (onQueryChange) {
+      onQueryChange(value, override);
+    }
+  };
+
+  onClickHintFix = () => {
+    const { hint, onClickHintFix } = this.props;
+    if (onClickHintFix && hint && hint.fix) {
+      onClickHintFix(hint.fix.action);
+    }
+  };
+
+  onReceiveMetrics = () => {
+    Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
+    const { logLabelOptions } = this.languageProvider;
+    this.setState({
+      logLabelOptions,
+      syntaxLoaded: true,
+    });
+  };
+
+  onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+    if (!this.languageProvider) {
+      return { suggestions: [] };
+    }
+
+    const { history } = this.props;
+    const { prefix, text, value, wrapperNode } = typeahead;
+
+    // Get DOM-dependent context
+    const wrapperClasses = Array.from(wrapperNode.classList);
+    const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
+    const labelKey = labelKeyNode && labelKeyNode.textContent;
+    const nextChar = getNextCharacter();
+
+    const result = this.languageProvider.provideCompletionItems(
+      { text, value, prefix, wrapperClasses, labelKey },
+      { history }
+    );
+
+    console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
+
+    return result;
+  };
+
+  render() {
+    const { error, hint, initialQuery } = this.props;
+    const { logLabelOptions, syntaxLoaded } = this.state;
+    const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
+
+    return (
+      <div className="prom-query-field">
+        <div className="prom-query-field-tools">
+          <Cascader options={logLabelOptions} onChange={this.onChangeLogLabels}>
+            <button className="btn navbar-button navbar-button--tight">Log labels</button>
+          </Cascader>
+        </div>
+        <div className="prom-query-field-wrapper">
+          <TypeaheadField
+            additionalPlugins={this.plugins}
+            cleanText={cleanText}
+            initialValue={initialQuery}
+            onTypeahead={this.onTypeahead}
+            onWillApplySuggestion={willApplySuggestion}
+            onValueChanged={this.onChangeQuery}
+            placeholder="Enter a PromQL query"
+            portalOrigin="prometheus"
+            syntaxLoaded={syntaxLoaded}
+          />
+          {error ? <div className="prom-query-field-info text-error">{error}</div> : null}
+          {hint ? (
+            <div className="prom-query-field-info text-warning">
+              {hint.label}{' '}
+              {hint.fix ? (
+                <a className="text-link muted" onClick={this.onClickHintFix}>
+                  {hint.fix.label}
+                </a>
+              ) : null}
+            </div>
+          ) : null}
+        </div>
+      </div>
+    );
+  }
+}
+
+export default LoggingQueryField;

+ 60 - 0
public/app/plugins/datasource/logging/components/LoggingStartPage.tsx

@@ -0,0 +1,60 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+
+import LoggingCheatSheet from './LoggingCheatSheet';
+
+const TAB_MENU_ITEMS = [
+  {
+    text: 'Start',
+    id: 'start',
+    icon: 'fa fa-rocket',
+  },
+];
+
+export default class LoggingStartPage extends PureComponent<any, { active: string }> {
+  state = {
+    active: 'start',
+  };
+
+  onClickTab = active => {
+    this.setState({ active });
+  };
+
+  render() {
+    const { active } = this.state;
+    const customCss = '';
+
+    return (
+      <div style={{ margin: '45px 0', border: '1px solid #ddd', borderRadius: 5 }}>
+        <div className="page-header-canvas">
+          <div className="page-container">
+            <div className="page-header">
+              <nav>
+                <ul className={`gf-tabs ${customCss}`}>
+                  {TAB_MENU_ITEMS.map((tab, idx) => {
+                    const tabClasses = classNames({
+                      'gf-tabs-link': true,
+                      active: tab.id === active,
+                    });
+
+                    return (
+                      <li className="gf-tabs-item" key={tab.id}>
+                        <a className={tabClasses} onClick={() => this.onClickTab(tab.id)}>
+                          <i className={tab.icon} />
+                          {tab.text}
+                        </a>
+                      </li>
+                    );
+                  })}
+                </ul>
+              </nav>
+            </div>
+          </div>
+        </div>
+        <div className="page-container page-body">
+          {active === 'start' && <LoggingCheatSheet onClickQuery={this.props.onClickQuery} />}
+        </div>
+      </div>
+    );
+  }
+}

+ 6 - 1
public/app/plugins/datasource/logging/datasource.ts

@@ -2,6 +2,7 @@ import _ from 'lodash';
 
 import * as dateMath from 'app/core/utils/datemath';
 
+import LanguageProvider from './language_provider';
 import { processStreams } from './result_transformer';
 
 const DEFAULT_LIMIT = 100;
@@ -48,8 +49,12 @@ function serializeParams(data: any) {
 }
 
 export default class LoggingDatasource {
+  languageProvider: LanguageProvider;
+
   /** @ngInject */
-  constructor(private instanceSettings, private backendSrv, private templateSrv) {}
+  constructor(private instanceSettings, private backendSrv, private templateSrv) {
+    this.languageProvider = new LanguageProvider(this);
+  }
 
   _request(apiUrl: string, data?, options?: any) {
     const baseUrl = this.instanceSettings.url;

+ 211 - 0
public/app/plugins/datasource/logging/language_provider.ts

@@ -0,0 +1,211 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+import {
+  CompletionItem,
+  CompletionItemGroup,
+  LanguageProvider,
+  TypeaheadInput,
+  TypeaheadOutput,
+} from 'app/types/explore';
+
+import { parseSelector } from 'app/plugins/datasource/prometheus/language_utils';
+import PromqlSyntax from 'app/plugins/datasource/prometheus/promql';
+
+const DEFAULT_KEYS = ['job', 'instance'];
+const EMPTY_SELECTOR = '{}';
+const HISTORY_ITEM_COUNT = 5;
+const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
+
+const wrapLabel = (label: string) => ({ label });
+
+export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
+  const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
+  const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
+  const count = historyForItem.length;
+  const recent = historyForItem[0];
+  let hint = `Queried ${count} times in the last 24h.`;
+  if (recent) {
+    const lastQueried = moment(recent.ts).fromNow();
+    hint = `${hint} Last queried ${lastQueried}.`;
+  }
+  return {
+    ...item,
+    documentation: hint,
+  };
+}
+
+export default class LoggingLanguageProvider extends LanguageProvider {
+  labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
+  labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
+  logLabelOptions: any[];
+  started: boolean;
+
+  constructor(datasource: any, initialValues?: any) {
+    super();
+
+    this.datasource = datasource;
+    this.labelKeys = {};
+    this.labelValues = {};
+    this.started = false;
+
+    Object.assign(this, initialValues);
+  }
+  // Strip syntax chars
+  cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
+
+  getSyntax() {
+    return PromqlSyntax;
+  }
+
+  request = url => {
+    return this.datasource.metadataRequest(url);
+  };
+
+  start = () => {
+    if (!this.started) {
+      this.started = true;
+      return Promise.all([this.fetchLogLabels()]);
+    }
+    return Promise.resolve([]);
+  };
+
+  // Keep this DOM-free for testing
+  provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput {
+    // Syntax spans have 3 classes by default. More indicate a recognized token
+    const tokenRecognized = wrapperClasses.length > 3;
+    // Determine candidates by CSS context
+    if (_.includes(wrapperClasses, 'context-labels')) {
+      // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
+      return this.getLabelCompletionItems.apply(this, arguments);
+    } else if (
+      // Show default suggestions in a couple of scenarios
+      (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
+      (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
+      text.match(/[+\-*/^%]/) // Anything after binary operator
+    ) {
+      return this.getEmptyCompletionItems(context || {});
+    }
+
+    return {
+      suggestions: [],
+    };
+  }
+
+  getEmptyCompletionItems(context: any): TypeaheadOutput {
+    const { history } = context;
+    const suggestions: CompletionItemGroup[] = [];
+
+    if (history && history.length > 0) {
+      const historyItems = _.chain(history)
+        .uniqBy('query')
+        .take(HISTORY_ITEM_COUNT)
+        .map(h => h.query)
+        .map(wrapLabel)
+        .map(item => addHistoryMetadata(item, history))
+        .value();
+
+      suggestions.push({
+        prefixMatch: true,
+        skipSort: true,
+        label: 'History',
+        items: historyItems,
+      });
+    }
+
+    return { suggestions };
+  }
+
+  getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
+    let context: string;
+    const suggestions: CompletionItemGroup[] = [];
+    const line = value.anchorBlock.getText();
+    const cursorOffset: number = value.anchorOffset;
+
+    // Get normalized selector
+    let selector;
+    let parsedSelector;
+    try {
+      parsedSelector = parseSelector(line, cursorOffset);
+      selector = parsedSelector.selector;
+    } catch {
+      selector = EMPTY_SELECTOR;
+    }
+    const containsMetric = selector.indexOf('__name__=') > -1;
+    const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
+
+    if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
+      // Label values
+      if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
+        const labelValues = this.labelValues[selector][labelKey];
+        context = 'context-label-values';
+        suggestions.push({
+          label: `Label values for "${labelKey}"`,
+          items: labelValues.map(wrapLabel),
+        });
+      }
+    } else {
+      // Label keys
+      const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
+      if (labelKeys) {
+        const possibleKeys = _.difference(labelKeys, existingKeys);
+        if (possibleKeys.length > 0) {
+          context = 'context-labels';
+          suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
+        }
+      }
+    }
+
+    return { context, suggestions };
+  }
+
+  async fetchLogLabels() {
+    const url = '/api/prom/label';
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const labelKeys = body.data.slice().sort();
+      const labelKeysBySelector = {
+        ...this.labelKeys,
+        [EMPTY_SELECTOR]: labelKeys,
+      };
+      const labelValuesByKey = {};
+      this.logLabelOptions = [];
+      for (const key of labelKeys) {
+        const valuesUrl = `/api/prom/label/${key}/values`;
+        const res = await this.request(valuesUrl);
+        const body = await (res.data || res.json());
+        const values = body.data.slice().sort();
+        labelValuesByKey[key] = values;
+        this.logLabelOptions.push({
+          label: key,
+          value: key,
+          children: values.map(value => ({ label: value, value })),
+        });
+      }
+      this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
+      this.labelKeys = labelKeysBySelector;
+    } catch (e) {
+      console.error(e);
+    }
+  }
+
+  async fetchLabelValues(key: string) {
+    const url = `/api/prom/label/${key}/values`;
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const exisingValues = this.labelValues[EMPTY_SELECTOR];
+      const values = {
+        ...exisingValues,
+        [key]: body.data,
+      };
+      this.labelValues = {
+        ...this.labelValues,
+        [EMPTY_SELECTOR]: values,
+      };
+    } catch (e) {
+      console.error(e);
+    }
+  }
+}

+ 9 - 1
public/app/plugins/datasource/logging/module.ts

@@ -1,7 +1,15 @@
 import Datasource from './datasource';
 
+import LoggingStartPage from './components/LoggingStartPage';
+import LoggingQueryField from './components/LoggingQueryField';
+
 export class LoggingConfigCtrl {
   static templateUrl = 'partials/config.html';
 }
 
-export { Datasource, LoggingConfigCtrl as ConfigCtrl };
+export {
+  Datasource,
+  LoggingConfigCtrl as ConfigCtrl,
+  LoggingQueryField as ExploreQueryField,
+  LoggingStartPage as ExploreStartPage,
+};

+ 20 - 16
public/app/plugins/datasource/mysql/datasource.ts

@@ -1,24 +1,27 @@
 import _ from 'lodash';
 import ResponseParser from './response_parser';
+import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
 
 export class MysqlDatasource {
   id: any;
   name: any;
   responseParser: ResponseParser;
+  queryModel: MysqlQuery;
   interval: string;
 
   /** @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.responseParser = new ResponseParser(this.$q);
+    this.queryModel = new MysqlQuery({});
     this.interval = (instanceSettings.jsonData || {}).timeInterval;
   }
 
-  interpolateVariable(value, variable) {
+  interpolateVariable = (value, variable) => {
     if (typeof value === 'string') {
       if (variable.multi || variable.includeAll) {
-        return "'" + value.replace(/'/g, `''`) + "'";
+        return this.queryModel.quoteLiteral(value);
       } else {
         return value;
       }
@@ -28,27 +31,25 @@ export class MysqlDatasource {
       return value;
     }
 
-    const quotedValues = _.map(value, val => {
-      if (typeof value === 'number') {
-        return value;
-      }
-
-      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 MysqlQuery(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,
       };
     });
 
@@ -109,8 +110,11 @@ export class MysqlDatasource {
       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) {

+ 142 - 0
public/app/plugins/datasource/mysql/meta_query.ts

@@ -0,0 +1,142 @@
+export class MysqlMetaQuery {
+  constructor(private target, private queryModel) {}
+
+  getOperators(datatype: string) {
+    switch (datatype) {
+      case 'double':
+      case 'float': {
+        return ['=', '!=', '<', '<=', '>', '>='];
+      }
+      case 'text':
+      case 'tinytext':
+      case 'mediumtext':
+      case 'longtext':
+      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
+    table_name as table_name,
+    ( SELECT
+        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
+        c.data_type IN ('timestamp', 'datetime')
+      ORDER BY ordinal_position LIMIT 1
+    ) AS time_column,
+    ( SELECT
+        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
+        c.data_type IN('float', 'int', 'bigint')
+      ORDER BY ordinal_position LIMIT 1
+    ) AS value_column
+  FROM information_schema.tables t
+  WHERE
+    t.table_schema = database() AND
+    EXISTS
+    ( SELECT 1
+      FROM information_schema.columns c
+      WHERE
+        c.table_schema = t.table_schema AND
+        c.table_name = t.table_name AND
+        c.data_type IN ('timestamp', 'datetime')
+    ) AND
+    EXISTS
+    ( SELECT 1
+      FROM information_schema.columns c
+      WHERE
+        c.table_schema = t.table_schema AND
+        c.table_name = t.table_name AND
+        c.data_type IN('float', 'int', 'bigint')
+    )
+  LIMIT 1
+;`;
+    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 = database() AND table_name = ' + this.quoteIdentAsLiteral(table);
+
+      return query;
+    }
+  }
+
+  buildTableQuery() {
+    return 'SELECT table_name FROM information_schema.tables WHERE table_schema = database() ORDER BY table_name';
+  }
+
+  buildColumnQuery(type?: string) {
+    let query = 'SELECT column_name FROM information_schema.columns WHERE ';
+    query += this.buildTableConstraint(this.target.table);
+
+    switch (type) {
+      case 'time': {
+        query += " AND data_type IN ('timestamp','datetime','bigint','int','double','float')";
+        break;
+      }
+      case 'metric': {
+        query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')";
+        break;
+      }
+      case 'value': {
+        query += " AND data_type IN ('bigint','int','smallint','mediumint','tinyint','double','decimal','float')";
+        query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn);
+        break;
+      }
+      case 'group': {
+        query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')";
+        break;
+      }
+    }
+
+    query += ' ORDER BY column_name';
+
+    return query;
+  }
+
+  buildValueQuery(column: string) {
+    let query = 'SELECT DISTINCT QUOTE(' + 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 data_type
+FROM information_schema.columns
+WHERE `;
+    query += ' table_name = ' + this.quoteIdentAsLiteral(this.target.table);
+    query += ' AND column_name = ' + this.quoteIdentAsLiteral(column);
+    return query;
+  }
+}

+ 233 - 0
public/app/plugins/datasource/mysql/mysql_query.ts

@@ -0,0 +1,233 @@
+import _ from 'lodash';
+
+export default class MysqlQuery {
+  target: any;
+  templateSrv: any;
+  scopedVars: any;
+
+  /** @ngInject */
+  constructor(target, templateSrv?, scopedVars?) {
+    this.target = target;
+    this.templateSrv = templateSrv;
+    this.scopedVars = scopedVars;
+
+    target.format = target.format || 'time_series';
+    target.timeColumn = target.timeColumn || 'time';
+    target.metricColumn = target.metricColumn || 'none';
+
+    target.group = target.group || [];
+    target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }];
+    target.select = target.select || [[{ type: 'column', params: ['value'] }]];
+
+    // handle pre query gui panels gracefully
+    if (!('rawQuery' in this.target)) {
+      if ('rawSql' in target) {
+        // pre query gui panel
+        target.rawQuery = true;
+      } else {
+        // new panel
+        target.rawQuery = false;
+      }
+    }
+
+    // give interpolateQueryStr access to this
+    this.interpolateQueryStr = this.interpolateQueryStr.bind(this);
+  }
+
+  // remove identifier quoting from identifier to use in metadata queries
+  unquoteIdentifier(value) {
+    if (value[0] === '"' && value[value.length - 1] === '"') {
+      return value.substring(1, value.length - 1).replace(/""/g, '"');
+    } else {
+      return value;
+    }
+  }
+
+  quoteIdentifier(value) {
+    return '"' + value.replace(/"/g, '""') + '"';
+  }
+
+  quoteLiteral(value) {
+    return "'" + value.replace(/'/g, "''") + "'";
+  }
+
+  escapeLiteral(value) {
+    return value.replace(/'/g, "''");
+  }
+
+  hasTimeGroup() {
+    return _.find(this.target.group, (g: any) => g.type === 'time');
+  }
+
+  hasMetricColumn() {
+    return this.target.metricColumn !== 'none';
+  }
+
+  interpolateQueryStr(value, variable, defaultFormatFn) {
+    // if no multi or include all do not regexEscape
+    if (!variable.multi && !variable.includeAll) {
+      return this.escapeLiteral(value);
+    }
+
+    if (typeof value === 'string') {
+      return this.quoteLiteral(value);
+    }
+
+    const escapedValues = _.map(value, this.quoteLiteral);
+    return escapedValues.join(',');
+  }
+
+  render(interpolate?) {
+    const target = this.target;
+
+    // new query with no table set yet
+    if (!this.target.rawQuery && !('table' in this.target)) {
+      return '';
+    }
+
+    if (!target.rawQuery) {
+      target.rawSql = this.buildQuery();
+    }
+
+    if (interpolate) {
+      return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr);
+    } else {
+      return target.rawSql;
+    }
+  }
+
+  hasUnixEpochTimecolumn() {
+    return ['int', 'bigint', 'double'].indexOf(this.target.timeColumnType) > -1;
+  }
+
+  buildTimeColumn(alias = true) {
+    const timeGroup = this.hasTimeGroup();
+    let query;
+    let macro = '$__timeGroup';
+
+    if (timeGroup) {
+      let args;
+      if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') {
+        args = timeGroup.params.join(',');
+      } else {
+        args = timeGroup.params[0];
+      }
+      if (this.hasUnixEpochTimecolumn()) {
+        macro = '$__unixEpochGroup';
+      }
+      if (alias) {
+        macro += 'Alias';
+      }
+      query = macro + '(' + this.target.timeColumn + ',' + args + ')';
+    } else {
+      query = this.target.timeColumn;
+      if (alias) {
+        query += ' AS "time"';
+      }
+    }
+
+    return query;
+  }
+
+  buildMetricColumn() {
+    if (this.hasMetricColumn()) {
+      return this.target.metricColumn + ' AS metric';
+    }
+
+    return '';
+  }
+
+  buildValueColumns() {
+    let query = '';
+    for (const column of this.target.select) {
+      query += ',\n  ' + this.buildValueColumn(column);
+    }
+
+    return query;
+  }
+
+  buildValueColumn(column) {
+    let query = '';
+
+    const columnName = _.find(column, (g: any) => g.type === 'column');
+    query = columnName.params[0];
+
+    const aggregate = _.find(column, (g: any) => g.type === 'aggregate');
+
+    if (aggregate) {
+      const func = aggregate.params[0];
+      query = func + '(' + query + ')';
+    }
+
+    const alias = _.find(column, (g: any) => g.type === 'alias');
+    if (alias) {
+      query += ' AS ' + this.quoteIdentifier(alias.params[0]);
+    }
+
+    return query;
+  }
+
+  buildWhereClause() {
+    let query = '';
+    const conditions = _.map(this.target.where, (tag, index) => {
+      switch (tag.type) {
+        case 'macro':
+          return tag.name + '(' + this.target.timeColumn + ')';
+          break;
+        case 'expression':
+          return tag.params.join(' ');
+          break;
+      }
+    });
+
+    if (conditions.length > 0) {
+      query = '\nWHERE\n  ' + conditions.join(' AND\n  ');
+    }
+
+    return query;
+  }
+
+  buildGroupClause() {
+    let query = '';
+    let groupSection = '';
+
+    for (let i = 0; i < this.target.group.length; i++) {
+      const part = this.target.group[i];
+      if (i > 0) {
+        groupSection += ', ';
+      }
+      if (part.type === 'time') {
+        groupSection += '1';
+      } else {
+        groupSection += part.params[0];
+      }
+    }
+
+    if (groupSection.length) {
+      query = '\nGROUP BY ' + groupSection;
+      if (this.hasMetricColumn()) {
+        query += ',2';
+      }
+    }
+    return query;
+  }
+
+  buildQuery() {
+    let query = 'SELECT';
+
+    query += '\n  ' + this.buildTimeColumn();
+    if (this.hasMetricColumn()) {
+      query += ',\n  ' + this.buildMetricColumn();
+    }
+    query += this.buildValueColumns();
+
+    query += '\nFROM ' + this.target.table;
+
+    query += this.buildWhereClause();
+    query += this.buildGroupClause();
+
+    query += '\nORDER BY ' + this.buildTimeColumn(false);
+
+    return query;
+  }
+}

+ 134 - 36
public/app/plugins/datasource/mysql/partials/query.editor.html

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

+ 559 - 11
public/app/plugins/datasource/mysql/query_ctrl.ts

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

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

@@ -9,12 +9,23 @@ describe('MySQLDatasource', () => {
     replace: jest.fn(text => text),
   };
 
+  const raw = {
+    from: moment.utc('2018-04-25 10:00'),
+    to: moment.utc('2018-04-25 11:00'),
+  };
   const ctx = {
     backendSrv,
+    timeSrvMock: {
+      timeRange: () => ({
+        from: raw.from,
+        to: raw.to,
+        raw: raw,
+      }),
+    },
   } as any;
 
   beforeEach(() => {
-    ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv);
+    ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv, ctx.timeSrvMock);
   });
 
   describe('When performing annotationQuery', () => {

+ 86 - 0
public/app/plugins/datasource/mysql/sql_part.ts

@@ -0,0 +1,86 @@
+import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part';
+
+const index = [];
+
+function createPart(part): any {
+  const def = index[part.type];
+  if (!def) {
+    return null;
+  }
+
+  return new SqlPart(part, def);
+}
+
+function register(options: any) {
+  index[options.type] = new SqlPartDef(options);
+}
+
+register({
+  type: 'column',
+  style: 'label',
+  params: [{ type: 'column', dynamicLookup: true }],
+  defaultParams: ['value'],
+});
+
+register({
+  type: 'expression',
+  style: 'expression',
+  label: 'Expr:',
+  params: [
+    { name: 'left', type: 'string', dynamicLookup: true },
+    { name: 'op', type: 'string', dynamicLookup: true },
+    { name: 'right', type: 'string', dynamicLookup: true },
+  ],
+  defaultParams: ['value', '=', 'value'],
+});
+
+register({
+  type: 'macro',
+  style: 'label',
+  label: 'Macro:',
+  params: [],
+  defaultParams: [],
+});
+
+register({
+  type: 'aggregate',
+  style: 'label',
+  params: [
+    {
+      name: 'name',
+      type: 'string',
+      options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'],
+    },
+  ],
+  defaultParams: ['avg'],
+});
+
+register({
+  type: 'alias',
+  style: 'label',
+  params: [{ name: 'name', type: 'string', quote: 'double' }],
+  defaultParams: ['alias'],
+});
+
+register({
+  type: 'time',
+  style: 'function',
+  label: 'time',
+  params: [
+    {
+      name: 'interval',
+      type: 'interval',
+      options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'],
+    },
+    {
+      name: 'fill',
+      type: 'string',
+      options: ['none', 'NULL', 'previous', '0'],
+    },
+  ],
+  defaultParams: ['$__interval', 'none'],
+});
+
+export default {
+  create: createPart,
+};

+ 35 - 0
public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+const CHEAT_SHEET_ITEMS = [
+  {
+    title: 'Request Rate',
+    expression: 'rate(http_request_total[5m])',
+    label:
+      'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.',
+  },
+  {
+    title: '95th Percentile of Request Latencies',
+    expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))',
+    label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.',
+  },
+  {
+    title: 'Alerts Firing',
+    expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))',
+    label: 'Sums up the alerts that have been firing over the last 24 hours.',
+  },
+];
+
+export default (props: any) => (
+  <div>
+    <h1>PromQL Cheat Sheet</h1>
+    {CHEAT_SHEET_ITEMS.map(item => (
+      <div className="cheat-sheet-item" key={item.expression}>
+        <div className="cheat-sheet-item__title">{item.title}</div>
+        <div className="cheat-sheet-item__expression" onClick={e => props.onClickQuery(item.expression)}>
+          <code>{item.expression}</code>
+        </div>
+        <div className="cheat-sheet-item__label">{item.label}</div>
+      </div>
+    ))}
+  </div>
+);

+ 0 - 0
public/app/features/explore/PromQueryField.test.tsx → public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx


+ 21 - 53
public/app/features/explore/PromQueryField.tsx → public/app/plugins/datasource/prometheus/components/PromQueryField.tsx

@@ -7,11 +7,10 @@ import Prism from 'prismjs';
 import { TypeaheadOutput } from 'app/types/explore';
 
 // dom also includes Element polyfills
-import { getNextCharacter, getPreviousCousin } from './utils/dom';
-import BracesPlugin from './slate-plugins/braces';
-import RunnerPlugin from './slate-plugins/runner';
-
-import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
+import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
+import BracesPlugin from 'app/features/explore/slate-plugins/braces';
+import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
+import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
 
 const HISTOGRAM_GROUP = '__histograms__';
 const METRIC_MARK = 'metric';
@@ -51,10 +50,7 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
   return [...options, ...metricsOptions];
 }
 
-export function willApplySuggestion(
-  suggestion: string,
-  { typeaheadContext, typeaheadText }: TypeaheadFieldState
-): string {
+export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
   // Modify suggestion based on context
   switch (typeaheadContext) {
     case 'context-labels': {
@@ -98,11 +94,9 @@ interface PromQueryFieldProps {
   onClickHintFix?: (action: any) => void;
   onPressEnter?: () => void;
   onQueryChange?: (value: string, override?: boolean) => void;
-  supportsLogs?: boolean; // To be removed after Logging gets its own query field
 }
 
 interface PromQueryFieldState {
-  logLabelOptions: any[];
   metricsOptions: any[];
   metricsByPrefix: CascaderOption[];
   syntaxLoaded: boolean;
@@ -129,7 +123,6 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     ];
 
     this.state = {
-      logLabelOptions: [],
       metricsByPrefix: [],
       metricsOptions: [],
       syntaxLoaded: false,
@@ -142,23 +135,6 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     }
   }
 
-  onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
-    let query;
-    if (selectedOptions.length === 1) {
-      if (selectedOptions[0].children.length === 0) {
-        query = selectedOptions[0].value;
-      } else {
-        // Ignore click on group
-        return;
-      }
-    } else {
-      const key = selectedOptions[0].value;
-      const value = selectedOptions[1].value;
-      query = `{${key}="${value}"}`;
-    }
-    this.onChangeQuery(query, true);
-  };
-
   onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
     let query;
     if (selectedOptions.length === 1) {
@@ -243,37 +219,29 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   };
 
   render() {
-    const { error, hint, initialQuery, supportsLogs } = this.props;
-    const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
+    const { error, hint, initialQuery } = this.props;
+    const { metricsOptions, syntaxLoaded } = this.state;
     const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
 
     return (
       <div className="prom-query-field">
         <div className="prom-query-field-tools">
-          {supportsLogs ? (
-            <Cascader options={logLabelOptions} onChange={this.onChangeLogLabels}>
-              <button className="btn navbar-button navbar-button--tight">Log labels</button>
-            </Cascader>
-          ) : (
-            <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
-              <button className="btn navbar-button navbar-button--tight">Metrics</button>
-            </Cascader>
-          )}
+          <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
+            <button className="btn navbar-button navbar-button--tight">Metrics</button>
+          </Cascader>
         </div>
         <div className="prom-query-field-wrapper">
-          <div className="slate-query-field-wrapper">
-            <TypeaheadField
-              additionalPlugins={this.plugins}
-              cleanText={cleanText}
-              initialValue={initialQuery}
-              onTypeahead={this.onTypeahead}
-              onWillApplySuggestion={willApplySuggestion}
-              onValueChanged={this.onChangeQuery}
-              placeholder="Enter a PromQL query"
-              portalOrigin="prometheus"
-              syntaxLoaded={syntaxLoaded}
-            />
-          </div>
+          <TypeaheadField
+            additionalPlugins={this.plugins}
+            cleanText={cleanText}
+            initialValue={initialQuery}
+            onTypeahead={this.onTypeahead}
+            onWillApplySuggestion={willApplySuggestion}
+            onValueChanged={this.onChangeQuery}
+            placeholder="Enter a PromQL query"
+            portalOrigin="prometheus"
+            syntaxLoaded={syntaxLoaded}
+          />
           {error ? <div className="prom-query-field-info text-error">{error}</div> : null}
           {hint ? (
             <div className="prom-query-field-info text-warning">

+ 60 - 0
public/app/plugins/datasource/prometheus/components/PromStart.tsx

@@ -0,0 +1,60 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+
+import PromCheatSheet from './PromCheatSheet';
+
+const TAB_MENU_ITEMS = [
+  {
+    text: 'Start',
+    id: 'start',
+    icon: 'fa fa-rocket',
+  },
+];
+
+export default class PromStart extends PureComponent<any, { active: string }> {
+  state = {
+    active: 'start',
+  };
+
+  onClickTab = active => {
+    this.setState({ active });
+  };
+
+  render() {
+    const { active } = this.state;
+    const customCss = '';
+
+    return (
+      <div style={{ margin: '45px 0', border: '1px solid #ddd', borderRadius: 5 }}>
+        <div className="page-header-canvas">
+          <div className="page-container">
+            <div className="page-header">
+              <nav>
+                <ul className={`gf-tabs ${customCss}`}>
+                  {TAB_MENU_ITEMS.map((tab, idx) => {
+                    const tabClasses = classNames({
+                      'gf-tabs-link': true,
+                      active: tab.id === active,
+                    });
+
+                    return (
+                      <li className="gf-tabs-item" key={tab.id}>
+                        <a className={tabClasses} onClick={() => this.onClickTab(tab.id)}>
+                          <i className={tab.icon} />
+                          {tab.text}
+                        </a>
+                      </li>
+                    );
+                  })}
+                </ul>
+              </nav>
+            </div>
+          </div>
+        </div>
+        <div className="page-container page-body">
+          {active === 'start' && <PromCheatSheet onClickQuery={this.props.onClickQuery} />}
+        </div>
+      </div>
+    );
+  }
+}

+ 1 - 37
public/app/plugins/datasource/prometheus/language_provider.ts

@@ -46,8 +46,6 @@ export default class PromQlLanguageProvider extends LanguageProvider {
   labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
   labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   metrics?: string[];
-  logLabelOptions: any[];
-  supportsLogs?: boolean;
   started: boolean;
 
   constructor(datasource: any, initialValues?: any) {
@@ -58,7 +56,6 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     this.labelKeys = {};
     this.labelValues = {};
     this.metrics = [];
-    this.supportsLogs = false;
     this.started = false;
 
     Object.assign(this, initialValues);
@@ -243,8 +240,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     }
 
     // Query labels for selector
-    // Temporarily add skip for logging
-    if (selector && !this.labelValues[selector] && !this.supportsLogs) {
+    if (selector && !this.labelValues[selector]) {
       if (selector === EMPTY_SELECTOR) {
         // Query label values for default labels
         refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
@@ -275,38 +271,6 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     }
   }
 
-  // Temporarily here while reusing this field for logging
-  async fetchLogLabels() {
-    const url = '/api/prom/label';
-    try {
-      const res = await this.request(url);
-      const body = await (res.data || res.json());
-      const labelKeys = body.data.slice().sort();
-      const labelKeysBySelector = {
-        ...this.labelKeys,
-        [EMPTY_SELECTOR]: labelKeys,
-      };
-      const labelValuesByKey = {};
-      this.logLabelOptions = [];
-      for (const key of labelKeys) {
-        const valuesUrl = `/api/prom/label/${key}/values`;
-        const res = await this.request(valuesUrl);
-        const body = await (res.data || res.json());
-        const values = body.data.slice().sort();
-        labelValuesByKey[key] = values;
-        this.logLabelOptions.push({
-          label: key,
-          value: key,
-          children: values.map(value => ({ label: value, value })),
-        });
-      }
-      this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
-      this.labelKeys = labelKeysBySelector;
-    } catch (e) {
-      console.error(e);
-    }
-  }
-
   async fetchLabelValues(key: string) {
     const url = `/api/v1/label/${key}/values`;
     try {

+ 5 - 0
public/app/plugins/datasource/prometheus/module.ts

@@ -2,6 +2,9 @@ import { PrometheusDatasource } from './datasource';
 import { PrometheusQueryCtrl } from './query_ctrl';
 import { PrometheusConfigCtrl } from './config_ctrl';
 
+import PrometheusStartPage from './components/PromStart';
+import PromQueryField from './components/PromQueryField';
+
 class PrometheusAnnotationsQueryCtrl {
   static templateUrl = 'partials/annotations.editor.html';
 }
@@ -11,4 +14,6 @@ export {
   PrometheusQueryCtrl as QueryCtrl,
   PrometheusConfigCtrl as ConfigCtrl,
   PrometheusAnnotationsQueryCtrl as AnnotationsQueryCtrl,
+  PromQueryField as ExploreQueryField,
+  PrometheusStartPage as ExploreStartPage,
 };

+ 321 - 0
public/app/plugins/panel/graph/Legend/Legend.tsx

@@ -0,0 +1,321 @@
+import _ from 'lodash';
+import React, { PureComponent } from 'react';
+import { TimeSeries } from 'app/core/core';
+import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import { LegendItem, LEGEND_STATS } from './LegendSeriesItem';
+
+interface LegendProps {
+  seriesList: TimeSeries[];
+  optionalClass?: string;
+}
+
+interface LegendEventHandlers {
+  onToggleSeries?: (hiddenSeries) => void;
+  onToggleSort?: (sortBy, sortDesc) => void;
+  onToggleAxis?: (series: TimeSeries) => void;
+  onColorChange?: (series: TimeSeries, color: string) => void;
+}
+
+interface LegendComponentEventHandlers {
+  onToggleSeries?: (series, event) => void;
+  onToggleSort?: (sortBy, sortDesc) => void;
+  onToggleAxis?: (series: TimeSeries) => void;
+  onColorChange?: (series: TimeSeries, color: string) => void;
+}
+
+interface LegendDisplayProps {
+  hiddenSeries: any;
+  hideEmpty?: boolean;
+  hideZero?: boolean;
+  alignAsTable?: boolean;
+  rightSide?: boolean;
+  sideWidth?: number;
+}
+
+interface LegendValuesProps {
+  values?: boolean;
+  min?: boolean;
+  max?: boolean;
+  avg?: boolean;
+  current?: boolean;
+  total?: boolean;
+}
+
+interface LegendSortProps {
+  sort?: 'min' | 'max' | 'avg' | 'current' | 'total';
+  sortDesc?: boolean;
+}
+
+export type GraphLegendProps = LegendProps &
+  LegendDisplayProps &
+  LegendValuesProps &
+  LegendSortProps &
+  LegendEventHandlers;
+export type LegendComponentProps = LegendProps &
+  LegendDisplayProps &
+  LegendValuesProps &
+  LegendSortProps &
+  LegendComponentEventHandlers;
+
+interface LegendState {
+  hiddenSeries: { [seriesAlias: string]: boolean };
+}
+
+export class GraphLegend extends PureComponent<GraphLegendProps, LegendState> {
+  static defaultProps: Partial<GraphLegendProps> = {
+    values: false,
+    min: false,
+    max: false,
+    avg: false,
+    current: false,
+    total: false,
+    alignAsTable: false,
+    rightSide: false,
+    sort: undefined,
+    sortDesc: false,
+    optionalClass: '',
+    onToggleSeries: () => {},
+    onToggleSort: () => {},
+    onToggleAxis: () => {},
+    onColorChange: () => {},
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      hiddenSeries: this.props.hiddenSeries,
+    };
+  }
+
+  sortLegend() {
+    let seriesList = [...this.props.seriesList] || [];
+    if (this.props.sort) {
+      seriesList = _.sortBy(seriesList, series => {
+        let sort = series.stats[this.props.sort];
+        if (sort === null) {
+          sort = -Infinity;
+        }
+        return sort;
+      });
+      if (this.props.sortDesc) {
+        seriesList = seriesList.reverse();
+      }
+    }
+    return seriesList;
+  }
+
+  onToggleSeries = (series, event) => {
+    let hiddenSeries = { ...this.state.hiddenSeries };
+    if (event.ctrlKey || event.metaKey || event.shiftKey) {
+      if (hiddenSeries[series.alias]) {
+        delete hiddenSeries[series.alias];
+      } else {
+        hiddenSeries[series.alias] = true;
+      }
+    } else {
+      hiddenSeries = this.toggleSeriesExclusiveMode(series);
+    }
+    this.setState({ hiddenSeries: hiddenSeries });
+    this.props.onToggleSeries(hiddenSeries);
+  };
+
+  toggleSeriesExclusiveMode(series) {
+    const hiddenSeries = { ...this.state.hiddenSeries };
+
+    if (hiddenSeries[series.alias]) {
+      delete hiddenSeries[series.alias];
+    }
+
+    // check if every other series is hidden
+    const alreadyExclusive = this.props.seriesList.every(value => {
+      if (value.alias === series.alias) {
+        return true;
+      }
+
+      return hiddenSeries[value.alias];
+    });
+
+    if (alreadyExclusive) {
+      // remove all hidden series
+      this.props.seriesList.forEach(value => {
+        delete hiddenSeries[value.alias];
+      });
+    } else {
+      // hide all but this serie
+      this.props.seriesList.forEach(value => {
+        if (value.alias === series.alias) {
+          return;
+        }
+
+        hiddenSeries[value.alias] = true;
+      });
+    }
+
+    return hiddenSeries;
+  }
+
+  render() {
+    const {
+      optionalClass,
+      rightSide,
+      sideWidth,
+      sort,
+      sortDesc,
+      hideEmpty,
+      hideZero,
+      values,
+      min,
+      max,
+      avg,
+      current,
+      total,
+    } = this.props;
+    const seriesValuesProps = { values, min, max, avg, current, total };
+    const hiddenSeries = this.state.hiddenSeries;
+    const seriesHideProps = { hideEmpty, hideZero };
+    const sortProps = { sort, sortDesc };
+    const seriesList = this.sortLegend().filter(series => !series.hideFromLegend(seriesHideProps));
+    const legendClass = `${this.props.alignAsTable ? 'graph-legend-table' : ''} ${optionalClass}`;
+
+    // Set min-width if side style and there is a value, otherwise remove the CSS property
+    // Set width so it works with IE11
+    const width: any = rightSide && sideWidth ? sideWidth : undefined;
+    const ieWidth: any = rightSide && sideWidth ? sideWidth - 1 : undefined;
+    const legendStyle: React.CSSProperties = {
+      minWidth: width,
+      width: ieWidth,
+    };
+
+    const legendProps: LegendComponentProps = {
+      seriesList: seriesList,
+      hiddenSeries: hiddenSeries,
+      onToggleSeries: this.onToggleSeries,
+      onToggleAxis: this.props.onToggleAxis,
+      onToggleSort: this.props.onToggleSort,
+      onColorChange: this.props.onColorChange,
+      ...seriesValuesProps,
+      ...sortProps,
+    };
+
+    return (
+      <div className={`graph-legend-content ${legendClass}`} style={legendStyle}>
+        {this.props.alignAsTable ? <LegendTable {...legendProps} /> : <LegendSeriesList {...legendProps} />}
+      </div>
+    );
+  }
+}
+
+class LegendSeriesList extends PureComponent<LegendComponentProps> {
+  render() {
+    const { seriesList, hiddenSeries, values, min, max, avg, current, total } = this.props;
+    const seriesValuesProps = { values, min, max, avg, current, total };
+    return seriesList.map((series, i) => (
+      <LegendItem
+        // This trick required because TimeSeries.id is not unique (it's just TimeSeries.alias).
+        // In future would be good to make id unique across the series list.
+        key={`${series.id}-${i}`}
+        series={series}
+        hidden={hiddenSeries[series.alias]}
+        {...seriesValuesProps}
+        onLabelClick={this.props.onToggleSeries}
+        onColorChange={this.props.onColorChange}
+        onToggleAxis={this.props.onToggleAxis}
+      />
+    ));
+  }
+}
+
+class LegendTable extends PureComponent<Partial<LegendComponentProps>> {
+  onToggleSort = stat => {
+    let sortDesc = this.props.sortDesc;
+    let sortBy = this.props.sort;
+    if (stat !== sortBy) {
+      sortDesc = null;
+    }
+
+    // if already sort ascending, disable sorting
+    if (sortDesc === false) {
+      sortBy = null;
+      sortDesc = null;
+    } else {
+      sortDesc = !sortDesc;
+      sortBy = stat;
+    }
+    this.props.onToggleSort(sortBy, sortDesc);
+  };
+
+  render() {
+    const seriesList = this.props.seriesList;
+    const { values, min, max, avg, current, total, sort, sortDesc, hiddenSeries } = this.props;
+    const seriesValuesProps = { values, min, max, avg, current, total };
+    return (
+      <table>
+        <colgroup>
+          <col style={{ width: '100%' }} />
+        </colgroup>
+        <thead>
+          <tr>
+            <th style={{ textAlign: 'left' }} />
+            {LEGEND_STATS.map(
+              statName =>
+                seriesValuesProps[statName] && (
+                  <LegendTableHeaderItem
+                    key={statName}
+                    statName={statName}
+                    sort={sort}
+                    sortDesc={sortDesc}
+                    onClick={this.onToggleSort}
+                  />
+                )
+            )}
+          </tr>
+        </thead>
+        <tbody>
+          {seriesList.map((series, i) => (
+            <LegendItem
+              key={`${series.id}-${i}`}
+              asTable={true}
+              series={series}
+              hidden={hiddenSeries[series.alias]}
+              onLabelClick={this.props.onToggleSeries}
+              onColorChange={this.props.onColorChange}
+              onToggleAxis={this.props.onToggleAxis}
+              {...seriesValuesProps}
+            />
+          ))}
+        </tbody>
+      </table>
+    );
+  }
+}
+
+interface LegendTableHeaderProps {
+  statName: string;
+  onClick?: (statName: string) => void;
+}
+
+class LegendTableHeaderItem extends PureComponent<LegendTableHeaderProps & LegendSortProps> {
+  onClick = () => this.props.onClick(this.props.statName);
+
+  render() {
+    const { statName, sort, sortDesc } = this.props;
+    return (
+      <th className="pointer" onClick={this.onClick}>
+        {statName}
+        {sort === statName && <span className={sortDesc ? 'fa fa-caret-down' : 'fa fa-caret-up'} />}
+      </th>
+    );
+  }
+}
+
+export class Legend extends PureComponent<GraphLegendProps> {
+  render() {
+    return (
+      <CustomScrollbar>
+        <GraphLegend {...this.props} />
+      </CustomScrollbar>
+    );
+  }
+}
+
+export default Legend;

+ 196 - 0
public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx

@@ -0,0 +1,196 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import { TimeSeries } from 'app/core/core';
+import { SeriesColorPicker } from 'app/core/components/colorpicker/SeriesColorPicker';
+
+export const LEGEND_STATS = ['min', 'max', 'avg', 'current', 'total'];
+
+export interface LegendLabelProps {
+  series: TimeSeries;
+  asTable?: boolean;
+  hidden?: boolean;
+  onLabelClick?: (series, event) => void;
+  onColorChange?: (series, color: string) => void;
+  onToggleAxis?: (series) => void;
+}
+
+export interface LegendValuesProps {
+  values?: boolean;
+  min?: boolean;
+  max?: boolean;
+  avg?: boolean;
+  current?: boolean;
+  total?: boolean;
+}
+
+type LegendItemProps = LegendLabelProps & LegendValuesProps;
+
+interface LegendItemState {
+  yaxis: number;
+}
+
+export class LegendItem extends PureComponent<LegendItemProps, LegendItemState> {
+  static defaultProps = {
+    asTable: false,
+    hidden: false,
+    onLabelClick: () => {},
+    onColorChange: () => {},
+    onToggleAxis: () => {},
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      yaxis: this.props.series.yaxis,
+    };
+  }
+
+  onLabelClick = e => this.props.onLabelClick(this.props.series, e);
+
+  onToggleAxis = () => {
+    const yaxis = this.state.yaxis === 2 ? 1 : 2;
+    const info = { alias: this.props.series.alias, yaxis: yaxis };
+    this.setState({ yaxis: yaxis });
+    this.props.onToggleAxis(info);
+  };
+
+  onColorChange = color => {
+    this.props.onColorChange(this.props.series, color);
+    // Because of PureComponent nature it makes only shallow props comparison and changing of series.color doesn't run
+    // component re-render. In this case we can't rely on color, selected by user, because it may be overwritten
+    // by series overrides. So we need to use forceUpdate() to make sure we have proper series color.
+    this.forceUpdate();
+  };
+
+  renderLegendValues() {
+    const { series, asTable } = this.props;
+    const legendValueItems = [];
+    for (const valueName of LEGEND_STATS) {
+      if (this.props[valueName]) {
+        const valueFormatted = series.formatValue(series.stats[valueName]);
+        legendValueItems.push(
+          <LegendValue key={valueName} valueName={valueName} value={valueFormatted} asTable={asTable} />
+        );
+      }
+    }
+    return legendValueItems;
+  }
+
+  render() {
+    const { series, values, asTable, hidden } = this.props;
+    const seriesOptionClasses = classNames({
+      'graph-legend-series-hidden': hidden,
+      'graph-legend-series--right-y': series.yaxis === 2,
+    });
+    const valueItems = values ? this.renderLegendValues() : [];
+    const seriesLabel = (
+      <LegendSeriesLabel
+        label={series.alias}
+        color={series.color}
+        yaxis={this.state.yaxis}
+        onLabelClick={this.onLabelClick}
+        onColorChange={this.onColorChange}
+        onToggleAxis={this.onToggleAxis}
+      />
+    );
+
+    if (asTable) {
+      return (
+        <tr className={`graph-legend-series ${seriesOptionClasses}`}>
+          <td>{seriesLabel}</td>
+          {valueItems}
+        </tr>
+      );
+    } else {
+      return (
+        <div className={`graph-legend-series ${seriesOptionClasses}`}>
+          {seriesLabel}
+          {valueItems}
+        </div>
+      );
+    }
+  }
+}
+
+interface LegendSeriesLabelProps {
+  label: string;
+  color: string;
+  yaxis?: number;
+  onLabelClick?: (event) => void;
+}
+
+class LegendSeriesLabel extends PureComponent<LegendSeriesLabelProps & LegendSeriesIconProps> {
+  static defaultProps = {
+    yaxis: undefined,
+    onLabelClick: () => {},
+  };
+
+  render() {
+    const { label, color, yaxis } = this.props;
+    const { onColorChange, onToggleAxis } = this.props;
+    return [
+      <LegendSeriesIcon
+        key="icon"
+        color={color}
+        yaxis={yaxis}
+        onColorChange={onColorChange}
+        onToggleAxis={onToggleAxis}
+      />,
+      <a className="graph-legend-alias pointer" title={label} key="label" onClick={e => this.props.onLabelClick(e)}>
+        {label}
+      </a>,
+    ];
+  }
+}
+
+interface LegendSeriesIconProps {
+  color: string;
+  yaxis?: number;
+  onColorChange?: (color: string) => void;
+  onToggleAxis?: () => void;
+}
+
+interface LegendSeriesIconState {
+  color: string;
+}
+
+function SeriesIcon(props) {
+  return <i className="fa fa-minus pointer" style={{ color: props.color }} />;
+}
+
+class LegendSeriesIcon extends PureComponent<LegendSeriesIconProps, LegendSeriesIconState> {
+  static defaultProps = {
+    yaxis: undefined,
+    onColorChange: () => {},
+    onToggleAxis: () => {},
+  };
+
+  render() {
+    return (
+      <SeriesColorPicker
+        optionalClass="graph-legend-icon"
+        yaxis={this.props.yaxis}
+        color={this.props.color}
+        onColorChange={this.props.onColorChange}
+        onToggleAxis={this.props.onToggleAxis}
+      >
+        <SeriesIcon color={this.props.color} />
+      </SeriesColorPicker>
+    );
+  }
+}
+
+interface LegendValueProps {
+  value: string;
+  valueName: string;
+  asTable?: boolean;
+}
+
+function LegendValue(props: LegendValueProps) {
+  const value = props.value;
+  const valueName = props.valueName;
+  if (props.asTable) {
+    return <td className={`graph-legend-value ${valueName}`}>{value}</td>;
+  }
+  return <div className={`graph-legend-value ${valueName}`}>{value}</div>;
+}

+ 36 - 16
public/app/plugins/panel/graph/graph.ts

@@ -20,6 +20,9 @@ import { EventManager } from 'app/features/annotations/all';
 import { convertToHistogramData } from './histogram';
 import { alignYLevel } from './align_yaxes';
 import config from 'app/core/config';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Legend, GraphLegendProps } from './Legend/Legend';
 
 import { GraphCtrl } from './module';
 
@@ -35,6 +38,7 @@ class GraphElement {
   panelWidth: number;
   eventManager: EventManager;
   thresholdManager: ThresholdManager;
+  legendElem: HTMLElement;
 
   constructor(private scope, private elem, private timeSrv) {
     this.ctrl = scope.ctrl;
@@ -50,7 +54,7 @@ class GraphElement {
     });
 
     // panel events
-    this.ctrl.events.on('panel-teardown', this.onPanelteardown.bind(this));
+    this.ctrl.events.on('panel-teardown', this.onPanelTeardown.bind(this));
 
     /**
      * Split graph rendering into two parts.
@@ -63,13 +67,14 @@ class GraphElement {
 
     // global events
     appEvents.on('graph-hover', this.onGraphHover.bind(this), scope);
-
     appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), scope);
-
     this.elem.bind('plotselected', this.onPlotSelected.bind(this));
-
     this.elem.bind('plotclick', this.onPlotClick.bind(this));
-    scope.$on('$destroy', this.onScopeDestroy.bind(this));
+
+    // get graph legend element
+    if (this.elem && this.elem.parent) {
+      this.legendElem = this.elem.parent().find('.graph-legend')[0];
+    }
   }
 
   onRender(renderData) {
@@ -82,7 +87,26 @@ class GraphElement {
     const graphHeight = this.elem.height();
     updateLegendValues(this.data, this.panel, graphHeight);
 
-    this.ctrl.events.emit('render-legend');
+    const { values, min, max, avg, current, total } = this.panel.legend;
+    const { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero } = this.panel.legend;
+    const legendOptions = { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero };
+    const valueOptions = { values, min, max, avg, current, total };
+    const legendProps: GraphLegendProps = {
+      seriesList: this.data,
+      hiddenSeries: this.ctrl.hiddenSeries,
+      ...legendOptions,
+      ...valueOptions,
+      onToggleSeries: this.ctrl.onToggleSeries,
+      onToggleSort: this.ctrl.onToggleSort,
+      onColorChange: this.ctrl.onColorChange,
+      onToggleAxis: this.ctrl.onToggleAxis,
+    };
+    const legendReactElem = React.createElement(Legend, legendProps);
+    ReactDOM.render(legendReactElem, this.legendElem, () => this.onLegendRenderingComplete());
+  }
+
+  onLegendRenderingComplete() {
+    this.render_panel();
   }
 
   onGraphHover(evt) {
@@ -99,17 +123,19 @@ class GraphElement {
     this.tooltip.show(evt.pos);
   }
 
-  onPanelteardown() {
+  onPanelTeardown() {
     this.thresholdManager = null;
 
     if (this.plot) {
       this.plot.destroy();
       this.plot = null;
     }
-  }
 
-  onLegendRenderingComplete() {
-    this.render_panel();
+    this.tooltip.destroy();
+    this.elem.off();
+    this.elem.remove();
+
+    ReactDOM.unmountComponentAtNode(this.legendElem);
   }
 
   onGraphHoverClear(event, info) {
@@ -157,12 +183,6 @@ class GraphElement {
     }
   }
 
-  onScopeDestroy() {
-    this.tooltip.destroy();
-    this.elem.off();
-    this.elem.remove();
-  }
-
   shouldAbortRender() {
     if (!this.data) {
       return true;

+ 0 - 306
public/app/plugins/panel/graph/legend.ts

@@ -1,306 +0,0 @@
-import _ from 'lodash';
-import $ from 'jquery';
-import baron from 'baron';
-import coreModule from 'app/core/core_module';
-
-/** @ngInject */
-function graphLegendDirective(popoverSrv, $timeout) {
-  return {
-    link: (scope, elem) => {
-      let firstRender = true;
-      const ctrl = scope.ctrl;
-      const panel = ctrl.panel;
-      let data;
-      let seriesList;
-      let i;
-      let legendScrollbar;
-      const legendRightDefaultWidth = 10;
-      const legendElem = elem.parent();
-
-      scope.$on('$destroy', () => {
-        destroyScrollbar();
-      });
-
-      ctrl.events.on('render-legend', () => {
-        data = ctrl.seriesList;
-        if (data) {
-          render();
-        }
-        ctrl.events.emit('legend-rendering-complete');
-      });
-
-      function getSeriesIndexForElement(el) {
-        return el.parents('[data-series-index]').data('series-index');
-      }
-
-      function openColorSelector(e) {
-        // if we clicked inside poup container ignore click
-        if ($(e.target).parents('.popover').length) {
-          return;
-        }
-
-        const el = $(e.currentTarget).find('.fa-minus');
-        const index = getSeriesIndexForElement(el);
-        const series = seriesList[index];
-
-        $timeout(() => {
-          popoverSrv.show({
-            element: el[0],
-            position: 'bottom left',
-            targetAttachment: 'top left',
-            template:
-              '<series-color-picker series="series" onToggleAxis="toggleAxis" onColorChange="colorSelected">' +
-              '</series-color-picker>',
-            openOn: 'hover',
-            model: {
-              series: series,
-              toggleAxis: () => {
-                ctrl.toggleAxis(series);
-              },
-              colorSelected: color => {
-                ctrl.changeSeriesColor(series, color);
-              },
-            },
-          });
-        });
-      }
-
-      function toggleSeries(e) {
-        const el = $(e.currentTarget);
-        const index = getSeriesIndexForElement(el);
-        const seriesInfo = seriesList[index];
-        const scrollPosition = legendScrollbar.scroller.scrollTop;
-        ctrl.toggleSeries(seriesInfo, e);
-        legendScrollbar.scroller.scrollTop = scrollPosition;
-      }
-
-      function sortLegend(e) {
-        const el = $(e.currentTarget);
-        const stat = el.data('stat');
-
-        if (stat !== panel.legend.sort) {
-          panel.legend.sortDesc = null;
-        }
-
-        // if already sort ascending, disable sorting
-        if (panel.legend.sortDesc === false) {
-          panel.legend.sort = null;
-          panel.legend.sortDesc = null;
-          ctrl.render();
-          return;
-        }
-
-        panel.legend.sortDesc = !panel.legend.sortDesc;
-        panel.legend.sort = stat;
-        ctrl.render();
-      }
-
-      function getTableHeaderHtml(statName) {
-        if (!panel.legend[statName]) {
-          return '';
-        }
-        let html = '<th class="pointer" data-stat="' + statName + '">' + statName;
-
-        if (panel.legend.sort === statName) {
-          const cssClass = panel.legend.sortDesc ? 'fa fa-caret-down' : 'fa fa-caret-up';
-          html += ' <span class="' + cssClass + '"></span>';
-        }
-
-        return html + '</th>';
-      }
-
-      function render() {
-        const legendWidth = legendElem.width();
-        if (!ctrl.panel.legend.show) {
-          elem.empty();
-          firstRender = true;
-          return;
-        }
-
-        if (firstRender) {
-          elem.on('click', '.graph-legend-icon', openColorSelector);
-          elem.on('click', '.graph-legend-alias', toggleSeries);
-          elem.on('click', 'th', sortLegend);
-          firstRender = false;
-        }
-
-        seriesList = data;
-
-        elem.empty();
-
-        // Set min-width if side style and there is a value, otherwise remove the CSS property
-        // Set width so it works with IE11
-        const width: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth + 'px' : '';
-        const ieWidth: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth - 1 + 'px' : '';
-        legendElem.css('min-width', width);
-        legendElem.css('width', ieWidth);
-
-        elem.toggleClass('graph-legend-table', panel.legend.alignAsTable === true);
-
-        let tableHeaderElem;
-        if (panel.legend.alignAsTable) {
-          let header = '<tr>';
-          header += '<th colspan="2" style="text-align:left"></th>';
-          if (panel.legend.values) {
-            header += getTableHeaderHtml('min');
-            header += getTableHeaderHtml('max');
-            header += getTableHeaderHtml('avg');
-            header += getTableHeaderHtml('current');
-            header += getTableHeaderHtml('total');
-          }
-          header += '</tr>';
-          tableHeaderElem = $(header);
-        }
-
-        if (panel.legend.sort) {
-          seriesList = _.sortBy(seriesList, series => {
-            let sort = series.stats[panel.legend.sort];
-            if (sort === null) {
-              sort = -Infinity;
-            }
-            return sort;
-          });
-          if (panel.legend.sortDesc) {
-            seriesList = seriesList.reverse();
-          }
-        }
-
-        // render first time for getting proper legend height
-        if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) {
-          renderLegendElement(tableHeaderElem);
-          elem.empty();
-        }
-
-        renderLegendElement(tableHeaderElem);
-      }
-
-      function renderSeriesLegendElements() {
-        const seriesElements = [];
-        for (i = 0; i < seriesList.length; i++) {
-          const series = seriesList[i];
-
-          if (series.hideFromLegend(panel.legend)) {
-            continue;
-          }
-
-          let html = '<div class="graph-legend-series';
-
-          if (series.yaxis === 2) {
-            html += ' graph-legend-series--right-y';
-          }
-          if (ctrl.hiddenSeries[series.alias]) {
-            html += ' graph-legend-series-hidden';
-          }
-          html += '" data-series-index="' + i + '">';
-          html += '<div class="graph-legend-icon">';
-          html += '<i class="fa fa-minus pointer" style="color:' + series.color + '"></i>';
-          html += '</div>';
-
-          html +=
-            '<a class="graph-legend-alias pointer" title="' + series.aliasEscaped + '">' + series.aliasEscaped + '</a>';
-
-          if (panel.legend.values) {
-            const avg = series.formatValue(series.stats.avg);
-            const current = series.formatValue(series.stats.current);
-            const min = series.formatValue(series.stats.min);
-            const max = series.formatValue(series.stats.max);
-            const total = series.formatValue(series.stats.total);
-
-            if (panel.legend.min) {
-              html += '<div class="graph-legend-value min">' + min + '</div>';
-            }
-            if (panel.legend.max) {
-              html += '<div class="graph-legend-value max">' + max + '</div>';
-            }
-            if (panel.legend.avg) {
-              html += '<div class="graph-legend-value avg">' + avg + '</div>';
-            }
-            if (panel.legend.current) {
-              html += '<div class="graph-legend-value current">' + current + '</div>';
-            }
-            if (panel.legend.total) {
-              html += '<div class="graph-legend-value total">' + total + '</div>';
-            }
-          }
-
-          html += '</div>';
-          seriesElements.push($(html));
-        }
-        return seriesElements;
-      }
-
-      function renderLegendElement(tableHeaderElem) {
-        const legendWidth = elem.width();
-
-        const seriesElements = renderSeriesLegendElements();
-
-        if (panel.legend.alignAsTable) {
-          const tbodyElem = $('<tbody></tbody>');
-          tbodyElem.append(tableHeaderElem);
-          tbodyElem.append(seriesElements);
-          elem.append(tbodyElem);
-          tbodyElem.wrap('<div class="graph-legend-scroll"></div>');
-        } else {
-          elem.append('<div class="graph-legend-scroll"></div>');
-          elem.find('.graph-legend-scroll').append(seriesElements);
-        }
-
-        if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) {
-          addScrollbar();
-        } else {
-          destroyScrollbar();
-        }
-      }
-
-      function addScrollbar() {
-        const scrollRootClass = 'baron baron__root';
-        const scrollerClass = 'baron__scroller';
-        const scrollBarHTML = `
-          <div class="baron__track">
-            <div class="baron__bar"></div>
-          </div>
-        `;
-
-        const scrollRoot = elem;
-        const scroller = elem.find('.graph-legend-scroll');
-
-        // clear existing scroll bar track to prevent duplication
-        scrollRoot.find('.baron__track').remove();
-
-        scrollRoot.addClass(scrollRootClass);
-        $(scrollBarHTML).appendTo(scrollRoot);
-        scroller.addClass(scrollerClass);
-
-        const scrollbarParams = {
-          root: scrollRoot[0],
-          scroller: scroller[0],
-          bar: '.baron__bar',
-          track: '.baron__track',
-          barOnCls: '_scrollbar',
-          scrollingCls: '_scrolling',
-        };
-
-        if (!legendScrollbar) {
-          legendScrollbar = baron(scrollbarParams);
-        } else {
-          destroyScrollbar();
-          legendScrollbar = baron(scrollbarParams);
-        }
-
-        // #11830 - compensates for Firefox scrollbar calculation error in the baron framework
-        scroller[0].style.marginRight = '-' + (scroller[0].offsetWidth - scroller[0].clientWidth) + 'px';
-
-        legendScrollbar.scroll();
-      }
-
-      function destroyScrollbar() {
-        if (legendScrollbar) {
-          legendScrollbar.dispose();
-          legendScrollbar = undefined;
-        }
-      }
-    },
-  };
-}
-
-coreModule.directive('graphLegend', graphLegendDirective);

+ 13 - 49
public/app/plugins/panel/graph/module.ts

@@ -1,5 +1,4 @@
 import './graph';
-import './legend';
 import './series_overrides_ctrl';
 import './thresholds_form';
 
@@ -244,67 +243,32 @@ class GraphCtrl extends MetricsPanelCtrl {
     }
   }
 
-  changeSeriesColor(series, color) {
+  onColorChange = (series, color) => {
     series.setColor(color);
     this.panel.aliasColors[series.alias] = series.color;
     this.render();
-  }
+  };
 
-  toggleSeries(serie, event) {
-    if (event.ctrlKey || event.metaKey || event.shiftKey) {
-      if (this.hiddenSeries[serie.alias]) {
-        delete this.hiddenSeries[serie.alias];
-      } else {
-        this.hiddenSeries[serie.alias] = true;
-      }
-    } else {
-      this.toggleSeriesExclusiveMode(serie);
-    }
+  onToggleSeries = hiddenSeries => {
+    this.hiddenSeries = hiddenSeries;
     this.render();
-  }
-
-  toggleSeriesExclusiveMode(serie) {
-    const hidden = this.hiddenSeries;
-
-    if (hidden[serie.alias]) {
-      delete hidden[serie.alias];
-    }
-
-    // check if every other series is hidden
-    const alreadyExclusive = _.every(this.seriesList, value => {
-      if (value.alias === serie.alias) {
-        return true;
-      }
-
-      return hidden[value.alias];
-    });
-
-    if (alreadyExclusive) {
-      // remove all hidden series
-      _.each(this.seriesList, value => {
-        delete this.hiddenSeries[value.alias];
-      });
-    } else {
-      // hide all but this serie
-      _.each(this.seriesList, value => {
-        if (value.alias === serie.alias) {
-          return;
-        }
+  };
 
-        this.hiddenSeries[value.alias] = true;
-      });
-    }
-  }
+  onToggleSort = (sortBy, sortDesc) => {
+    this.panel.legend.sort = sortBy;
+    this.panel.legend.sortDesc = sortDesc;
+    this.render();
+  };
 
-  toggleAxis(info) {
+  onToggleAxis = info => {
     let override = _.find(this.panel.seriesOverrides, { alias: info.alias });
     if (!override) {
       override = { alias: info.alias };
       this.panel.seriesOverrides.push(override);
     }
-    info.yaxis = override.yaxis = info.yaxis === 2 ? 1 : 2;
+    override.yaxis = info.yaxis;
     this.render();
-  }
+  };
 
   addSeriesOverride(override) {
     this.panel.seriesOverrides.push(override || {});

+ 1 - 1
public/app/plugins/panel/graph/series_overrides_ctrl.ts

@@ -53,7 +53,7 @@ export function SeriesOverridesCtrl($scope, $element, popoverSrv) {
       element: $element.find('.dropdown')[0],
       position: 'top center',
       openOn: 'click',
-      template: '<series-color-picker series="series" onColorChange="colorSelected" />',
+      template: '<series-color-picker-popover series="series" onColorChange="colorSelected" />',
       model: {
         autoClose: true,
         colorSelected: $scope.colorSelected,

+ 1 - 0
public/app/plugins/panel/table/column_options.ts

@@ -41,6 +41,7 @@ export class ColumnOptionsCtrl {
       { text: 'YYYY-MM-DD HH:mm:ss.SSS', value: 'YYYY-MM-DD HH:mm:ss.SSS' },
       { text: 'MM/DD/YY h:mm:ss a', value: 'MM/DD/YY h:mm:ss a' },
       { text: 'MMMM D, YYYY LT', value: 'MMMM D, YYYY LT' },
+      { text: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
     ];
     this.mappingTypes = [{ text: 'Value to text', value: 1 }, { text: 'Range to text', value: 2 }];
 

+ 5 - 1
public/app/types/datasources.ts

@@ -1,5 +1,5 @@
 import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector';
-import { Plugin } from './plugins';
+import { Plugin, PluginExports, PluginMeta } from './plugins';
 
 export interface DataSource {
   id: number;
@@ -16,6 +16,10 @@ export interface DataSource {
   isDefault: boolean;
   jsonData: { authType: string; defaultRegion: string };
   readOnly: boolean;
+  meta?: PluginMeta;
+  pluginExports?: PluginExports;
+  init?: () => void;
+  testDatasource?: () => Promise<any>;
 }
 
 export interface DataSourcesState {

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

@@ -146,6 +146,7 @@ export interface TextMatch {
 }
 
 export interface ExploreState {
+  StartPage?: any;
   datasource: any;
   datasourceError: any;
   datasourceLoading: boolean | null;

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

@@ -6,6 +6,8 @@ export interface PluginExports {
   ConfigCtrl?: any;
   AnnotationsQueryCtrl?: any;
   PanelOptions?: any;
+  ExploreQueryField?: any;
+  ExploreStartPage?: any;
 }
 
 export interface PanelPlugin {
@@ -25,6 +27,12 @@ export interface PluginMeta {
   name: string;
   info: PluginMetaInfo;
   includes: PluginInclude[];
+
+  // Datasource-specific
+  metrics?: boolean;
+  logs?: boolean;
+  explore?: boolean;
+  annotations?: boolean;
 }
 
 export interface PluginInclude {

+ 12 - 28
public/sass/components/_panel_graph.scss

@@ -14,7 +14,7 @@
 
       .graph-legend-series {
         display: block;
-        padding-left: 0px;
+        padding-left: 4px;
       }
 
       .graph-legend-table .graph-legend-series {
@@ -52,9 +52,6 @@
   padding-top: 6px;
   position: relative;
 
-  // fix for Firefox (white stripe on the right of scrollbar)
-  width: calc(100% - 1px);
-
   .popover-content {
     padding: 0;
   }
@@ -62,15 +59,6 @@
 
 .graph-legend-content {
   position: relative;
-
-  // fix for Firefox (white stripe on the right of scrollbar)
-  width: calc(100% - 1px);
-}
-
-.graph-legend-scroll {
-  position: relative;
-  overflow: auto !important;
-  padding: 1px;
 }
 
 .graph-legend-icon {
@@ -82,8 +70,8 @@
 .graph-legend-icon,
 .graph-legend-alias,
 .graph-legend-value {
+  display: inline;
   cursor: pointer;
-  float: left;
   white-space: nowrap;
   font-size: 85%;
   text-align: left;
@@ -120,6 +108,11 @@
   }
 }
 
+// Don't move series to the right if legend is on the right as well
+.graph-panel--legend-right .graph-legend-series--right-y {
+  float: left;
+}
+
 .graph-legend-value {
   padding-left: 6px;
 }
@@ -128,7 +121,8 @@
 .body--phantomjs {
   .graph-panel--legend-right {
     .graph-legend {
-      display: inline-block;
+      display: block;
+      max-width: min-content;
     }
 
     .graph-panel__chart {
@@ -138,24 +132,14 @@
     .graph-legend-table {
       display: table;
       width: auto;
-
-      .graph-legend-scroll {
-        display: table;
-      }
     }
   }
 }
 
 .graph-legend-table {
-  tbody {
-    display: block;
-    position: relative;
-    overflow-y: auto;
-    overflow-x: hidden;
-    padding-bottom: 1px;
-    padding-right: 5px;
-    padding-left: 5px;
-  }
+  padding-bottom: 1px;
+  padding-right: 5px;
+  padding-left: 5px;
 
   .graph-legend-series {
     display: table-row;

+ 26 - 4
public/sass/pages/_explore.scss

@@ -52,7 +52,7 @@
   }
 
   .result-options {
-    margin-top: 2 * $panel-margin;
+    margin: 2 * $panel-margin 0;
   }
 
   .time-series-disclaimer {
@@ -87,7 +87,7 @@
     flex-wrap: wrap;
   }
 
-  .explore-graph__loader {
+  .explore-panel__loader {
     height: 2px;
     position: relative;
     overflow: hidden;
@@ -95,7 +95,7 @@
     margin: $panel-margin / 2;
   }
 
-  .explore-graph__loader:after {
+  .explore-panel__loader:after {
     content: ' ';
     display: block;
     width: 25%;
@@ -219,7 +219,13 @@
     }
 
     .logs-row-match-highlight {
-      background-color: lighten($blue, 20%);
+      // Undoing mark styling
+      background: inherit;
+      padding: inherit;
+
+      color: $typeahead-selected-color;
+      border-bottom: 1px solid $typeahead-selected-color;
+      background-color: lighten($typeahead-selected-color, 60%);
     }
 
     .logs-row-level {
@@ -322,3 +328,19 @@
 .ReactTable .rt-tr .rt-td:last-child {
   text-align: right;
 }
+
+// TODO Experimental
+
+.cheat-sheet-item {
+  margin: 2*$panel-margin 0;
+  width: 50%;
+}
+
+.cheat-sheet-item__title {
+  font-size: $font-size-h3;
+}
+
+.cheat-sheet-item__expression {
+  margin: $panel-margin/2 0;
+  cursor: pointer;
+}

+ 5 - 0
scripts/build/ci-deploy/Dockerfile

@@ -0,0 +1,5 @@
+FROM circleci/python:2.7-stretch
+
+RUN sudo pip install awscli && \
+  curl https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-222.0.0-linux-x86_64.tar.gz | \
+  sudo tar xvzf - -C /opt

+ 7 - 0
scripts/build/ci-deploy/build-deploy.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+
+_version="1.0.0"
+_tag="grafana/grafana-ci-deploy:${_version}"
+
+docker build -t $_tag .
+docker push $_tag

+ 2 - 2
scripts/grunt/default_task.js

@@ -17,8 +17,8 @@ module.exports = function (grunt) {
 
   grunt.registerTask('precommit', [
     'sasslint',
-    'exec:tslint',
-    'exec:tsc',
+    'newer:exec:tslint',
+    'newer:exec:tsc',
     'no-only-tests'
   ]);
 

+ 8 - 2
scripts/grunt/options/exec.js

@@ -2,8 +2,14 @@ module.exports = function (config, grunt) {
   'use strict';
 
   return {
-    tslint: 'node ./node_modules/tslint/lib/tslintCli.js -c tslint.json --project ./tsconfig.json',
-    tsc: 'yarn tsc --noEmit',
+    tslint: {
+      command: 'node ./node_modules/tslint/lib/tslintCli.js -c tslint.json --project ./tsconfig.json',
+      src: ['public/app/**/*.ts*'],
+    },
+    tsc: {
+      command: 'yarn tsc --noEmit',
+      src: ['public/app/**/*.ts*'],
+    },
     jest: 'node ./node_modules/jest-cli/bin/jest.js --maxWorkers 2',
     webpack: 'node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js',
   };

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

@@ -16,7 +16,7 @@ module.exports = {
     publicPath: "public/build/",
   },
   resolve: {
-    extensions: ['.ts', '.tsx', '.es6', '.js', '.json'],
+    extensions: ['.ts', '.tsx', '.es6', '.js', '.json', '.svg'],
     alias: {
     },
     modules: [

+ 8 - 0
yarn.lock

@@ -5971,6 +5971,14 @@ grunt-legacy-util@~1.0.0:
     underscore.string "~3.2.3"
     which "~1.2.1"
 
+grunt-newer@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/grunt-newer/-/grunt-newer-1.3.0.tgz#83ccb7a1dda7cbd8ab23b059024ebe614ad2f342"
+  integrity sha1-g8y3od2ny9irI7BZAk6+YUrS80I=
+  dependencies:
+    async "^1.5.2"
+    rimraf "^2.5.2"
+
 grunt-notify@^0.4.5:
   version "0.4.5"
   resolved "https://registry.yarnpkg.com/grunt-notify/-/grunt-notify-0.4.5.tgz#05293990616110db6bc0ad15e6c0592ffe18ac31"