Browse Source

Merge branch 'master' into azure-monitor-refactor-#15087

corpglory-dev 7 years ago
parent
commit
e58f3a678d
100 changed files with 2310 additions and 1325 deletions
  1. 4 0
      CHANGELOG.md
  2. 10 2
      Gopkg.lock
  3. 37 15
      README.md
  4. 3 0
      conf/defaults.ini
  5. 3 0
      conf/sample.ini
  6. 27 0
      devenv/docker/blocks/loki/config.yaml
  7. 2 10
      devenv/docker/blocks/loki/docker-compose.yaml
  8. 9 0
      docs/sources/features/datasources/cloudwatch.md
  9. 1 3
      docs/sources/installation/configuration.md
  10. 1 0
      packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
  11. 1 2
      packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
  12. 4 22
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  13. 23 11
      packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
  14. 41 2
      packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
  15. 1 1
      packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx
  16. 7 7
      packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
  17. 10 0
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx
  18. 1 1
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
  19. 3 11
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
  20. 29 47
      packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
  21. 1 1
      packages/grafana-ui/src/types/datasource.ts
  22. 1 1
      packages/grafana-ui/src/types/panel.ts
  23. 25 6
      packages/grafana-ui/src/types/plugin.ts
  24. 2 2
      pkg/api/api.go
  25. 1 1
      pkg/api/common_test.go
  26. 1 1
      pkg/api/login.go
  27. 1 1
      pkg/middleware/middleware_test.go
  28. 16 3
      pkg/services/auth/auth_token.go
  29. 39 0
      pkg/services/auth/auth_token_test.go
  30. 1 4
      pkg/services/sqlstore/sqlstore.go
  31. 16 0
      pkg/setting/setting.go
  32. 3 1
      pkg/tsdb/cloudwatch/cloudwatch.go
  33. 87 1
      pkg/tsdb/cloudwatch/metric_find_query.go
  34. 57 0
      pkg/tsdb/cloudwatch/metric_find_query_test.go
  35. 1 4
      pkg/tsdb/mssql/mssql.go
  36. 0 25
      pkg/util/ip.go
  37. 31 6
      pkg/util/ip_address.go
  38. 83 1
      pkg/util/ip_address_test.go
  39. 0 43
      pkg/util/ip_test.go
  40. 1 2
      public/app/core/components/Select/MetricSelect.tsx
  41. 2 3
      public/app/core/logs_model.ts
  42. 26 11
      public/app/core/services/backend_srv.ts
  43. 1 1
      public/app/core/services/keybindingSrv.ts
  44. 30 2
      public/app/core/utils/explore.test.ts
  45. 46 5
      public/app/core/utils/explore.ts
  46. 23 0
      public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx
  47. 61 39
      public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
  48. 42 5
      public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
  49. 86 0
      public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap
  50. 1 0
      public/app/features/dashboard/components/SaveModals/index.ts
  51. 3 3
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  52. 2 2
      public/app/features/dashboard/panel_editor/PanelEditor.tsx
  53. 7 1
      public/app/features/dashboard/panel_editor/QueriesTab.tsx
  54. 6 10
      public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
  55. 20 1
      public/app/features/dashboard/panel_editor/VisualizationTab.tsx
  56. 13 0
      public/app/features/dashboard/state/PanelModel.ts
  57. 2 2
      public/app/features/datasources/DataSourcesListItem.tsx
  58. 1 0
      public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap
  59. 20 25
      public/app/features/explore/Explore.tsx
  60. 8 1
      public/app/features/explore/ExploreToolbar.tsx
  61. 4 1
      public/app/features/explore/Logs.tsx
  62. 4 7
      public/app/features/explore/QueryEditor.tsx
  63. 81 54
      public/app/features/explore/QueryField.tsx
  64. 28 29
      public/app/features/explore/QueryRow.tsx
  65. 5 7
      public/app/features/explore/QueryRows.tsx
  66. 7 7
      public/app/features/explore/Wrapper.tsx
  67. 344 238
      public/app/features/explore/state/actionTypes.ts
  68. 162 252
      public/app/features/explore/state/actions.ts
  69. 35 30
      public/app/features/explore/state/reducers.test.ts
  70. 212 179
      public/app/features/explore/state/reducers.ts
  71. 17 0
      public/app/plugins/datasource/cloudwatch/datasource.ts
  72. 23 0
      public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
  73. 86 0
      public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx
  74. 21 25
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  75. 2 5
      public/app/plugins/datasource/loki/components/LokiStartPage.tsx
  76. 41 2
      public/app/plugins/datasource/loki/datasource.test.ts
  77. 55 43
      public/app/plugins/datasource/loki/datasource.ts
  78. 2 0
      public/app/plugins/datasource/loki/module.ts
  79. 3 1
      public/app/plugins/datasource/loki/plugin.json
  80. 3 0
      public/app/plugins/datasource/loki/types.ts
  81. 22 26
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  82. 2 5
      public/app/plugins/datasource/prometheus/components/PromStart.tsx
  83. 4 4
      public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx
  84. 12 7
      public/app/plugins/datasource/stackdriver/datasource.ts
  85. BIN
      public/app/plugins/datasource/stackdriver/img/stackdriver_logo.png
  86. 0 0
      public/app/plugins/datasource/stackdriver/img/stackdriver_logo.svg
  87. 2 2
      public/app/plugins/datasource/stackdriver/plugin.json
  88. 2 2
      public/app/plugins/datasource/stackdriver/query_ctrl.ts
  89. 51 10
      public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
  90. 12 8
      public/app/plugins/datasource/stackdriver/types.ts
  91. 2 2
      public/app/plugins/datasource/testdata/QueryEditor.tsx
  92. 20 9
      public/app/plugins/panel/gauge/GaugePanel.tsx
  93. 1 1
      public/app/plugins/panel/graph/module.ts
  94. 1 1
      public/app/plugins/panel/graph/tab_legend.html
  95. 8 5
      public/app/plugins/panel/graph2/GraphPanel.tsx
  96. 22 0
      public/app/routes/GrafanaCtrl.ts
  97. 2 2
      public/app/store/configureStore.ts
  98. 26 11
      public/app/types/explore.ts
  99. 1 1
      public/img/icons_dark_theme/icon_advanced.svg
  100. 1 1
      public/img/icons_dark_theme/icon_advanced_active.svg

+ 4 - 0
CHANGELOG.md

@@ -2,6 +2,10 @@
 
 ### Minor
 * **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
+* **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182)
+* **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock)
+* **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson)
+* **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen)
 
 # 6.0.0-beta1 (2019-01-30)
 

+ 10 - 2
Gopkg.lock

@@ -37,6 +37,7 @@
     "aws/credentials",
     "aws/credentials/ec2rolecreds",
     "aws/credentials/endpointcreds",
+    "aws/credentials/processcreds",
     "aws/credentials/stscreds",
     "aws/csm",
     "aws/defaults",
@@ -45,13 +46,18 @@
     "aws/request",
     "aws/session",
     "aws/signer/v4",
+    "internal/ini",
+    "internal/s3err",
     "internal/sdkio",
     "internal/sdkrand",
+    "internal/sdkuri",
     "internal/shareddefaults",
     "private/protocol",
     "private/protocol/ec2query",
     "private/protocol/eventstream",
     "private/protocol/eventstream/eventstreamapi",
+    "private/protocol/json/jsonutil",
+    "private/protocol/jsonrpc",
     "private/protocol/query",
     "private/protocol/query/queryutil",
     "private/protocol/rest",
@@ -60,11 +66,13 @@
     "service/cloudwatch",
     "service/ec2",
     "service/ec2/ec2iface",
+    "service/resourcegroupstaggingapi",
+    "service/resourcegroupstaggingapi/resourcegroupstaggingapiiface",
     "service/s3",
     "service/sts"
   ]
-  revision = "fde4ded7becdeae4d26bf1212916aabba79349b4"
-  version = "v1.14.12"
+  revision = "62936e15518acb527a1a9cb4a39d96d94d0fd9a2"
+  version = "v1.16.15"
 
 [[projects]]
   branch = "master"

+ 37 - 15
README.md

@@ -25,49 +25,71 @@ the latest master builds [here](https://grafana.com/grafana/download)
 ### Dependencies
 
 - Go (Latest Stable)
+  - bra [`go get github.com/Unknwon/bra`]
 - Node.js LTS
+  - yarn [`npm install -g yarn`]
+
+### Get the project
+
+**The project located in the go-path will be your working directory.**
 
-### Building the backend
 ```bash
 go get github.com/grafana/grafana
 cd $GOPATH/src/github.com/grafana/grafana
+```
+
+### Building
+
+#### The backend
+
+```bash
 go run build.go setup
 go run build.go build
 ```
 
-### Building frontend assets
+#### Frontend assets
 
-For this you need Node.js (LTS version).
+*For this you need Node.js (LTS version).*
 
-To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
 ```bash
-npm install -g yarn
 yarn install --pure-lockfile
+```
+
+### Run and rebuild on source change
+
+#### Backend
+
+To run the backend and rebuild on source change:
+
+```bash
+$GOPATH/bin/bra run
+```
+
+#### Frontend
+
+Rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
+
+```bash
 yarn watch
 ```
 
 Build the assets, rebuild on file change with Hot Module Replacement (HMR), and serve them by webpack-dev-server (http://localhost:3333):
+
 ```bash
 yarn start
 # OR set a theme
 env GRAFANA_THEME=light yarn start
 ```
-Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload.
 
-Run tests
-```bash
-yarn jest
-```
+*Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload.*
 
-### Recompile backend on source change
+Run tests and rebuild on source change:
 
-To rebuild on source change.
 ```bash
-go get github.com/Unknwon/bra
-bra run
+yarn jest
 ```
 
-Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
+**Open grafana in your browser (default: e.g. `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).**
 
 ### Building a Docker image
 

+ 3 - 0
conf/defaults.ini

@@ -113,6 +113,9 @@ cache_mode = private
 # Login cookie name
 cookie_name = grafana_session
 
+# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
+cookie_samesite = lax
+
 # How many days an session can be unused before we inactivate it
 login_remember_days = 7
 

+ 3 - 0
conf/sample.ini

@@ -109,6 +109,9 @@ log_queries =
 # Login cookie name
 ;cookie_name = grafana_session
 
+# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
+;cookie_samesite = lax
+
 # How many days an session can be unused before we inactivate it
 ;login_remember_days = 7
 

+ 27 - 0
devenv/docker/blocks/loki/config.yaml

@@ -0,0 +1,27 @@
+server:
+  http_listen_port: 9080
+  grpc_listen_port: 0
+
+positions:
+  filename: /tmp/positions.yaml
+
+client:
+  url: http://loki:3100/api/prom/push
+
+scrape_configs:
+- job_name: system
+  entry_parser: raw
+  static_configs:
+  - targets:
+      - localhost
+    labels:
+      job: varlogs
+      __path__: /var/log/*log
+- job_name: grafana
+  entry_parser: raw
+  static_configs:
+  - targets:
+      - localhost
+    labels:
+      job: grafana
+      __path__: /var/log/grafana/*log

+ 2 - 10
devenv/docker/blocks/loki/docker-compose.yaml

@@ -1,22 +1,14 @@
-version: "3"
-
-networks:
-  loki:
-
-services:
   loki:
     image: grafana/loki:master
     ports:
       - "3100:3100"
     command: -config.file=/etc/loki/local-config.yaml
-    networks:
-      - loki
 
   promtail:
     image: grafana/promtail:master
     volumes:
+      - ./docker/blocks/loki/config.yaml:/etc/promtail/docker-config.yaml
       - /var/log:/var/log
+      - ../data/log:/var/log/grafana
     command:
       -config.file=/etc/promtail/docker-config.yaml
-    networks:
-      - loki

+ 9 - 0
docs/sources/features/datasources/cloudwatch.md

@@ -74,6 +74,12 @@ Here is a minimal policy example:
                 "ec2:DescribeRegions"
             ],
             "Resource": "*"
+        },
+        {
+            "Sid": "AllowReadingResourcesForTags",
+            "Effect" : "Allow",      
+            "Action" : "tag:GetResources",      
+            "Resource" : "*"      
         }
     ]
 }
@@ -128,6 +134,7 @@ Name | Description
 *dimension_values(region, namespace, metric, dimension_key, [filters])* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well.
 *ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`.
 *ec2_instance_attribute(region, attribute_name, filters)* | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`.
+*resource_arns(region, resource_type, tags)* | Returns a list of ARNs matching the specified `region`, `resource_type` and `tags`.
 
 For details about the metrics CloudWatch provides, please refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/CW_Support_For_AWS.html).
 
@@ -143,6 +150,8 @@ Query | Service
 *dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS
 *dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3
 *dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})* | CloudWatch Agent
+*resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})* | ELB
+*resource_arns(eu-west-1,ec2:instance,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})* | EC2
 
 ## ec2_instance_attribute examples
 

+ 1 - 3
docs/sources/installation/configuration.md

@@ -393,9 +393,7 @@ Analytics ID here. By default this feature is disabled.
 
 ### check_for_updates
 
-Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
-in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
-send any sensitive information.
+Set to false to disable all checks to https://grafana.com for new versions of installed plugins and to the Grafana GitHub repository to check for a newer version of Grafana. The version information is used in some UI views to notify that a new Grafana update or a plugin update exists. This option does not cause any auto updates, nor send any sensitive information. The check is run every 10 minutes.
 
 <hr />
 

+ 1 - 0
packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss

@@ -167,6 +167,7 @@ $arrowSize: 15px;
   color: inherit;
   padding: 0;
   border-radius: 10px;
+  cursor: pointer;
 }
 
 .sp-replacer:hover,

+ 1 - 2
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 import { shallow } from 'enzyme';
 
 import { Gauge, Props } from './Gauge';
-import { TimeSeriesVMs } from '../../types/data';
 import { ValueMapping, MappingType } from '../../types';
 
 jest.mock('jquery', () => ({
@@ -23,7 +22,7 @@ const setup = (propOverrides?: object) => {
     stat: 'avg',
     height: 300,
     width: 300,
-    timeSeries: {} as TimeSeriesVMs,
+    value: 25,
     decimals: 0,
   };
 

+ 4 - 22
packages/grafana-ui/src/components/Gauge/Gauge.tsx

@@ -1,7 +1,7 @@
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
 
-import { ValueMapping, Threshold, BasicGaugeColor, TimeSeriesVMs, GrafanaTheme } from '../../types';
+import { ValueMapping, Threshold, BasicGaugeColor, GrafanaTheme } from '../../types';
 import { getMappedValue } from '../../utils/valueMappings';
 import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
 
@@ -14,7 +14,6 @@ export interface Props {
   maxValue: number;
   minValue: number;
   prefix: string;
-  timeSeries: TimeSeriesVMs;
   thresholds: Threshold[];
   showThresholdMarkers: boolean;
   showThresholdLabels: boolean;
@@ -22,6 +21,7 @@ export interface Props {
   suffix: string;
   unit: string;
   width: number;
+  value: number;
   theme?: GrafanaTheme;
 }
 
@@ -122,25 +122,7 @@ export class Gauge extends PureComponent<Props> {
   }
 
   draw() {
-    const {
-      maxValue,
-      minValue,
-      timeSeries,
-      showThresholdLabels,
-      showThresholdMarkers,
-      width,
-      height,
-      stat,
-      theme,
-    } = this.props;
-
-    let value: TimeSeriesValue = '';
-
-    if (timeSeries[0]) {
-      value = timeSeries[0].stats[stat];
-    } else {
-      value = null;
-    }
+    const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props;
 
     const formattedValue = this.formatValue(value) as string;
     const dimension = Math.min(width, height * 1.3);
@@ -194,7 +176,7 @@ export class Gauge extends PureComponent<Props> {
     try {
       $.plot(this.canvasElement, [plotSeries], options);
     } catch (err) {
-      console.log('Gauge rendering error', err, options, timeSeries);
+      console.log('Gauge rendering error', err, options, value);
     }
   }
 

+ 23 - 11
packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx

@@ -1,26 +1,38 @@
 // Libraries
-import React, { SFC } from 'react';
+import React, { FunctionComponent } from 'react';
 
 interface Props {
   title?: string;
   onClose?: () => void;
-  children: JSX.Element | JSX.Element[];
+  children: JSX.Element | JSX.Element[] | boolean;
+  onAdd?: () => void;
 }
 
-export const PanelOptionsGroup: SFC<Props> = props => {
+export const PanelOptionsGroup: FunctionComponent<Props> = props => {
   return (
     <div className="panel-options-group">
-      {props.title && (
+      {props.onAdd ? (
         <div className="panel-options-group__header">
-          {props.title}
-          {props.onClose && (
-            <button className="btn btn-link" onClick={props.onClose}>
-              <i className="fa fa-remove" />
-            </button>
-          )}
+          <button className="panel-options-group__add-btn" onClick={props.onAdd}>
+            <div className="panel-options-group__add-circle">
+              <i className="fa fa-plus" />
+            </div>
+            <span className="panel-options-group__title">{props.title}</span>
+          </button>
         </div>
+      ) : (
+        props.title && (
+          <div className="panel-options-group__header">
+            <span className="panel-options-group__title">{props.title}</span>
+            {props.onClose && (
+              <button className="btn btn-link" onClick={props.onClose}>
+                <i className="fa fa-remove" />
+              </button>
+            )}
+          </div>
+        )
       )}
-      <div className="panel-options-group__body">{props.children}</div>
+      {props.children && <div className="panel-options-group__body">{props.children}</div>}
     </div>
   );
 };

+ 41 - 2
packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss

@@ -7,18 +7,57 @@
 
 .panel-options-group__header {
   padding: 4px 8px;
-  font-size: 1.1rem;
   background: $panel-options-group-header-bg;
   position: relative;
   border-radius: $border-radius $border-radius 0 0;
+  display: flex;
+  align-items: center;
 
   .btn {
     position: absolute;
     right: 0;
-    top: 0px;
+    top: 0;
+  }
+}
+
+.panel-options-group__add-btn {
+  background: none;
+  border: none;
+  display: flex;
+  align-items: center;
+  padding: 0;
+
+  &:hover {
+    .panel-options-group__add-circle {
+      background-color: $btn-success-bg;
+      color: $text-color-strong;
+    }
+  }
+}
+
+.panel-options-group__add-circle {
+  @include gradientBar($btn-success-bg, $btn-success-bg-hl, $text-color);
+
+  border-radius: 50px;
+  width: 20px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 6px;
+
+  i {
+    position: relative;
+    top: 1px;
   }
 }
 
+.panel-options-group__title {
+  font-size: 1.1rem;
+  position: relative;
+  top: 1px;
+}
+
 .panel-options-group__body {
   padding: 20px;
 

+ 1 - 1
packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx

@@ -49,7 +49,7 @@ export default class SelectOptionGroup extends PureComponent<ExtendedGroupProps,
     return (
       <div className="gf-form-select-box__option-group">
         <div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}>
-          <span className="flex-grow">{label}</span>
+          <span className="flex-grow-1">{label}</span>
           <i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '}
         </div>
         {expanded && children}

+ 7 - 7
packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss

@@ -1,11 +1,11 @@
 .thresholds {
-  margin-bottom: 10px;
+  margin-bottom: 20px;
 }
 
 .thresholds-row {
   display: flex;
   flex-direction: row;
-  height: 70px;
+  height: 62px;
 }
 
 .thresholds-row:first-child > .thresholds-row-color-indicator {
@@ -21,21 +21,21 @@
 }
 
 .thresholds-row-add-button {
+  @include buttonBackground($btn-success-bg, $btn-success-bg-hl, $text-color);
+
   align-self: center;
   margin-right: 5px;
-  color: $green;
   height: 24px;
   width: 24px;
-  background-color: $green;
   border-radius: 50%;
   display: flex;
   align-items: center;
   justify-content: center;
   cursor: pointer;
-}
 
-.thresholds-row-add-button > i {
-  color: $white;
+  &:hover {
+    color: $text-color-strong;
+  }
 }
 
 .thresholds-row-color-indicator {

+ 10 - 0
packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx

@@ -0,0 +1,10 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import { ValueMappingsEditor } from './ValueMappingsEditor';
+
+const ValueMappingsEditorStories = storiesOf('UI/ValueMappingsEditor', module);
+
+ValueMappingsEditorStories.add('default', () => {
+  return <ValueMappingsEditor valueMappings={[]} onChange={action('Mapping changed')} />;
+});

+ 1 - 1
packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 import { shallow } from 'enzyme';
 
 import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
-import { MappingType } from '../../types/panel';
+import { MappingType } from '../../types';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {

+ 3 - 11
packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx

@@ -1,8 +1,8 @@
 import React, { PureComponent } from 'react';
 
 import MappingRow from './MappingRow';
-import { MappingType, ValueMapping } from '../../types/panel';
-import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+import { MappingType, ValueMapping } from '../../types';
+import { PanelOptionsGroup } from '..';
 
 export interface Props {
   valueMappings: ValueMapping[];
@@ -81,8 +81,7 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
     const { valueMappings } = this.state;
 
     return (
-      <PanelOptionsGroup title="Value Mappings">
-        <div>
+      <PanelOptionsGroup title="Add value mapping" onAdd={this.addMapping}>
           {valueMappings.length > 0 &&
             valueMappings.map((valueMapping, index) => (
               <MappingRow
@@ -92,13 +91,6 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
                 removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
               />
             ))}
-        </div>
-        <div className="add-mapping-row" onClick={this.addMapping}>
-          <div className="add-mapping-row-icon">
-            <i className="fa fa-plus" />
-          </div>
-          <div className="add-mapping-row-label">Add mapping</div>
-        </div>
       </PanelOptionsGroup>
     );
   }

+ 29 - 47
packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap

@@ -2,55 +2,37 @@
 
 exports[`Render should render component 1`] = `
 <Component
-  title="Value Mappings"
+  onAdd={[Function]}
+  title="Add value mapping"
 >
-  <div>
-    <MappingRow
-      key="Ok-0"
-      removeValueMapping={[Function]}
-      updateValueMapping={[Function]}
-      valueMapping={
-        Object {
-          "id": 1,
-          "operator": "",
-          "text": "Ok",
-          "type": 1,
-          "value": "20",
-        }
+  <MappingRow
+    key="Ok-0"
+    removeValueMapping={[Function]}
+    updateValueMapping={[Function]}
+    valueMapping={
+      Object {
+        "id": 1,
+        "operator": "",
+        "text": "Ok",
+        "type": 1,
+        "value": "20",
       }
-    />
-    <MappingRow
-      key="Meh-1"
-      removeValueMapping={[Function]}
-      updateValueMapping={[Function]}
-      valueMapping={
-        Object {
-          "from": "21",
-          "id": 2,
-          "operator": "",
-          "text": "Meh",
-          "to": "30",
-          "type": 2,
-        }
+    }
+  />
+  <MappingRow
+    key="Meh-1"
+    removeValueMapping={[Function]}
+    updateValueMapping={[Function]}
+    valueMapping={
+      Object {
+        "from": "21",
+        "id": 2,
+        "operator": "",
+        "text": "Meh",
+        "to": "30",
+        "type": 2,
       }
-    />
-  </div>
-  <div
-    className="add-mapping-row"
-    onClick={[Function]}
-  >
-    <div
-      className="add-mapping-row-icon"
-    >
-      <i
-        className="fa fa-plus"
-      />
-    </div>
-    <div
-      className="add-mapping-row-label"
-    >
-      Add mapping
-    </div>
-  </div>
+    }
+  />
 </Component>
 `;

+ 1 - 1
packages/grafana-ui/src/types/datasource.ts

@@ -3,7 +3,7 @@ import { PluginMeta } from './plugin';
 import { TableData, TimeSeries } from './data';
 
 export interface DataQueryResponse {
-  data: TimeSeries[] | [TableData];
+  data: TimeSeries[] | [TableData] | any;
 }
 
 export interface DataQuery {

+ 1 - 1
packages/grafana-ui/src/types/panel.ts

@@ -4,7 +4,7 @@ import { TimeRange } from './time';
 export type InterpolateFunction = (value: string, format?: string | Function) => string;
 
 export interface PanelProps<T = any> {
-  timeSeries: TimeSeries[];
+  panelData: PanelData;
   timeRange: TimeRange;
   loading: LoadingState;
   options: T;

+ 25 - 6
packages/grafana-ui/src/types/plugin.ts

@@ -1,6 +1,6 @@
 import { ComponentClass } from 'react';
 import { PanelProps, PanelOptionsProps } from './panel';
-import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource';
+import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint, QueryFixAction } from './datasource';
 
 export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
   /**
@@ -41,22 +41,43 @@ export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
   pluginExports?: PluginExports;
 }
 
+export interface ExploreDataSourceApi<TQuery extends DataQuery = DataQuery> extends DataSourceApi {
+  modifyQuery?(query: TQuery, action: QueryFixAction): TQuery;
+  getHighlighterExpression?(query: TQuery): string;
+  languageProvider?: any;
+}
+
 export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
   datasource: DSType;
   query: TQuery;
+  onRunQuery: () => void;
+  onChange: (value: TQuery) => void;
+}
+
+export interface ExploreQueryFieldProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
+  datasource: DSType;
+  query: TQuery;
+  error?: string | JSX.Element;
+  hint?: QueryHint;
+  history: any[];
   onExecuteQuery?: () => void;
   onQueryChange?: (value: TQuery) => void;
+  onExecuteHint?: (action: QueryFixAction) => void;
+}
+
+export interface ExploreStartPageProps {
+  onClickExample: (query: DataQuery) => void;
 }
 
 export interface PluginExports {
   Datasource?: DataSourceApi;
   QueryCtrl?: any;
-  QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi,DataQuery>>;
+  QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi, DataQuery>>;
   ConfigCtrl?: any;
   AnnotationsQueryCtrl?: any;
   VariableQueryEditor?: any;
-  ExploreQueryField?: any;
-  ExploreStartPage?: any;
+  ExploreQueryField?: ComponentClass<ExploreQueryFieldProps<DataSourceApi, DataQuery>>;
+  ExploreStartPage?: ComponentClass<ExploreStartPageProps>;
 
   // Panel plugin
   PanelCtrl?: any;
@@ -114,5 +135,3 @@ export interface PluginMetaInfo {
   updated: string;
   version: string;
 }
-
-

+ 2 - 2
pkg/api/api.go

@@ -108,8 +108,8 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey))
 	r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
 
-	// api renew session based on remember cookie
-	r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
+	// api renew session based on cookie
+	r.Get("/api/login/ping", quota("session"), Wrap(hs.LoginAPIPing))
 
 	// authed api
 	r.Group("/api", func(apiRoute routing.RouteRegister) {

+ 1 - 1
pkg/api/common_test.go

@@ -149,4 +149,4 @@ func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqC
 	return nil
 }
 
-func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
+func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }

+ 1 - 1
pkg/api/login.go

@@ -136,7 +136,7 @@ func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
 }
 
 func (hs *HTTPServer) Logout(c *m.ReqContext) {
-	hs.AuthTokenService.UserSignedOutHook(c)
+	hs.AuthTokenService.SignOutUser(c)
 
 	if setting.SignoutRedirectUrl != "" {
 		c.Redirect(setting.SignoutRedirectUrl)

+ 1 - 1
pkg/middleware/middleware_test.go

@@ -602,4 +602,4 @@ func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqC
 	return nil
 }
 
-func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
+func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }

+ 16 - 3
pkg/services/auth/auth_token.go

@@ -3,6 +3,7 @@ package auth
 import (
 	"crypto/sha256"
 	"encoding/hex"
+	"errors"
 	"net/http"
 	"net/url"
 	"time"
@@ -31,7 +32,7 @@ var (
 type UserAuthTokenService interface {
 	InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
 	UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
-	UserSignedOutHook(c *models.ReqContext)
+	SignOutUser(c *models.ReqContext) error
 }
 
 type UserAuthTokenServiceImpl struct {
@@ -85,7 +86,7 @@ func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext,
 
 func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
 	if setting.Env == setting.DEV {
-		ctx.Logger.Info("new token", "unhashed token", value)
+		ctx.Logger.Debug("new token", "unhashed token", value)
 	}
 
 	ctx.Resp.Header().Del("Set-Cookie")
@@ -96,6 +97,7 @@ func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, va
 		Path:     setting.AppSubUrl + "/",
 		Secure:   s.Cfg.SecurityHTTPSCookies,
 		MaxAge:   maxAge,
+		SameSite: s.Cfg.LoginCookieSameSite,
 	}
 
 	http.SetCookie(ctx.Resp, &cookie)
@@ -111,8 +113,19 @@ func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *m
 	return nil
 }
 
-func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) {
+func (s *UserAuthTokenServiceImpl) SignOutUser(c *models.ReqContext) error {
+	unhashedToken := c.GetCookie(s.Cfg.LoginCookieName)
+	if unhashedToken == "" {
+		return errors.New("cannot logout without session token")
+	}
+
+	hashedToken := hashToken(unhashedToken)
+
+	sql := `DELETE FROM user_auth_token WHERE auth_token = ?`
+	_, err := s.SQLStore.NewSession().Exec(sql, hashedToken)
+
 	s.writeSessionCookie(c, "", -1)
+	return err
 }
 
 func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {

+ 39 - 0
pkg/services/auth/auth_token_test.go

@@ -1,10 +1,15 @@
 package auth
 
 import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 	"time"
 
+	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
+	macaron "gopkg.in/macaron.v1"
 
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -46,6 +51,40 @@ func TestUserAuthToken(t *testing.T) {
 				So(err, ShouldEqual, ErrAuthTokenNotFound)
 				So(LookupToken, ShouldBeNil)
 			})
+
+			Convey("signing out should delete token and cookie if present", func() {
+				httpreq := &http.Request{Header: make(http.Header)}
+				httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: token.UnhashedToken})
+
+				ctx := &models.ReqContext{Context: &macaron.Context{
+					Req:  macaron.Request{Request: httpreq},
+					Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
+				},
+					Logger: log.New("fakelogger"),
+				}
+
+				err = userAuthTokenService.SignOutUser(ctx)
+				So(err, ShouldBeNil)
+
+				// makes sure we tell the browser to overwrite the cookie
+				cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName)
+				So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader)
+			})
+
+			Convey("signing out an none existing session should return an error", func() {
+				httpreq := &http.Request{Header: make(http.Header)}
+				httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""})
+
+				ctx := &models.ReqContext{Context: &macaron.Context{
+					Req:  macaron.Request{Request: httpreq},
+					Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
+				},
+					Logger: log.New("fakelogger"),
+				}
+
+				err = userAuthTokenService.SignOutUser(ctx)
+				So(err, ShouldNotBeNil)
+			})
 		})
 
 		Convey("expires correctly", func() {

+ 1 - 4
pkg/services/sqlstore/sqlstore.go

@@ -242,10 +242,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
 
 		cnnstr += ss.buildExtraConnectionString('&')
 	case migrator.POSTGRES:
-		host, port, err := util.SplitIPPort(ss.dbCfg.Host, "5432")
-		if err != nil {
-			return "", err
-		}
+		host, port := util.SplitHostPortDefault(ss.dbCfg.Host, "127.0.0.1", "5432")
 		if ss.dbCfg.Pwd == "" {
 			ss.dbCfg.Pwd = "''"
 		}

+ 16 - 0
pkg/setting/setting.go

@@ -6,6 +6,7 @@ package setting
 import (
 	"bytes"
 	"fmt"
+	"net/http"
 	"net/url"
 	"os"
 	"path"
@@ -227,6 +228,7 @@ type Cfg struct {
 	LoginCookieMaxDays                int
 	LoginCookieRotation               int
 	LoginDeleteExpiredTokensAfterDays int
+	LoginCookieSameSite               http.SameSite
 
 	SecurityHTTPSCookies bool
 }
@@ -557,6 +559,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
 	cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
 	cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
+
+	samesiteString := login.Key("cookie_samesite").MustString("lax")
+	validSameSiteValues := map[string]http.SameSite{
+		"lax":    http.SameSiteLaxMode,
+		"strict": http.SameSiteStrictMode,
+		"none":   http.SameSiteDefaultMode,
+	}
+
+	if samesite, ok := validSameSiteValues[samesiteString]; ok {
+		cfg.LoginCookieSameSite = samesite
+	} else {
+		cfg.LoginCookieSameSite = http.SameSiteLaxMode
+	}
+
 	cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
 	if cfg.LoginCookieRotation < 2 {
 		cfg.LoginCookieRotation = 2

+ 3 - 1
pkg/tsdb/cloudwatch/cloudwatch.go

@@ -21,6 +21,7 @@ import (
 	"github.com/aws/aws-sdk-go/aws/request"
 	"github.com/aws/aws-sdk-go/service/cloudwatch"
 	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+	"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
 	"github.com/grafana/grafana/pkg/components/null"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/metrics"
@@ -28,7 +29,8 @@ import (
 
 type CloudWatchExecutor struct {
 	*models.DataSource
-	ec2Svc ec2iface.EC2API
+	ec2Svc  ec2iface.EC2API
+	rgtaSvc resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI
 }
 
 type DatasourceInfo struct {

+ 87 - 1
pkg/tsdb/cloudwatch/metric_find_query.go

@@ -15,6 +15,7 @@ import (
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/cloudwatch"
 	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/tsdb"
@@ -95,10 +96,11 @@ func init() {
 		"AWS/Logs":             {"IncomingBytes", "IncomingLogEvents", "ForwardedBytes", "ForwardedLogEvents", "DeliveryErrors", "DeliveryThrottling"},
 		"AWS/ML":               {"PredictCount", "PredictFailureCount"},
 		"AWS/NATGateway":       {"PacketsOutToDestination", "PacketsOutToSource", "PacketsInFromSource", "PacketsInFromDestination", "BytesOutToDestination", "BytesOutToSource", "BytesInFromSource", "BytesInFromDestination", "ErrorPortAllocation", "ActiveConnectionCount", "ConnectionAttemptCount", "ConnectionEstablishedCount", "IdleTimeoutCount", "PacketsDropCount"},
+		"AWS/Neptune":          {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeableMemory", "FreeLocalStorage", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinErrors", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketSuccess", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketAvailableConnections", "Http1xx", "Http2xx", "Http4xx", "Http5xx", "Http100", "Http101", "Http200", "Http400", "Http403", "Http405", "Http413", "Http429", "Http500", "Http501", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlErrors", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"},
 		"AWS/NetworkELB":       {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "UnHealthyHostCount"},
 		"AWS/OpsWorks":         {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
 		"AWS/Redshift":         {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMQueueLength", "WriteIOPS", "WriteLatency", "WriteThroughput"},
-		"AWS/RDS":              {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
+		"AWS/RDS":              {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
 		"AWS/Route53":          {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
 		"AWS/S3":               {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
 		"AWS/SES":              {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"},
@@ -149,6 +151,7 @@ func init() {
 		"AWS/Logs":             {"LogGroupName", "DestinationType", "FilterName"},
 		"AWS/ML":               {"MLModelId", "RequestMode"},
 		"AWS/NATGateway":       {"NatGatewayId"},
+		"AWS/Neptune":          {"DBClusterIdentifier", "Role", "DatabaseClass", "EngineName"},
 		"AWS/NetworkELB":       {"LoadBalancer", "TargetGroup", "AvailabilityZone"},
 		"AWS/OpsWorks":         {"StackId", "LayerId", "InstanceId"},
 		"AWS/Redshift":         {"NodeID", "ClusterIdentifier", "latency", "service class", "wmlid"},
@@ -198,6 +201,8 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo
 		data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext)
 	case "ec2_instance_attribute":
 		data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext)
+	case "resource_arns":
+		data, err = e.handleGetResourceArns(ctx, parameters, queryContext)
 	}
 
 	transformToTable(data, queryResult)
@@ -534,6 +539,65 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context,
 	return result, nil
 }
 
+func (e *CloudWatchExecutor) ensureRGTAClientSession(region string) error {
+	if e.rgtaSvc == nil {
+		dsInfo := e.getDsInfo(region)
+		cfg, err := e.getAwsConfig(dsInfo)
+		if err != nil {
+			return fmt.Errorf("Failed to call ec2:getAwsConfig, %v", err)
+		}
+		sess, err := session.NewSession(cfg)
+		if err != nil {
+			return fmt.Errorf("Failed to call ec2:NewSession, %v", err)
+		}
+		e.rgtaSvc = resourcegroupstaggingapi.New(sess, cfg)
+	}
+	return nil
+}
+
+func (e *CloudWatchExecutor) handleGetResourceArns(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
+	region := parameters.Get("region").MustString()
+	resourceType := parameters.Get("resourceType").MustString()
+	filterJson := parameters.Get("tags").MustMap()
+
+	err := e.ensureRGTAClientSession(region)
+	if err != nil {
+		return nil, err
+	}
+
+	var filters []*resourcegroupstaggingapi.TagFilter
+	for k, v := range filterJson {
+		if vv, ok := v.([]interface{}); ok {
+			var vvvvv []*string
+			for _, vvv := range vv {
+				if vvvv, ok := vvv.(string); ok {
+					vvvvv = append(vvvvv, &vvvv)
+				}
+			}
+			filters = append(filters, &resourcegroupstaggingapi.TagFilter{
+				Key:    aws.String(k),
+				Values: vvvvv,
+			})
+		}
+	}
+
+	var resourceTypes []*string
+	resourceTypes = append(resourceTypes, &resourceType)
+
+	resources, err := e.resourceGroupsGetResources(region, filters, resourceTypes)
+	if err != nil {
+		return nil, err
+	}
+
+	result := make([]suggestData, 0)
+	for _, resource := range resources.ResourceTagMappingList {
+		data := *resource.ResourceARN
+		result = append(result, suggestData{Text: data, Value: data})
+	}
+
+	return result, nil
+}
+
 func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string, dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) {
 	svc, err := e.getClient(region)
 	if err != nil {
@@ -585,6 +649,28 @@ func (e *CloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2.
 	return &resp, nil
 }
 
+func (e *CloudWatchExecutor) resourceGroupsGetResources(region string, filters []*resourcegroupstaggingapi.TagFilter, resourceTypes []*string) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+	params := &resourcegroupstaggingapi.GetResourcesInput{
+		ResourceTypeFilters: resourceTypes,
+		TagFilters:          filters,
+	}
+
+	var resp resourcegroupstaggingapi.GetResourcesOutput
+	err := e.rgtaSvc.GetResourcesPages(params,
+		func(page *resourcegroupstaggingapi.GetResourcesOutput, lastPage bool) bool {
+			resources, _ := awsutil.ValuesAtPath(page, "ResourceTagMappingList")
+			for _, resource := range resources {
+				resp.ResourceTagMappingList = append(resp.ResourceTagMappingList, resource.(*resourcegroupstaggingapi.ResourceTagMapping))
+			}
+			return !lastPage
+		})
+	if err != nil {
+		return nil, errors.New("Failed to call tags:GetResources")
+	}
+
+	return &resp, nil
+}
+
 func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) {
 	creds, err := GetCredentials(cwData)
 	if err != nil {

+ 57 - 0
pkg/tsdb/cloudwatch/metric_find_query_test.go

@@ -8,6 +8,8 @@ import (
 	"github.com/aws/aws-sdk-go/service/cloudwatch"
 	"github.com/aws/aws-sdk-go/service/ec2"
 	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+	"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
+	"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
 	"github.com/bmizerany/assert"
 	"github.com/grafana/grafana/pkg/components/securejsondata"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -22,6 +24,11 @@ type mockedEc2 struct {
 	RespRegions ec2.DescribeRegionsOutput
 }
 
+type mockedRGTA struct {
+	resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI
+	Resp resourcegroupstaggingapi.GetResourcesOutput
+}
+
 func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error {
 	fn(&m.Resp, true)
 	return nil
@@ -30,6 +37,11 @@ func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeR
 	return &m.RespRegions, nil
 }
 
+func (m mockedRGTA) GetResourcesPages(in *resourcegroupstaggingapi.GetResourcesInput, fn func(*resourcegroupstaggingapi.GetResourcesOutput, bool) bool) error {
+	fn(&m.Resp, true)
+	return nil
+}
+
 func TestCloudWatchMetrics(t *testing.T) {
 
 	Convey("When calling getMetricsForCustomMetrics", t, func() {
@@ -209,6 +221,51 @@ func TestCloudWatchMetrics(t *testing.T) {
 			So(result[7].Text, ShouldEqual, "vol-4-2")
 		})
 	})
+
+	Convey("When calling handleGetResourceArns", t, func() {
+		executor := &CloudWatchExecutor{
+			rgtaSvc: mockedRGTA{
+				Resp: resourcegroupstaggingapi.GetResourcesOutput{
+					ResourceTagMappingList: []*resourcegroupstaggingapi.ResourceTagMapping{
+						{
+							ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567"),
+							Tags: []*resourcegroupstaggingapi.Tag{
+								{
+									Key:   aws.String("Environment"),
+									Value: aws.String("production"),
+								},
+							},
+						},
+						{
+							ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321"),
+							Tags: []*resourcegroupstaggingapi.Tag{
+								{
+									Key:   aws.String("Environment"),
+									Value: aws.String("production"),
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		json := simplejson.New()
+		json.Set("region", "us-east-1")
+		json.Set("resourceType", "ec2:instance")
+		tags := make(map[string]interface{})
+		tags["Environment"] = []string{"production"}
+		json.Set("tags", tags)
+		result, _ := executor.handleGetResourceArns(context.Background(), json, &tsdb.TsdbQuery{})
+
+		Convey("Should return all two instances", func() {
+			So(result[0].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567")
+			So(result[0].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567")
+			So(result[1].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321")
+			So(result[1].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321")
+
+		})
+	})
 }
 
 func TestParseMultiSelectValue(t *testing.T) {

+ 1 - 4
pkg/tsdb/mssql/mssql.go

@@ -49,10 +49,7 @@ func generateConnectionString(datasource *models.DataSource) (string, error) {
 		}
 	}
 
-	server, port, err := util.SplitIPPort(datasource.Url, "1433")
-	if err != nil {
-		return "", err
-	}
+	server, port := util.SplitHostPortDefault(datasource.Url, "localhost", "1433")
 
 	encrypt := datasource.JsonData.Get("encrypt").MustString("false")
 	connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",

+ 0 - 25
pkg/util/ip.go

@@ -1,25 +0,0 @@
-package util
-
-import (
-	"net"
-)
-
-// SplitIPPort splits the ip string and port.
-func SplitIPPort(ipStr string, portDefault string) (ip string, port string, err error) {
-	ipAddr := net.ParseIP(ipStr)
-
-	if ipAddr == nil {
-		// Port was included
-		ip, port, err = net.SplitHostPort(ipStr)
-
-		if err != nil {
-			return "", "", err
-		}
-	} else {
-		// No port was included
-		ip = ipAddr.String()
-		port = portDefault
-	}
-
-	return ip, port, nil
-}

+ 31 - 6
pkg/util/ip_address.go

@@ -7,23 +7,48 @@ import (
 
 // ParseIPAddress parses an IP address and removes port and/or IPV6 format
 func ParseIPAddress(input string) string {
+	host, _ := SplitHostPort(input)
+
+	ip := net.ParseIP(host)
+
+	if ip == nil {
+		return host
+	}
+
+	if ip.IsLoopback() {
+		return "127.0.0.1"
+	}
+
+	return ip.String()
+}
+
+// SplitHostPortDefault splits ip address/hostname string by host and port. Defaults used if no match found
+func SplitHostPortDefault(input, defaultHost, defaultPort string) (host string, port string) {
+	port = defaultPort
 	s := input
 	lastIndex := strings.LastIndex(input, ":")
 
 	if lastIndex != -1 {
 		if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" {
 			s = input[:lastIndex]
+			port = input[lastIndex+1:]
+		} else if lastIndex == 0 {
+			s = defaultHost
+			port = input[lastIndex+1:]
 		}
+	} else {
+		port = defaultPort
 	}
 
 	s = strings.Replace(s, "[", "", -1)
 	s = strings.Replace(s, "]", "", -1)
+	port = strings.Replace(port, "[", "", -1)
+	port = strings.Replace(port, "]", "", -1)
 
-	ip := net.ParseIP(s)
-
-	if ip.IsLoopback() {
-		return "127.0.0.1"
-	}
+	return s, port
+}
 
-	return ip.String()
+// SplitHostPort splits ip address/hostname string by host and port
+func SplitHostPort(input string) (host string, port string) {
+	return SplitHostPortDefault(input, "", "")
 }

+ 83 - 1
pkg/util/ip_address_test.go

@@ -9,8 +9,90 @@ import (
 func TestParseIPAddress(t *testing.T) {
 	Convey("Test parse ip address", t, func() {
 		So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140")
+		So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
 		So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1")
 		So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1")
-		So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
+		So(ParseIPAddress("::1"), ShouldEqual, "127.0.0.1")
+		So(ParseIPAddress("::1:123"), ShouldEqual, "127.0.0.1")
+	})
+}
+
+func TestSplitHostPortDefault(t *testing.T) {
+	Convey("Test split ip address to host and port", t, func() {
+		host, port := SplitHostPortDefault("192.168.0.140:456", "", "")
+		So(host, ShouldEqual, "192.168.0.140")
+		So(port, ShouldEqual, "456")
+
+		host, port = SplitHostPortDefault("192.168.0.140", "", "123")
+		So(host, ShouldEqual, "192.168.0.140")
+		So(port, ShouldEqual, "123")
+
+		host, port = SplitHostPortDefault("[::1:456]", "", "")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "456")
+
+		host, port = SplitHostPortDefault("[::1]", "", "123")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "123")
+
+		host, port = SplitHostPortDefault("::1:123", "", "")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "123")
+
+		host, port = SplitHostPortDefault("::1", "", "123")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "123")
+
+		host, port = SplitHostPortDefault(":456", "1.2.3.4", "")
+		So(host, ShouldEqual, "1.2.3.4")
+		So(port, ShouldEqual, "456")
+
+		host, port = SplitHostPortDefault("xyz.rds.amazonaws.com", "", "123")
+		So(host, ShouldEqual, "xyz.rds.amazonaws.com")
+		So(port, ShouldEqual, "123")
+
+		host, port = SplitHostPortDefault("xyz.rds.amazonaws.com:123", "", "")
+		So(host, ShouldEqual, "xyz.rds.amazonaws.com")
+		So(port, ShouldEqual, "123")
+	})
+}
+
+func TestSplitHostPort(t *testing.T) {
+	Convey("Test split ip address to host and port", t, func() {
+		host, port := SplitHostPort("192.168.0.140:456")
+		So(host, ShouldEqual, "192.168.0.140")
+		So(port, ShouldEqual, "456")
+
+		host, port = SplitHostPort("192.168.0.140")
+		So(host, ShouldEqual, "192.168.0.140")
+		So(port, ShouldEqual, "")
+
+		host, port = SplitHostPort("[::1:456]")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "456")
+
+		host, port = SplitHostPort("[::1]")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "")
+
+		host, port = SplitHostPort("::1:123")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "123")
+
+		host, port = SplitHostPort("::1")
+		So(host, ShouldEqual, "::1")
+		So(port, ShouldEqual, "")
+
+		host, port = SplitHostPort(":456")
+		So(host, ShouldEqual, "")
+		So(port, ShouldEqual, "456")
+
+		host, port = SplitHostPort("xyz.rds.amazonaws.com")
+		So(host, ShouldEqual, "xyz.rds.amazonaws.com")
+		So(port, ShouldEqual, "")
+
+		host, port = SplitHostPort("xyz.rds.amazonaws.com:123")
+		So(host, ShouldEqual, "xyz.rds.amazonaws.com")
+		So(port, ShouldEqual, "123")
 	})
 }

+ 0 - 43
pkg/util/ip_test.go

@@ -1,43 +0,0 @@
-package util
-
-import (
-	"testing"
-
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestSplitIPPort(t *testing.T) {
-
-	Convey("When parsing an IPv4 without explicit port", t, func() {
-		ip, port, err := SplitIPPort("1.2.3.4", "5678")
-
-		So(err, ShouldEqual, nil)
-		So(ip, ShouldEqual, "1.2.3.4")
-		So(port, ShouldEqual, "5678")
-	})
-
-	Convey("When parsing an IPv6 without explicit port", t, func() {
-		ip, port, err := SplitIPPort("::1", "5678")
-
-		So(err, ShouldEqual, nil)
-		So(ip, ShouldEqual, "::1")
-		So(port, ShouldEqual, "5678")
-	})
-
-	Convey("When parsing an IPv4 with explicit port", t, func() {
-		ip, port, err := SplitIPPort("1.2.3.4:56", "78")
-
-		So(err, ShouldEqual, nil)
-		So(ip, ShouldEqual, "1.2.3.4")
-		So(port, ShouldEqual, "56")
-	})
-
-	Convey("When parsing an IPv6 with explicit port", t, func() {
-		ip, port, err := SplitIPPort("[::1]:56", "78")
-
-		So(err, ShouldEqual, nil)
-		So(ip, ShouldEqual, "::1")
-		So(port, ShouldEqual, "56")
-	})
-
-}

+ 1 - 2
public/app/core/components/Select/MetricSelect.tsx

@@ -1,8 +1,7 @@
 import React from 'react';
 import _ from 'lodash';
 
-import { Select } from '@grafana/ui';
-import { SelectOptionItem } from '@grafana/ui';
+import { Select, SelectOptionItem } from '@grafana/ui';
 import { Variable } from 'app/types/templates';
 
 export interface Props {

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

@@ -1,7 +1,6 @@
 import _ from 'lodash';
-import { colors } from '@grafana/ui';
 
-import { TimeSeries } from 'app/core/core';
+import { colors, TimeSeries } from '@grafana/ui';
 import { getThemeColor } from 'app/core/utils/colors';
 
 /**
@@ -341,6 +340,6 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Time
       return a[1] - b[1];
     });
 
-    return new TimeSeries(series);
+    return { datapoints: series.datapoints, target: series.alias, color: series.color };
   });
 }

+ 26 - 11
public/app/core/services/backend_srv.ts

@@ -1,6 +1,7 @@
 import _ from 'lodash';
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
+import config from 'app/core/config';
 import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
 
 export class BackendSrv {
@@ -103,10 +104,17 @@ export class BackendSrv {
       err => {
         // handle unauthorized
         if (err.status === 401 && this.contextSrv.user.isSignedIn && firstAttempt) {
-          return this.loginPing().then(() => {
-            options.retry = 1;
-            return this.request(options);
-          });
+          return this.loginPing()
+            .then(() => {
+              options.retry = 1;
+              return this.request(options);
+            })
+            .catch(err => {
+              if (err.status === 401) {
+                window.location.href = config.appSubUrl + '/logout';
+                throw err;
+              }
+            });
         }
 
         this.$timeout(this.requestErrorHandler.bind(this, err), 50);
@@ -184,13 +192,20 @@ export class BackendSrv {
 
         // handle unauthorized for backend requests
         if (requestIsLocal && firstAttempt && err.status === 401) {
-          return this.loginPing().then(() => {
-            options.retry = 1;
-            if (canceler) {
-              canceler.resolve();
-            }
-            return this.datasourceRequest(options);
-          });
+          return this.loginPing()
+            .then(() => {
+              options.retry = 1;
+              if (canceler) {
+                canceler.resolve();
+              }
+              return this.datasourceRequest(options);
+            })
+            .catch(err => {
+              if (err.status === 401) {
+                window.location.href = config.appSubUrl + '/logout';
+                throw err;
+              }
+            });
         }
 
         // populate error obj on Internal Error

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

@@ -249,7 +249,7 @@ export class KeybindingSrv {
         if (panelInfo.panel.legend) {
           const panelRef = dashboard.getPanelById(dashboard.meta.focusPanelId);
           panelRef.legend.show = !panelRef.legend.show;
-          panelRef.refresh();
+          panelRef.render();
         }
       }
     });

+ 30 - 2
public/app/core/utils/explore.test.ts

@@ -13,6 +13,11 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
   datasource: null,
   queries: [],
   range: DEFAULT_RANGE,
+  ui: {
+    showingGraph: true,
+    showingTable: true,
+    showingLogs: true,
+  }
 };
 
 describe('state functions', () => {
@@ -69,9 +74,11 @@ describe('state functions', () => {
           to: 'now',
         },
       };
+
       expect(serializeStateToUrlParam(state)).toBe(
         '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
-          '{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
+          '{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"},' +
+          '"ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}'
       );
     });
 
@@ -93,7 +100,7 @@ describe('state functions', () => {
         },
       };
       expect(serializeStateToUrlParam(state, true)).toBe(
-        '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
+        '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"},{"ui":[true,true,true]}]'
       );
     });
   });
@@ -118,7 +125,28 @@ describe('state functions', () => {
       };
       const serialized = serializeStateToUrlParam(state);
       const parsed = parseUrlState(serialized);
+      expect(state).toMatchObject(parsed);
+    });
 
+    it('can parse the compact serialized state into the original state', () => {
+      const state = {
+        ...DEFAULT_EXPLORE_STATE,
+        datasource: 'foo',
+        queries: [
+          {
+            expr: 'metric{test="a/b"}',
+          },
+          {
+            expr: 'super{foo="x/z"}',
+          },
+        ],
+        range: {
+          from: 'now - 5h',
+          to: 'now',
+        },
+      };
+      const serialized = serializeStateToUrlParam(state, true);
+      const parsed = parseUrlState(serialized);
       expect(state).toMatchObject(parsed);
     });
   });

+ 46 - 5
public/app/core/utils/explore.ts

@@ -11,7 +11,7 @@ import { colors } from '@grafana/ui';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 
 // Types
-import { RawTimeRange, IntervalValues, DataQuery } from '@grafana/ui/src/types';
+import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui/src/types';
 import TimeSeries from 'app/core/time_series2';
 import {
   ExploreUrlState,
@@ -27,6 +27,12 @@ export const DEFAULT_RANGE = {
   to: 'now',
 };
 
+export const DEFAULT_UI_STATE = {
+  showingTable: true,
+  showingGraph: true,
+  showingLogs: true,
+};
+
 const MAX_HISTORY_ITEMS = 100;
 
 export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
@@ -147,7 +153,12 @@ export function buildQueryTransaction(
 
 export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
 
+const isMetricSegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('expr');
+const isUISegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('ui');
+
 export function parseUrlState(initial: string | undefined): ExploreUrlState {
+  let uiState = DEFAULT_UI_STATE;
+
   if (initial) {
     try {
       const parsed = JSON.parse(decodeURI(initial));
@@ -160,20 +171,41 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
           to: parsed[1],
         };
         const datasource = parsed[2];
-        const queries = parsed.slice(3);
-        return { datasource, queries, range };
+        let queries = [];
+
+        parsed.slice(3).forEach(segment => {
+          if (isMetricSegment(segment)) {
+            queries = [...queries, segment];
+          }
+
+          if (isUISegment(segment)) {
+            uiState = {
+              showingGraph: segment.ui[0],
+              showingLogs: segment.ui[1],
+              showingTable: segment.ui[2],
+            };
+          }
+        });
+
+        return { datasource, queries, range, ui: uiState };
       }
       return parsed;
     } catch (e) {
       console.error(e);
     }
   }
-  return { datasource: null, queries: [], range: DEFAULT_RANGE };
+  return { datasource: null, queries: [], range: DEFAULT_RANGE, ui: uiState };
 }
 
 export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
   if (compact) {
-    return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
+    return JSON.stringify([
+      urlState.range.from,
+      urlState.range.to,
+      urlState.datasource,
+      ...urlState.queries,
+      { ui: [!!urlState.ui.showingGraph, !!urlState.ui.showingLogs, !!urlState.ui.showingTable] },
+    ]);
   }
   return JSON.stringify(urlState);
 }
@@ -304,3 +336,12 @@ export function clearHistory(datasourceId: string) {
   const historyKey = `grafana.explore.history.${datasourceId}`;
   store.delete(historyKey);
 }
+
+export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourceApi): string[] => {
+  const queryKeys = queries.reduce((newQueryKeys, query, index) => {
+    const primaryKey = datasourceInstance && datasourceInstance.name ? datasourceInstance.name : query.key;
+    return newQueryKeys.concat(`${primaryKey}-${index}`);
+  }, []);
+
+  return queryKeys;
+};

+ 23 - 0
public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { AddPanelWidget, Props } from './AddPanelWidget';
+import { DashboardModel, PanelModel } from '../../state';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    dashboard: {} as DashboardModel,
+    panel: {} as PanelModel,
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<AddPanelWidget {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 61 - 39
public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx

@@ -1,12 +1,20 @@
+// Libraries
 import React from 'react';
 import _ from 'lodash';
+
+// Utils
 import config from 'app/core/config';
-import { PanelModel } from '../../state/PanelModel';
-import { DashboardModel } from '../../state/DashboardModel';
 import store from 'app/core/store';
-import { LS_PANEL_COPY_KEY } from 'app/core/constants';
-import { updateLocation } from 'app/core/actions';
+
+// Store
 import { store as reduxStore } from 'app/store/store';
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { PanelModel } from '../../state';
+import { DashboardModel } from '../../state';
+import { LS_PANEL_COPY_KEY } from 'app/core/constants';
+import { LocationUpdate } from 'app/types';
 
 export interface Props {
   panel: PanelModel;
@@ -46,6 +54,7 @@ export class AddPanelWidget extends React.Component<Props, State> {
         copiedPanels.push(pluginCopy);
       }
     }
+
     return _.sortBy(copiedPanels, 'sort');
   }
 
@@ -54,28 +63,7 @@ export class AddPanelWidget extends React.Component<Props, State> {
     this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
   }
 
-  copyButton(panel) {
-    return (
-      <button className="btn-inverse btn" onClick={() => this.onPasteCopiedPanel(panel)} title={panel.name}>
-        Paste copied Panel
-      </button>
-    );
-  }
-
-  moveToEdit(panel) {
-    reduxStore.dispatch(
-      updateLocation({
-        query: {
-          panelId: panel.id,
-          edit: true,
-          fullscreen: true,
-        },
-        partial: true,
-      })
-    );
-  }
-
-  onCreateNewPanel = () => {
+  onCreateNewPanel = (tab = 'queries') => {
     const dashboard = this.props.dashboard;
     const { gridPos } = this.props.panel;
 
@@ -88,7 +76,21 @@ export class AddPanelWidget extends React.Component<Props, State> {
     dashboard.addPanel(newPanel);
     dashboard.removePanel(this.props.panel);
 
-    this.moveToEdit(newPanel);
+    const location: LocationUpdate = {
+      query: {
+        panelId: newPanel.id,
+        edit: true,
+        fullscreen: true,
+      },
+      partial: true,
+    };
+
+    if (tab === 'visualization') {
+      location.query.tab = 'visualization';
+      location.query.openVizPicker  = true;
+    }
+
+    reduxStore.dispatch(updateLocation(location));
   };
 
   onPasteCopiedPanel = panelPluginInfo => {
@@ -125,30 +127,50 @@ export class AddPanelWidget extends React.Component<Props, State> {
     dashboard.removePanel(this.props.panel);
   };
 
-  render() {
-    let addCopyButton;
+  renderOptionLink = (icon, text, onClick) => {
+    return (
+      <div>
+        <a href="#" onClick={onClick} className="add-panel-widget__link btn btn-inverse">
+          <div className="add-panel-widget__icon">
+            <i className={`gicon gicon-${icon}`} />
+          </div>
+          <span>{text}</span>
+        </a>
+      </div>
+    );
+  };
 
-    if (this.state.copiedPanelPlugins.length === 1) {
-      addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]);
-    }
+  render() {
+    const { copiedPanelPlugins } = this.state;
 
     return (
       <div className="panel-container add-panel-widget-container">
         <div className="add-panel-widget">
           <div className="add-panel-widget__header grid-drag-handle">
             <i className="gicon gicon-add-panel" />
+            <span className="add-panel-widget__title">New Panel</span>
             <button className="add-panel-widget__close" onClick={this.handleCloseAddPanel}>
               <i className="fa fa-close" />
             </button>
           </div>
           <div className="add-panel-widget__btn-container">
-            <button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
-              Edit Panel
-            </button>
-            {addCopyButton}
-            <button className="btn-inverse btn" onClick={this.onCreateNewRow}>
-              Add Row
-            </button>
+            <div className="add-panel-widget__create">
+              {this.renderOptionLink('queries', 'Add Query', this.onCreateNewPanel)}
+              {this.renderOptionLink('visualization', 'Choose Visualization', () =>
+                this.onCreateNewPanel('visualization')
+              )}
+            </div>
+            <div className="add-panel-widget__actions">
+              <button className="btn btn-inverse add-panel-widget__action" onClick={this.onCreateNewRow}>Convert to row</button>
+              {copiedPanelPlugins.length === 1 && (
+                <button
+                  className="btn btn-inverse add-panel-widget__action"
+                  onClick={() => this.onPasteCopiedPanel(copiedPanelPlugins[0])}
+                >
+                  Paste copied panel
+                </button>
+              )}
+            </div>
           </div>
         </div>
       </div>

+ 42 - 5
public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss

@@ -14,6 +14,9 @@
   align-items: center;
   width: 100%;
   cursor: move;
+  background: $page-header-bg;
+  box-shadow: $page-header-shadow;
+  border-bottom: 1px solid $page-header-border-color;
 
   .gicon {
     font-size: 30px;
@@ -26,6 +29,29 @@
   }
 }
 
+.add-panel-widget__title {
+  font-size: $font-size-md;
+  font-weight: $font-weight-semi-bold;
+  margin-right: $spacer*2;
+}
+
+.add-panel-widget__link {
+  margin: 0 8px;
+  width: 154px;
+}
+
+.add-panel-widget__icon {
+  margin-bottom: 8px;
+
+  .gicon {
+    color: white;
+    height: 44px;
+    width: 53px;
+    position: relative;
+    left: 5px;
+  }
+}
+
 .add-panel-widget__close {
   margin-left: auto;
   background-color: transparent;
@@ -34,14 +60,25 @@
   margin-right: -10px;
 }
 
+.add-panel-widget__create {
+  display: inherit;
+  margin-bottom: 24px;
+  // this is to have the big button appear centered
+  margin-top: 55px;
+}
+
+.add-panel-widget__actions {
+  display: inherit;
+}
+
+.add-panel-widget__action {
+  margin: 0 4px;
+}
+
 .add-panel-widget__btn-container {
+  height: 100%;
   display: flex;
   justify-content: center;
   align-items: center;
-  height: 100%;
   flex-direction: column;
-
-  .btn {
-    margin-bottom: 10px;
-  }
 }

+ 86 - 0
public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap

@@ -0,0 +1,86 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="panel-container add-panel-widget-container"
+>
+  <div
+    className="add-panel-widget"
+  >
+    <div
+      className="add-panel-widget__header grid-drag-handle"
+    >
+      <i
+        className="gicon gicon-add-panel"
+      />
+      <span
+        className="add-panel-widget__title"
+      >
+        New Panel
+      </span>
+      <button
+        className="add-panel-widget__close"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-close"
+        />
+      </button>
+    </div>
+    <div
+      className="add-panel-widget__btn-container"
+    >
+      <div
+        className="add-panel-widget__create"
+      >
+        <div>
+          <a
+            className="add-panel-widget__link btn btn-inverse"
+            href="#"
+            onClick={[Function]}
+          >
+            <div
+              className="add-panel-widget__icon"
+            >
+              <i
+                className="gicon gicon-queries"
+              />
+            </div>
+            <span>
+              Add Query
+            </span>
+          </a>
+        </div>
+        <div>
+          <a
+            className="add-panel-widget__link btn btn-inverse"
+            href="#"
+            onClick={[Function]}
+          >
+            <div
+              className="add-panel-widget__icon"
+            >
+              <i
+                className="gicon gicon-visualization"
+              />
+            </div>
+            <span>
+              Choose Visualization
+            </span>
+          </a>
+        </div>
+      </div>
+      <div
+        className="add-panel-widget__actions"
+      >
+        <button
+          className="btn btn-inverse add-panel-widget__action"
+          onClick={[Function]}
+        >
+          Convert to row
+        </button>
+      </div>
+    </div>
+  </div>
+</div>
+`;

+ 1 - 0
public/app/features/dashboard/components/SaveModals/index.ts

@@ -1,2 +1,3 @@
 export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
 export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
+export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';

+ 3 - 3
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -94,7 +94,7 @@ export class PanelChrome extends PureComponent<Props, State> {
     return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
   }
 
-  renderPanel(loading, timeSeries, width, height): JSX.Element {
+  renderPanel(loading, panelData, width, height): JSX.Element {
     const { panel, plugin } = this.props;
     const { timeRange, renderCounter } = this.state;
     const PanelComponent = plugin.exports.Panel;
@@ -109,7 +109,7 @@ export class PanelChrome extends PureComponent<Props, State> {
       <div className="panel-content">
         <PanelComponent
           loading={loading}
-          timeSeries={timeSeries}
+          panelData={panelData}
           timeRange={timeRange}
           options={panel.getOptions(plugin.exports.PanelDefaults)}
           width={width - 2 * variables.panelHorizontalPadding}
@@ -158,7 +158,7 @@ export class PanelChrome extends PureComponent<Props, State> {
                   onDataResponse={this.onDataResponse}
                 >
                   {({ loading, panelData }) => {
-                    return this.renderPanel(loading, panelData.timeSeries, width, height);
+                    return this.renderPanel(loading, panelData, width, height);
                   }}
                 </DataPanel>
               )}

+ 2 - 2
public/app/features/dashboard/panel_editor/PanelEditor.tsx

@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
 import classNames from 'classnames';
 
 import { QueriesTab } from './QueriesTab';
-import { VisualizationTab } from './VisualizationTab';
+import VisualizationTab from './VisualizationTab';
 import { GeneralTab } from './GeneralTab';
 import { AlertTab } from '../../alerting/AlertTab';
 
@@ -38,7 +38,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
   onChangeTab = (tab: PanelEditorTab) => {
     store.dispatch(
       updateLocation({
-        query: { tab: tab.id },
+        query: { tab: tab.id, openVizPicker: null },
         partial: true,
       })
     );

+ 7 - 1
public/app/features/dashboard/panel_editor/QueriesTab.tsx

@@ -133,7 +133,7 @@ export class QueriesTab extends PureComponent<Props, State> {
     return (
       <>
         <DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />
-        <div className="flex-grow" />
+        <div className="flex-grow-1" />
         {!isAddingMixed && (
           <button className="btn navbar-button navbar-button--primary" onClick={this.onAddQueryClick}>
             Add Query
@@ -165,6 +165,11 @@ export class QueriesTab extends PureComponent<Props, State> {
     this.setState({ isAddingMixed: false });
   };
 
+  onQueryChange = (query: DataQuery, index) => {
+    this.props.panel.changeQuery(query, index);
+    this.forceUpdate();
+  };
+
   setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
     const target = event.target as HTMLElement;
     this.setState({ scrollTop: target.scrollTop });
@@ -201,6 +206,7 @@ export class QueriesTab extends PureComponent<Props, State> {
                 key={query.refId}
                 panel={panel}
                 query={query}
+                onChange={query => this.onQueryChange(query, index)}
                 onRemoveQuery={this.onRemoveQuery}
                 onAddQuery={this.onAddQuery}
                 onMoveQuery={this.onMoveQuery}

+ 6 - 10
public/app/features/dashboard/panel_editor/QueryEditorRow.tsx

@@ -18,6 +18,7 @@ interface Props {
   onAddQuery: (query?: DataQuery) => void;
   onRemoveQuery: (query: DataQuery) => void;
   onMoveQuery: (query: DataQuery, direction: number) => void;
+  onChange: (query: DataQuery) => void;
   dataSourceValue: string | null;
   inMixedMode: boolean;
 }
@@ -105,17 +106,12 @@ export class QueryEditorRow extends PureComponent<Props, State> {
     this.setState({ isCollapsed: !this.state.isCollapsed });
   };
 
-  onQueryChange = (query: DataQuery) => {
-    Object.assign(this.props.query, query);
-    this.onExecuteQuery();
-  };
-
-  onExecuteQuery = () => {
+  onRunQuery = () => {
     this.props.panel.refresh();
   };
 
   renderPluginEditor() {
-    const { query } = this.props;
+    const { query, onChange } = this.props;
     const { datasource } = this.state;
 
     if (datasource.pluginExports.QueryCtrl) {
@@ -128,8 +124,8 @@ export class QueryEditorRow extends PureComponent<Props, State> {
         <QueryEditor
           query={query}
           datasource={datasource}
-          onQueryChange={this.onQueryChange}
-          onExecuteQuery={this.onExecuteQuery}
+          onChange={onChange}
+          onRunQuery={this.onRunQuery}
         />
       );
     }
@@ -166,7 +162,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
 
   onDisableQuery = () => {
     this.props.query.hide = !this.props.query.hide;
-    this.onExecuteQuery();
+    this.onRunQuery();
     this.forceUpdate();
   };
 

+ 20 - 1
public/app/features/dashboard/panel_editor/VisualizationTab.tsx

@@ -3,6 +3,9 @@ import React, { PureComponent } from 'react';
 
 // Utils & Services
 import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
+import { StoreState } from 'app/types';
+import { updateLocation } from 'app/core/actions';
 
 // Components
 import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
@@ -21,6 +24,8 @@ interface Props {
   plugin: PanelPlugin;
   angularPanel?: AngularComponent;
   onTypeChanged: (newType: PanelPlugin) => void;
+  updateLocation: typeof updateLocation;
+  urlOpenVizPicker: boolean;
 }
 
 interface State {
@@ -38,7 +43,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
     super(props);
 
     this.state = {
-      isVizPickerOpen: false,
+      isVizPickerOpen: this.props.urlOpenVizPicker,
       searchQuery: '',
       scrollTop: 0,
     };
@@ -149,6 +154,10 @@ export class VisualizationTab extends PureComponent<Props, State> {
   };
 
   onCloseVizPicker = () => {
+    if (this.props.urlOpenVizPicker) {
+      this.props.updateLocation({ query: { openVizPicker: null }, partial: true });
+    }
+
     this.setState({ isVizPickerOpen: false });
   };
 
@@ -236,3 +245,13 @@ export class VisualizationTab extends PureComponent<Props, State> {
     );
   }
 }
+
+const mapStateToProps = (state: StoreState) => ({
+  urlOpenVizPicker: !!state.location.query.openVizPicker
+});
+
+const mapDispatchToProps = {
+  updateLocation
+};
+
+export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps);

+ 13 - 0
public/app/features/dashboard/state/PanelModel.ts

@@ -269,6 +269,19 @@ export class PanelModel {
     });
   }
 
+  changeQuery(query: DataQuery, index: number) {
+    // ensure refId is maintained
+    query.refId = this.targets[index].refId;
+
+    // update query in array
+    this.targets = this.targets.map((item, itemIndex) => {
+      if (itemIndex === index) {
+        return query;
+      }
+      return item;
+    });
+  }
+
   destroy() {
     this.events.emit('panel-teardown');
     this.events.removeAllListeners();

+ 2 - 2
public/app/features/datasources/DataSourcesListItem.tsx

@@ -16,12 +16,12 @@ export class DataSourcesListItem extends PureComponent<Props> {
           </div>
           <div className="card-item-body">
             <figure className="card-item-figure">
-              <img src={dataSource.typeLogoUrl} />
+              <img src={dataSource.typeLogoUrl} alt={dataSource.name} />
             </figure>
             <div className="card-item-details">
               <div className="card-item-name">
                 {dataSource.name}
-                {dataSource.isDefault && <span className="btn btn-secondary btn-mini">default</span>}
+                {dataSource.isDefault && <span className="btn btn-secondary btn-mini card-item-label">default</span>}
               </div>
               <div className="card-item-sub-name">{dataSource.url}</div>
             </div>

+ 1 - 0
public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap

@@ -24,6 +24,7 @@ exports[`Render should render component 1`] = `
         className="card-item-figure"
       >
         <img
+          alt="gdev-cloudwatch"
           src="public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png"
         />
       </figure>

+ 20 - 25
public/app/features/explore/Explore.tsx

@@ -1,5 +1,5 @@
 // Libraries
-import React from 'react';
+import React, { ComponentClass } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import _ from 'lodash';
@@ -18,34 +18,26 @@ import TableContainer from './TableContainer';
 import TimePicker, { parseTime } from './TimePicker';
 
 // Actions
-import {
-  changeSize,
-  changeTime,
-  initializeExplore,
-  modifyQueries,
-  scanStart,
-  scanStop,
-  setQueries,
-} from './state/actions';
+import { changeSize, changeTime, initializeExplore, modifyQueries, scanStart, setQueries } from './state/actions';
 
 // Types
-import { RawTimeRange, TimeRange, DataQuery } from '@grafana/ui';
+import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
 import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore';
 import { StoreState } from 'app/types';
-import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore';
+import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE, DEFAULT_UI_STATE } from 'app/core/utils/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { ExploreToolbar } from './ExploreToolbar';
+import { scanStopAction } from './state/actionTypes';
 
 interface ExploreProps {
-  StartPage?: any;
+  StartPage?: ComponentClass<ExploreStartPageProps>;
   changeSize: typeof changeSize;
   changeTime: typeof changeTime;
   datasourceError: string;
-  datasourceInstance: any;
+  datasourceInstance: ExploreDataSourceApi;
   datasourceLoading: boolean | null;
   datasourceMissing: boolean;
   exploreId: ExploreId;
-  initialQueries: DataQuery[];
   initializeExplore: typeof initializeExplore;
   initialized: boolean;
   modifyQueries: typeof modifyQueries;
@@ -54,7 +46,7 @@ interface ExploreProps {
   scanning?: boolean;
   scanRange?: RawTimeRange;
   scanStart: typeof scanStart;
-  scanStop: typeof scanStop;
+  scanStopAction: typeof scanStopAction;
   setQueries: typeof setQueries;
   split: boolean;
   showingStartPage?: boolean;
@@ -62,6 +54,7 @@ interface ExploreProps {
   supportsLogs: boolean | null;
   supportsTable: boolean | null;
   urlState: ExploreUrlState;
+  queryKeys: string[];
 }
 
 /**
@@ -107,18 +100,20 @@ export class Explore extends React.PureComponent<ExploreProps> {
     // Don't initialize on split, but need to initialize urlparameters when present
     if (!initialized) {
       // Load URL state and parse range
-      const { datasource, queries, range = DEFAULT_RANGE } = (urlState || {}) as ExploreUrlState;
+      const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
       const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
       const initialQueries: DataQuery[] = ensureQueries(queries);
       const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
       const width = this.el ? this.el.offsetWidth : 0;
+
       this.props.initializeExplore(
         exploreId,
         initialDatasource,
         initialQueries,
         initialRange,
         width,
-        this.exploreEvents
+        this.exploreEvents,
+        ui
       );
     }
   }
@@ -171,7 +166,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
   };
 
   onStopScanning = () => {
-    this.props.scanStop(this.props.exploreId);
+    this.props.scanStopAction({ exploreId: this.props.exploreId });
   };
 
   render() {
@@ -182,12 +177,12 @@ export class Explore extends React.PureComponent<ExploreProps> {
       datasourceLoading,
       datasourceMissing,
       exploreId,
-      initialQueries,
       showingStartPage,
       split,
       supportsGraph,
       supportsLogs,
       supportsTable,
+      queryKeys,
     } = this.props;
     const exploreClass = split ? 'explore explore-split' : 'explore';
 
@@ -208,7 +203,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
         {datasourceInstance &&
           !datasourceError && (
             <div className="explore-container">
-              <QueryRows exploreEvents={this.exploreEvents} exploreId={exploreId} initialQueries={initialQueries} />
+              <QueryRows exploreEvents={this.exploreEvents} exploreId={exploreId} queryKeys={queryKeys} />
               <AutoSizer onResize={this.onResize} disableHeight>
                 {({ width }) => (
                   <main className="m-t-2" style={{ width }}>
@@ -216,7 +211,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
                       {showingStartPage && <StartPage onClickExample={this.onClickExample} />}
                       {!showingStartPage && (
                         <>
-                          {supportsGraph && <GraphContainer exploreId={exploreId} />}
+                          {supportsGraph && !supportsLogs && <GraphContainer exploreId={exploreId} />}
                           {supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
                           {supportsLogs && (
                             <LogsContainer
@@ -250,13 +245,13 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     datasourceInstance,
     datasourceLoading,
     datasourceMissing,
-    initialQueries,
     initialized,
     range,
     showingStartPage,
     supportsGraph,
     supportsLogs,
     supportsTable,
+    queryKeys,
   } = item;
   return {
     StartPage,
@@ -264,7 +259,6 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     datasourceInstance,
     datasourceLoading,
     datasourceMissing,
-    initialQueries,
     initialized,
     range,
     showingStartPage,
@@ -272,6 +266,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     supportsGraph,
     supportsLogs,
     supportsTable,
+    queryKeys,
   };
 }
 
@@ -281,7 +276,7 @@ const mapDispatchToProps = {
   initializeExplore,
   modifyQueries,
   scanStart,
-  scanStop,
+  scanStopAction,
   setQueries,
 };
 

+ 8 - 1
public/app/features/explore/ExploreToolbar.tsx

@@ -8,6 +8,7 @@ import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { StoreState } from 'app/types/store';
 import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions';
 import TimePicker from './TimePicker';
+import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
 
 enum IconSide {
   left = 'left',
@@ -79,6 +80,10 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
     this.props.runQuery(this.props.exploreId);
   };
 
+  onCloseTimePicker = () => {
+    this.props.timepickerRef.current.setState({ isOpen: false });
+  };
+
   render() {
     const {
       datasourceMissing,
@@ -137,7 +142,9 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
               </div>
             ) : null}
             <div className="explore-toolbar-content-item timepicker">
-              <TimePicker ref={timepickerRef} range={range} onChangeTime={this.props.onChangeTime} />
+              <ClickOutsideWrapper onClick={this.onCloseTimePicker}>
+                <TimePicker ref={timepickerRef} range={range} onChangeTime={this.props.onChangeTime} />
+              </ClickOutsideWrapper>
             </div>
             <div className="explore-toolbar-content-item">
               <button className="btn navbar-button navbar-button--no-icon" onClick={this.onClearAll}>

+ 4 - 1
public/app/features/explore/Logs.tsx

@@ -3,6 +3,8 @@ import React, { PureComponent } from 'react';
 
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import { RawTimeRange, Switch } from '@grafana/ui';
+import TimeSeries from 'app/core/time_series2';
+
 import {
   LogsDedupDescription,
   LogsDedupStrategy,
@@ -205,12 +207,13 @@ export default class Logs extends PureComponent<Props, State> {
 
     // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
     const getRows = () => processedRows;
+    const timeSeries = data.series.map(series => new TimeSeries(series));
 
     return (
       <div className="logs-panel">
         <div className="logs-panel-graph">
           <Graph
-            data={data.series}
+            data={timeSeries}
             height="100px"
             range={range}
             id={`explore-logs-graph-${exploreId}`}

+ 4 - 7
public/app/features/explore/QueryEditor.tsx

@@ -14,7 +14,7 @@ interface QueryEditorProps {
   datasource: any;
   error?: string | JSX.Element;
   onExecuteQuery?: () => void;
-  onQueryChange?: (value: DataQuery, override?: boolean) => void;
+  onQueryChange?: (value: DataQuery) => void;
   initialQuery: DataQuery;
   exploreEvents: Emitter;
   range: RawTimeRange;
@@ -40,20 +40,17 @@ export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
         datasource,
         target,
         refresh: () => {
-          this.props.onQueryChange(target, false);
+          this.props.onQueryChange(target);
           this.props.onExecuteQuery();
         },
         events: exploreEvents,
-        panel: {
-          datasource,
-          targets: [target],
-        },
+        panel: { datasource, targets: [target] },
         dashboard: {},
       },
     };
 
     this.component = loader.load(this.element, scopeProps, template);
-    this.props.onQueryChange(target, false);
+    this.props.onQueryChange(target);
   }
 
   componentWillUnmount() {

+ 81 - 54
public/app/features/explore/QueryField.tsx

@@ -33,10 +33,9 @@ export interface QueryFieldProps {
   cleanText?: (text: string) => string;
   disabled?: boolean;
   initialQuery: string | null;
-  onBlur?: () => void;
-  onFocus?: () => void;
+  onExecuteQuery?: () => void;
+  onQueryChange?: (value: string) => void;
   onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
-  onValueChanged?: (value: string) => void;
   onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
   placeholder?: string;
   portalOrigin?: string;
@@ -51,6 +50,7 @@ export interface QueryFieldState {
   typeaheadPrefix: string;
   typeaheadText: string;
   value: Value;
+  lastExecutedValue: Value;
 }
 
 export interface TypeaheadInput {
@@ -90,6 +90,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
       typeaheadPrefix: '',
       typeaheadText: '',
       value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
+      lastExecutedValue: null,
     };
   }
 
@@ -104,11 +105,20 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
   }
 
   componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
+    const { initialQuery, syntax } = this.props;
+    const { value, suggestions } = this.state;
+
+    // if query changed from the outside
+    if (initialQuery !== prevProps.initialQuery) {
+      // and we have a version that differs
+      if (initialQuery !== Plain.serialize(value)) {
+        this.placeholdersBuffer = new PlaceholdersBuffer(initialQuery || '');
+        this.setState({ value: makeValue(this.placeholdersBuffer.toString(), syntax) });
+      }
+    }
+
     // Only update menu location when suggestion existence or text/selection changed
-    if (
-      this.state.value !== prevState.value ||
-      hasSuggestions(this.state.suggestions) !== hasSuggestions(prevState.suggestions)
-    ) {
+    if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
       this.updateMenu();
     }
   }
@@ -123,11 +133,11 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
       if (this.placeholdersBuffer.hasPlaceholders()) {
         change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
       }
-      this.onChange(change);
+      this.onChange(change, true);
     }
   }
 
-  onChange = ({ value }) => {
+  onChange = ({ value }, invokeParentOnValueChanged?: boolean) => {
     const documentChanged = value.document !== this.state.value.document;
     const prevValue = this.state.value;
 
@@ -135,8 +145,8 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
     this.setState({ value }, () => {
       if (documentChanged) {
         const textChanged = Plain.serialize(prevValue) !== Plain.serialize(value);
-        if (textChanged) {
-          this.handleChangeValue();
+        if (textChanged && invokeParentOnValueChanged) {
+          this.executeOnQueryChangeAndExecuteQueries();
         }
       }
     });
@@ -150,11 +160,16 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
     }
   };
 
-  handleChangeValue = () => {
+  executeOnQueryChangeAndExecuteQueries = () => {
     // Send text change to parent
-    const { onValueChanged } = this.props;
-    if (onValueChanged) {
-      onValueChanged(Plain.serialize(this.state.value));
+    const { onQueryChange, onExecuteQuery } = this.props;
+    if (onQueryChange) {
+      onQueryChange(Plain.serialize(this.state.value));
+    }
+
+    if (onExecuteQuery) {
+      onExecuteQuery();
+      this.setState({ lastExecutedValue: this.state.value });
     }
   };
 
@@ -279,8 +294,37 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
       .focus();
   }
 
-  onKeyDown = (event, change) => {
+  handleEnterAndTabKey = change => {
     const { typeaheadIndex, suggestions } = this.state;
+    if (this.menuEl) {
+      // Dont blur input
+      event.preventDefault();
+      if (!suggestions || suggestions.length === 0) {
+        return undefined;
+      }
+
+      const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
+      const nextChange = this.applyTypeahead(change, suggestion);
+
+      const insertTextOperation = nextChange.operations.find(operation => operation.type === 'insert_text');
+      if (insertTextOperation) {
+        const suggestionText = insertTextOperation.text;
+        this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
+        if (this.placeholdersBuffer.hasPlaceholders()) {
+          nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
+        }
+      }
+
+      return true;
+    } else {
+      this.executeOnQueryChangeAndExecuteQueries();
+
+      return undefined;
+    }
+  };
+
+  onKeyDown = (event, change) => {
+    const { typeaheadIndex } = this.state;
 
     switch (event.key) {
       case 'Escape': {
@@ -303,27 +347,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
       }
       case 'Enter':
       case 'Tab': {
-        if (this.menuEl) {
-          // Dont blur input
-          event.preventDefault();
-          if (!suggestions || suggestions.length === 0) {
-            return undefined;
-          }
-
-          const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
-          const nextChange = this.applyTypeahead(change, suggestion);
-
-          const insertTextOperation = nextChange.operations.find(operation => operation.type === 'insert_text');
-          if (insertTextOperation) {
-            const suggestionText = insertTextOperation.text;
-            this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
-            if (this.placeholdersBuffer.hasPlaceholders()) {
-              nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
-            }
-          }
-
-          return true;
-        }
+        return this.handleEnterAndTabKey(change);
         break;
       }
 
@@ -355,39 +379,33 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
 
   resetTypeahead = () => {
     if (this.mounted) {
-      this.setState({
-        suggestions: [],
-        typeaheadIndex: 0,
-        typeaheadPrefix: '',
-        typeaheadContext: null,
-      });
+      this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null });
       this.resetTimer = null;
     }
   };
 
-  handleBlur = () => {
-    const { onBlur } = this.props;
+  handleBlur = (event, change) => {
+    const { lastExecutedValue } = this.state;
+    const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
+    const currentValue = Plain.serialize(change.value);
+
     // If we dont wait here, menu clicks wont work because the menu
     // will be gone.
     this.resetTimer = setTimeout(this.resetTypeahead, 100);
     // Disrupting placeholder entry wipes all remaining placeholders needing input
     this.placeholdersBuffer.clearPlaceholders();
-    if (onBlur) {
-      onBlur();
-    }
-  };
 
-  handleFocus = () => {
-    const { onFocus } = this.props;
-    if (onFocus) {
-      onFocus();
+    if (previousValue !== currentValue) {
+      this.executeOnQueryChangeAndExecuteQueries();
     }
   };
 
+  handleFocus = () => {};
+
   onClickMenu = (item: CompletionItem) => {
     // Manually triggering change
     const change = this.applyTypeahead(this.state.value.change(), item);
-    this.onChange(change);
+    this.onChange(change, true);
   };
 
   updateMenu = () => {
@@ -450,6 +468,14 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
     );
   };
 
+  handlePaste = (event: ClipboardEvent, change: Editor) => {
+    const pastedValue = event.clipboardData.getData('Text');
+    const newValue = change.value.change().insertText(pastedValue);
+    this.onChange(newValue);
+
+    return true;
+  };
+
   render() {
     const { disabled } = this.props;
     const wrapperClassName = classnames('slate-query-field__wrapper', {
@@ -466,6 +492,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
             onKeyDown={this.onKeyDown}
             onChange={this.onChange}
             onFocus={this.handleFocus}
+            onPaste={this.handlePaste}
             placeholder={this.props.placeholder}
             plugins={this.plugins}
             spellCheck={false}

+ 28 - 29
public/app/features/explore/QueryRow.tsx

@@ -9,20 +9,14 @@ import QueryEditor from './QueryEditor';
 import QueryTransactionStatus from './QueryTransactionStatus';
 
 // Actions
-import {
-  addQueryRow,
-  changeQuery,
-  highlightLogsExpression,
-  modifyQueries,
-  removeQueryRow,
-  runQueries,
-} from './state/actions';
+import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/actions';
 
 // Types
 import { StoreState } from 'app/types';
-import { RawTimeRange, DataQuery, QueryHint } from '@grafana/ui';
+import { RawTimeRange, DataQuery, ExploreDataSourceApi, QueryHint, QueryFixAction } from '@grafana/ui';
 import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
 import { Emitter } from 'app/core/utils/emitter';
+import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
 
 function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
   const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
@@ -37,16 +31,16 @@ interface QueryRowProps {
   changeQuery: typeof changeQuery;
   className?: string;
   exploreId: ExploreId;
-  datasourceInstance: any;
-  highlightLogsExpression: typeof highlightLogsExpression;
+  datasourceInstance: ExploreDataSourceApi;
+  highlightLogsExpressionAction: typeof highlightLogsExpressionAction;
   history: HistoryItem[];
   index: number;
-  initialQuery: DataQuery;
+  query: DataQuery;
   modifyQueries: typeof modifyQueries;
   queryTransactions: QueryTransaction[];
   exploreEvents: Emitter;
   range: RawTimeRange;
-  removeQueryRow: typeof removeQueryRow;
+  removeQueryRowAction: typeof removeQueryRowAction;
   runQueries: typeof runQueries;
 }
 
@@ -65,6 +59,10 @@ export class QueryRow extends PureComponent<QueryRowProps> {
     }
   };
 
+  componentWillUnmount() {
+    console.log('QueryRow will unmount');
+  }
+
   onClickAddButton = () => {
     const { exploreId, index } = this.props;
     this.props.addQueryRow(exploreId, index);
@@ -74,29 +72,30 @@ export class QueryRow extends PureComponent<QueryRowProps> {
     this.onChangeQuery(null, true);
   };
 
-  onClickHintFix = action => {
+  onClickHintFix = (action: QueryFixAction) => {
     const { datasourceInstance, exploreId, index } = this.props;
     if (datasourceInstance && datasourceInstance.modifyQuery) {
-      const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action);
+      const modifier = (queries: DataQuery, action: QueryFixAction) => datasourceInstance.modifyQuery(queries, action);
       this.props.modifyQueries(exploreId, action, index, modifier);
     }
   };
 
   onClickRemoveButton = () => {
     const { exploreId, index } = this.props;
-    this.props.removeQueryRow(exploreId, index);
+    this.props.removeQueryRowAction({ exploreId, index });
   };
 
   updateLogsHighlights = _.debounce((value: DataQuery) => {
     const { datasourceInstance } = this.props;
     if (datasourceInstance.getHighlighterExpression) {
+      const { exploreId } = this.props;
       const expressions = [datasourceInstance.getHighlighterExpression(value)];
-      this.props.highlightLogsExpression(this.props.exploreId, expressions);
+      this.props.highlightLogsExpressionAction({ exploreId, expressions });
     }
   }, 500);
 
   render() {
-    const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props;
+    const { datasourceInstance, history, index, query, queryTransactions, exploreEvents, range } = this.props;
     const transactions = queryTransactions.filter(t => t.rowIndex === index);
     const transactionWithError = transactions.find(t => t.error !== undefined);
     const hint = getFirstHintFromTransactions(transactions);
@@ -107,16 +106,16 @@ export class QueryRow extends PureComponent<QueryRowProps> {
         <div className="query-row-status">
           <QueryTransactionStatus transactions={transactions} />
         </div>
-        <div className="query-row-field">
+        <div className="query-row-field flex-shrink-1">
           {QueryField ? (
             <QueryField
               datasource={datasourceInstance}
+              query={query}
               error={queryError}
               hint={hint}
-              initialQuery={initialQuery}
               history={history}
-              onClickHintFix={this.onClickHintFix}
-              onPressEnter={this.onExecuteQuery}
+              onExecuteQuery={this.onExecuteQuery}
+              onExecuteHint={this.onClickHintFix}
               onQueryChange={this.onChangeQuery}
             />
           ) : (
@@ -125,13 +124,13 @@ export class QueryRow extends PureComponent<QueryRowProps> {
               error={queryError}
               onQueryChange={this.onChangeQuery}
               onExecuteQuery={this.onExecuteQuery}
-              initialQuery={initialQuery}
+              initialQuery={query}
               exploreEvents={exploreEvents}
               range={range}
             />
           )}
         </div>
-        <div className="gf-form-inline">
+        <div className="gf-form-inline flex-shrink-0">
           <div className="gf-form">
             <button className="gf-form-label gf-form-label--btn" onClick={this.onClickClearButton}>
               <i className="fa fa-times" />
@@ -156,17 +155,17 @@ export class QueryRow extends PureComponent<QueryRowProps> {
 function mapStateToProps(state: StoreState, { exploreId, index }) {
   const explore = state.explore;
   const item: ExploreItemState = explore[exploreId];
-  const { datasourceInstance, history, initialQueries, queryTransactions, range } = item;
-  const initialQuery = initialQueries[index];
-  return { datasourceInstance, history, initialQuery, queryTransactions, range };
+  const { datasourceInstance, history, queries, queryTransactions, range } = item;
+  const query = queries[index];
+  return { datasourceInstance, history, query, queryTransactions, range };
 }
 
 const mapDispatchToProps = {
   addQueryRow,
   changeQuery,
-  highlightLogsExpression,
+  highlightLogsExpressionAction,
   modifyQueries,
-  removeQueryRow,
+  removeQueryRowAction,
   runQueries,
 };
 

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

@@ -6,25 +6,23 @@ import QueryRow from './QueryRow';
 
 // Types
 import { Emitter } from 'app/core/utils/emitter';
-import { DataQuery } from '@grafana/ui/src/types';
 import { ExploreId } from 'app/types/explore';
 
 interface QueryRowsProps {
   className?: string;
   exploreEvents: Emitter;
   exploreId: ExploreId;
-  initialQueries: DataQuery[];
+  queryKeys: string[];
 }
 
 export default class QueryRows extends PureComponent<QueryRowsProps> {
   render() {
-    const { className = '', exploreEvents, exploreId, initialQueries } = this.props;
+    const { className = '', exploreEvents, exploreId, queryKeys } = this.props;
     return (
       <div className={className}>
-        {initialQueries.map((query, index) => (
-          // TODO instead of relying on initialQueries, move to react key list in redux
-          <QueryRow key={query.key} exploreEvents={exploreEvents} exploreId={exploreId} index={index} />
-        ))}
+        {queryKeys.map((key, index) => {
+          return <QueryRow key={key} exploreEvents={exploreEvents} exploreId={exploreId} index={index} />;
+        })}
       </div>
     );
   }

+ 7 - 7
public/app/features/explore/Wrapper.tsx

@@ -7,16 +7,16 @@ import { StoreState } from 'app/types';
 import { ExploreId, ExploreUrlState } from 'app/types/explore';
 import { parseUrlState } from 'app/core/utils/explore';
 
-import { initializeExploreSplit, resetExplore } from './state/actions';
 import ErrorBoundary from './ErrorBoundary';
 import Explore from './Explore';
 import { CustomScrollbar } from '@grafana/ui';
+import { initializeExploreSplitAction, resetExploreAction } from './state/actionTypes';
 
 interface WrapperProps {
-  initializeExploreSplit: typeof initializeExploreSplit;
+  initializeExploreSplitAction: typeof initializeExploreSplitAction;
   split: boolean;
   updateLocation: typeof updateLocation;
-  resetExplore: typeof resetExplore;
+  resetExploreAction: typeof resetExploreAction;
   urlStates: { [key: string]: string };
 }
 
@@ -39,12 +39,12 @@ export class Wrapper extends Component<WrapperProps> {
 
   componentDidMount() {
     if (this.initialSplit) {
-      this.props.initializeExploreSplit();
+      this.props.initializeExploreSplitAction();
     }
   }
 
   componentWillUnmount() {
-    this.props.resetExplore();
+    this.props.resetExploreAction();
   }
 
   render() {
@@ -77,9 +77,9 @@ const mapStateToProps = (state: StoreState) => {
 };
 
 const mapDispatchToProps = {
-  initializeExploreSplit,
+  initializeExploreSplitAction,
   updateLocation,
-  resetExplore,
+  resetExploreAction,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper));

+ 344 - 238
public/app/features/explore/state/actionTypes.ts

@@ -1,6 +1,13 @@
 // Types
 import { Emitter } from 'app/core/core';
-import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi } from '@grafana/ui/src/types';
+import {
+  RawTimeRange,
+  TimeRange,
+  DataQuery,
+  DataSourceSelectItem,
+  DataSourceApi,
+  QueryFixAction,
+} from '@grafana/ui/src/types';
 import {
   ExploreId,
   ExploreItemState,
@@ -8,317 +15,416 @@ import {
   RangeScanner,
   ResultType,
   QueryTransaction,
+  ExploreUIState,
 } from 'app/types/explore';
+import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
 
+/**  Higher order actions
+ *
+ */
 export enum ActionTypes {
-  AddQueryRow = 'explore/ADD_QUERY_ROW',
-  ChangeDatasource = 'explore/CHANGE_DATASOURCE',
-  ChangeQuery = 'explore/CHANGE_QUERY',
-  ChangeSize = 'explore/CHANGE_SIZE',
-  ChangeTime = 'explore/CHANGE_TIME',
-  ClearQueries = 'explore/CLEAR_QUERIES',
-  HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION',
-  InitializeExplore = 'explore/INITIALIZE_EXPLORE',
   InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT',
-  LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE',
-  LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING',
-  LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING',
-  LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS',
-  ModifyQueries = 'explore/MODIFY_QUERIES',
-  QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE',
-  QueryTransactionStart = 'explore/QUERY_TRANSACTION_START',
-  QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS',
-  RemoveQueryRow = 'explore/REMOVE_QUERY_ROW',
-  RunQueries = 'explore/RUN_QUERIES',
-  RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY',
-  ScanRange = 'explore/SCAN_RANGE',
-  ScanStart = 'explore/SCAN_START',
-  ScanStop = 'explore/SCAN_STOP',
-  SetQueries = 'explore/SET_QUERIES',
   SplitClose = 'explore/SPLIT_CLOSE',
   SplitOpen = 'explore/SPLIT_OPEN',
-  StateSave = 'explore/STATE_SAVE',
-  ToggleGraph = 'explore/TOGGLE_GRAPH',
-  ToggleLogs = 'explore/TOGGLE_LOGS',
-  ToggleTable = 'explore/TOGGLE_TABLE',
-  UpdateDatasourceInstance = 'explore/UPDATE_DATASOURCE_INSTANCE',
   ResetExplore = 'explore/RESET_EXPLORE',
-  QueriesImported = 'explore/QueriesImported',
 }
 
-export interface AddQueryRowAction {
-  type: ActionTypes.AddQueryRow;
-  payload: {
-    exploreId: ExploreId;
-    index: number;
-    query: DataQuery;
-  };
+export interface InitializeExploreSplitAction {
+  type: ActionTypes.InitializeExploreSplit;
+  payload: {};
 }
 
-export interface ChangeQueryAction {
-  type: ActionTypes.ChangeQuery;
-  payload: {
-    exploreId: ExploreId;
-    query: DataQuery;
-    index: number;
-    override: boolean;
-  };
+export interface SplitCloseAction {
+  type: ActionTypes.SplitClose;
+  payload: {};
 }
 
-export interface ChangeSizeAction {
-  type: ActionTypes.ChangeSize;
+export interface SplitOpenAction {
+  type: ActionTypes.SplitOpen;
   payload: {
-    exploreId: ExploreId;
-    width: number;
-    height: number;
+    itemState: ExploreItemState;
   };
 }
 
-export interface ChangeTimeAction {
-  type: ActionTypes.ChangeTime;
-  payload: {
-    exploreId: ExploreId;
-    range: TimeRange;
-  };
+export interface ResetExploreAction {
+  type: ActionTypes.ResetExplore;
+  payload: {};
 }
 
-export interface ClearQueriesAction {
-  type: ActionTypes.ClearQueries;
-  payload: {
-    exploreId: ExploreId;
-  };
+/**  Lower order actions
+ *
+ */
+export interface AddQueryRowPayload {
+  exploreId: ExploreId;
+  index: number;
+  query: DataQuery;
 }
 
-export interface HighlightLogsExpressionAction {
-  type: ActionTypes.HighlightLogsExpression;
-  payload: {
-    exploreId: ExploreId;
-    expressions: string[];
-  };
+export interface ChangeQueryPayload {
+  exploreId: ExploreId;
+  query: DataQuery;
+  index: number;
+  override: boolean;
 }
 
-export interface InitializeExploreAction {
-  type: ActionTypes.InitializeExplore;
-  payload: {
-    exploreId: ExploreId;
-    containerWidth: number;
-    eventBridge: Emitter;
-    exploreDatasources: DataSourceSelectItem[];
-    queries: DataQuery[];
-    range: RawTimeRange;
-  };
+export interface ChangeSizePayload {
+  exploreId: ExploreId;
+  width: number;
+  height: number;
 }
 
-export interface InitializeExploreSplitAction {
-  type: ActionTypes.InitializeExploreSplit;
+export interface ChangeTimePayload {
+  exploreId: ExploreId;
+  range: TimeRange;
 }
 
-export interface LoadDatasourceFailureAction {
-  type: ActionTypes.LoadDatasourceFailure;
-  payload: {
-    exploreId: ExploreId;
-    error: string;
-  };
+export interface ClearQueriesPayload {
+  exploreId: ExploreId;
 }
 
-export interface LoadDatasourcePendingAction {
-  type: ActionTypes.LoadDatasourcePending;
-  payload: {
-    exploreId: ExploreId;
-    requestedDatasourceName: string;
-  };
+export interface HighlightLogsExpressionPayload {
+  exploreId: ExploreId;
+  expressions: string[];
 }
 
-export interface LoadDatasourceMissingAction {
-  type: ActionTypes.LoadDatasourceMissing;
-  payload: {
-    exploreId: ExploreId;
-  };
+export interface InitializeExplorePayload {
+  exploreId: ExploreId;
+  containerWidth: number;
+  eventBridge: Emitter;
+  exploreDatasources: DataSourceSelectItem[];
+  queries: DataQuery[];
+  range: RawTimeRange;
+  ui: ExploreUIState;
 }
 
-export interface LoadDatasourceSuccessAction {
-  type: ActionTypes.LoadDatasourceSuccess;
-  payload: {
-    exploreId: ExploreId;
-    StartPage?: any;
-    datasourceInstance: any;
-    history: HistoryItem[];
-    logsHighlighterExpressions?: any[];
-    showingStartPage: boolean;
-    supportsGraph: boolean;
-    supportsLogs: boolean;
-    supportsTable: boolean;
-  };
+export interface LoadDatasourceFailurePayload {
+  exploreId: ExploreId;
+  error: string;
 }
 
-export interface ModifyQueriesAction {
-  type: ActionTypes.ModifyQueries;
-  payload: {
-    exploreId: ExploreId;
-    modification: any;
-    index: number;
-    modifier: (queries: DataQuery[], modification: any) => DataQuery[];
-  };
+export interface LoadDatasourceMissingPayload {
+  exploreId: ExploreId;
 }
 
-export interface QueryTransactionFailureAction {
-  type: ActionTypes.QueryTransactionFailure;
-  payload: {
-    exploreId: ExploreId;
-    queryTransactions: QueryTransaction[];
-  };
+export interface LoadDatasourcePendingPayload {
+  exploreId: ExploreId;
+  requestedDatasourceName: string;
 }
 
-export interface QueryTransactionStartAction {
-  type: ActionTypes.QueryTransactionStart;
-  payload: {
-    exploreId: ExploreId;
-    resultType: ResultType;
-    rowIndex: number;
-    transaction: QueryTransaction;
-  };
+export interface LoadDatasourceSuccessPayload {
+  exploreId: ExploreId;
+  StartPage?: any;
+  datasourceInstance: any;
+  history: HistoryItem[];
+  logsHighlighterExpressions?: any[];
+  showingStartPage: boolean;
+  supportsGraph: boolean;
+  supportsLogs: boolean;
+  supportsTable: boolean;
 }
 
-export interface QueryTransactionSuccessAction {
-  type: ActionTypes.QueryTransactionSuccess;
-  payload: {
-    exploreId: ExploreId;
-    history: HistoryItem[];
-    queryTransactions: QueryTransaction[];
-  };
+export interface ModifyQueriesPayload {
+  exploreId: ExploreId;
+  modification: QueryFixAction;
+  index: number;
+  modifier: (query: DataQuery, modification: QueryFixAction) => DataQuery;
 }
 
-export interface RemoveQueryRowAction {
-  type: ActionTypes.RemoveQueryRow;
-  payload: {
-    exploreId: ExploreId;
-    index: number;
-  };
+export interface QueryTransactionFailurePayload {
+  exploreId: ExploreId;
+  queryTransactions: QueryTransaction[];
 }
 
-export interface RunQueriesEmptyAction {
-  type: ActionTypes.RunQueriesEmpty;
-  payload: {
-    exploreId: ExploreId;
-  };
+export interface QueryTransactionStartPayload {
+  exploreId: ExploreId;
+  resultType: ResultType;
+  rowIndex: number;
+  transaction: QueryTransaction;
 }
 
-export interface ScanStartAction {
-  type: ActionTypes.ScanStart;
-  payload: {
-    exploreId: ExploreId;
-    scanner: RangeScanner;
-  };
+export interface QueryTransactionSuccessPayload {
+  exploreId: ExploreId;
+  history: HistoryItem[];
+  queryTransactions: QueryTransaction[];
 }
 
-export interface ScanRangeAction {
-  type: ActionTypes.ScanRange;
-  payload: {
-    exploreId: ExploreId;
-    range: RawTimeRange;
-  };
+export interface RemoveQueryRowPayload {
+  exploreId: ExploreId;
+  index: number;
 }
 
-export interface ScanStopAction {
-  type: ActionTypes.ScanStop;
-  payload: {
-    exploreId: ExploreId;
-  };
+export interface RunQueriesEmptyPayload {
+  exploreId: ExploreId;
 }
 
-export interface SetQueriesAction {
-  type: ActionTypes.SetQueries;
-  payload: {
-    exploreId: ExploreId;
-    queries: DataQuery[];
-  };
+export interface ScanStartPayload {
+  exploreId: ExploreId;
+  scanner: RangeScanner;
 }
 
-export interface SplitCloseAction {
-  type: ActionTypes.SplitClose;
+export interface ScanRangePayload {
+  exploreId: ExploreId;
+  range: RawTimeRange;
 }
 
-export interface SplitOpenAction {
-  type: ActionTypes.SplitOpen;
-  payload: {
-    itemState: ExploreItemState;
-  };
+export interface ScanStopPayload {
+  exploreId: ExploreId;
 }
 
-export interface StateSaveAction {
-  type: ActionTypes.StateSave;
+export interface SetQueriesPayload {
+  exploreId: ExploreId;
+  queries: DataQuery[];
 }
 
-export interface ToggleTableAction {
-  type: ActionTypes.ToggleTable;
-  payload: {
-    exploreId: ExploreId;
-  };
+export interface SplitOpenPayload {
+  itemState: ExploreItemState;
 }
 
-export interface ToggleGraphAction {
-  type: ActionTypes.ToggleGraph;
-  payload: {
-    exploreId: ExploreId;
-  };
+export interface ToggleTablePayload {
+  exploreId: ExploreId;
 }
 
-export interface ToggleLogsAction {
-  type: ActionTypes.ToggleLogs;
-  payload: {
-    exploreId: ExploreId;
-  };
+export interface ToggleGraphPayload {
+  exploreId: ExploreId;
 }
 
-export interface UpdateDatasourceInstanceAction {
-  type: ActionTypes.UpdateDatasourceInstance;
-  payload: {
-    exploreId: ExploreId;
-    datasourceInstance: DataSourceApi;
-  };
+export interface ToggleLogsPayload {
+  exploreId: ExploreId;
 }
 
-export interface ResetExploreAction {
-  type: ActionTypes.ResetExplore;
-  payload: {};
+export interface UpdateDatasourceInstancePayload {
+  exploreId: ExploreId;
+  datasourceInstance: DataSourceApi;
 }
 
-export interface QueriesImported {
-  type: ActionTypes.QueriesImported;
-  payload: {
-    exploreId: ExploreId;
-    queries: DataQuery[];
-  };
+export interface QueriesImportedPayload {
+  exploreId: ExploreId;
+  queries: DataQuery[];
 }
 
-export type Action =
-  | AddQueryRowAction
-  | ChangeQueryAction
-  | ChangeSizeAction
-  | ChangeTimeAction
-  | ClearQueriesAction
-  | HighlightLogsExpressionAction
-  | InitializeExploreAction
+/**
+ * Adds a query row after the row with the given index.
+ */
+export const addQueryRowAction = actionCreatorFactory<AddQueryRowPayload>('explore/ADD_QUERY_ROW').create();
+
+/**
+ * Loads a new datasource identified by the given name.
+ */
+export const changeDatasourceAction = noPayloadActionCreatorFactory('explore/CHANGE_DATASOURCE').create();
+
+/**
+ * Query change handler for the query row with the given index.
+ * If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
+ */
+export const changeQueryAction = actionCreatorFactory<ChangeQueryPayload>('explore/CHANGE_QUERY').create();
+
+/**
+ * Keep track of the Explore container size, in particular the width.
+ * The width will be used to calculate graph intervals (number of datapoints).
+ */
+export const changeSizeAction = actionCreatorFactory<ChangeSizePayload>('explore/CHANGE_SIZE').create();
+
+/**
+ * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
+ */
+export const changeTimeAction = actionCreatorFactory<ChangeTimePayload>('explore/CHANGE_TIME').create();
+
+/**
+ * Clear all queries and results.
+ */
+export const clearQueriesAction = actionCreatorFactory<ClearQueriesPayload>('explore/CLEAR_QUERIES').create();
+
+/**
+ * Highlight expressions in the log results
+ */
+export const highlightLogsExpressionAction = actionCreatorFactory<HighlightLogsExpressionPayload>(
+  'explore/HIGHLIGHT_LOGS_EXPRESSION'
+).create();
+
+/**
+ * Initialize Explore state with state from the URL and the React component.
+ * Call this only on components for with the Explore state has not been initialized.
+ */
+export const initializeExploreAction = actionCreatorFactory<InitializeExplorePayload>(
+  'explore/INITIALIZE_EXPLORE'
+).create();
+
+/**
+ * Initialize the wrapper split state
+ */
+export const initializeExploreSplitAction = noPayloadActionCreatorFactory('explore/INITIALIZE_EXPLORE_SPLIT').create();
+
+/**
+ * Display an error that happened during the selection of a datasource
+ */
+export const loadDatasourceFailureAction = actionCreatorFactory<LoadDatasourceFailurePayload>(
+  'explore/LOAD_DATASOURCE_FAILURE'
+).create();
+
+/**
+ * Display an error when no datasources have been configured
+ */
+export const loadDatasourceMissingAction = actionCreatorFactory<LoadDatasourceMissingPayload>(
+  'explore/LOAD_DATASOURCE_MISSING'
+).create();
+
+/**
+ * Start the async process of loading a datasource to display a loading indicator
+ */
+export const loadDatasourcePendingAction = actionCreatorFactory<LoadDatasourcePendingPayload>(
+  'explore/LOAD_DATASOURCE_PENDING'
+).create();
+
+/**
+ * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
+ * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
+ * e.g., Prometheus -> Loki queries.
+ */
+export const loadDatasourceSuccessAction = actionCreatorFactory<LoadDatasourceSuccessPayload>(
+  'explore/LOAD_DATASOURCE_SUCCESS'
+).create();
+
+/**
+ * Action to modify a query given a datasource-specific modifier action.
+ * @param exploreId Explore area
+ * @param modification Action object with a type, e.g., ADD_FILTER
+ * @param index Optional query row index. If omitted, the modification is applied to all query rows.
+ * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
+ */
+export const modifyQueriesAction = actionCreatorFactory<ModifyQueriesPayload>('explore/MODIFY_QUERIES').create();
+
+/**
+ * Mark a query transaction as failed with an error extracted from the query response.
+ * The transaction will be marked as `done`.
+ */
+export const queryTransactionFailureAction = actionCreatorFactory<QueryTransactionFailurePayload>(
+  'explore/QUERY_TRANSACTION_FAILURE'
+).create();
+
+/**
+ * Start a query transaction for the given result type.
+ * @param exploreId Explore area
+ * @param transaction Query options and `done` status.
+ * @param resultType Associate the transaction with a result viewer, e.g., Graph
+ * @param rowIndex Index is used to associate latency for this transaction with a query row
+ */
+export const queryTransactionStartAction = actionCreatorFactory<QueryTransactionStartPayload>(
+  'explore/QUERY_TRANSACTION_START'
+).create();
+
+/**
+ * Complete a query transaction, mark the transaction as `done` and store query state in URL.
+ * If the transaction was started by a scanner, it keeps on scanning for more results.
+ * Side-effect: the query is stored in localStorage.
+ * @param exploreId Explore area
+ * @param transactionId ID
+ * @param result Response from `datasourceInstance.query()`
+ * @param latency Duration between request and response
+ * @param queries Queries from all query rows
+ * @param datasourceId Origin datasource instance, used to discard results if current datasource is different
+ */
+export const queryTransactionSuccessAction = actionCreatorFactory<QueryTransactionSuccessPayload>(
+  'explore/QUERY_TRANSACTION_SUCCESS'
+).create();
+
+/**
+ * Remove query row of the given index, as well as associated query results.
+ */
+export const removeQueryRowAction = actionCreatorFactory<RemoveQueryRowPayload>('explore/REMOVE_QUERY_ROW').create();
+export const runQueriesAction = noPayloadActionCreatorFactory('explore/RUN_QUERIES').create();
+export const runQueriesEmptyAction = actionCreatorFactory<RunQueriesEmptyPayload>('explore/RUN_QUERIES_EMPTY').create();
+
+/**
+ * Start a scan for more results using the given scanner.
+ * @param exploreId Explore area
+ * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
+ */
+export const scanStartAction = actionCreatorFactory<ScanStartPayload>('explore/SCAN_START').create();
+export const scanRangeAction = actionCreatorFactory<ScanRangePayload>('explore/SCAN_RANGE').create();
+
+/**
+ * Stop any scanning for more results.
+ */
+export const scanStopAction = actionCreatorFactory<ScanStopPayload>('explore/SCAN_STOP').create();
+
+/**
+ * Reset queries to the given queries. Any modifications will be discarded.
+ * Use this action for clicks on query examples. Triggers a query run.
+ */
+export const setQueriesAction = actionCreatorFactory<SetQueriesPayload>('explore/SET_QUERIES').create();
+
+/**
+ * Close the split view and save URL state.
+ */
+export const splitCloseAction = noPayloadActionCreatorFactory('explore/SPLIT_CLOSE').create();
+
+/**
+ * Open the split view and copy the left state to be the right state.
+ * The right state is automatically initialized.
+ * The copy keeps all query modifications but wipes the query results.
+ */
+export const splitOpenAction = actionCreatorFactory<SplitOpenPayload>('explore/SPLIT_OPEN').create();
+export const stateSaveAction = noPayloadActionCreatorFactory('explore/STATE_SAVE').create();
+
+/**
+ * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
+ */
+export const toggleTableAction = actionCreatorFactory<ToggleTablePayload>('explore/TOGGLE_TABLE').create();
+
+/**
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ */
+export const toggleGraphAction = actionCreatorFactory<ToggleGraphPayload>('explore/TOGGLE_GRAPH').create();
+
+/**
+ * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
+ */
+export const toggleLogsAction = actionCreatorFactory<ToggleLogsPayload>('explore/TOGGLE_LOGS').create();
+
+/**
+ * Updates datasource instance before datasouce loading has started
+ */
+export const updateDatasourceInstanceAction = actionCreatorFactory<UpdateDatasourceInstancePayload>(
+  'explore/UPDATE_DATASOURCE_INSTANCE'
+).create();
+
+/**
+ * Resets state for explore.
+ */
+export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create();
+export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
+
+export type HigherOrderAction =
   | InitializeExploreSplitAction
-  | LoadDatasourceFailureAction
-  | LoadDatasourceMissingAction
-  | LoadDatasourcePendingAction
-  | LoadDatasourceSuccessAction
-  | ModifyQueriesAction
-  | QueryTransactionFailureAction
-  | QueryTransactionStartAction
-  | QueryTransactionSuccessAction
-  | RemoveQueryRowAction
-  | RunQueriesEmptyAction
-  | ScanRangeAction
-  | ScanStartAction
-  | ScanStopAction
-  | SetQueriesAction
   | SplitCloseAction
   | SplitOpenAction
-  | ToggleGraphAction
-  | ToggleLogsAction
-  | ToggleTableAction
-  | UpdateDatasourceInstanceAction
   | ResetExploreAction
-  | QueriesImported;
+  | ActionOf<any>;
+
+export type Action =
+  | ActionOf<AddQueryRowPayload>
+  | ActionOf<ChangeQueryPayload>
+  | ActionOf<ChangeSizePayload>
+  | ActionOf<ChangeTimePayload>
+  | ActionOf<ClearQueriesPayload>
+  | ActionOf<HighlightLogsExpressionPayload>
+  | ActionOf<InitializeExplorePayload>
+  | ActionOf<LoadDatasourceFailurePayload>
+  | ActionOf<LoadDatasourceMissingPayload>
+  | ActionOf<LoadDatasourcePendingPayload>
+  | ActionOf<LoadDatasourceSuccessPayload>
+  | ActionOf<ModifyQueriesPayload>
+  | ActionOf<QueryTransactionFailurePayload>
+  | ActionOf<QueryTransactionStartPayload>
+  | ActionOf<QueryTransactionSuccessPayload>
+  | ActionOf<RemoveQueryRowPayload>
+  | ActionOf<RunQueriesEmptyPayload>
+  | ActionOf<ScanStartPayload>
+  | ActionOf<ScanRangePayload>
+  | ActionOf<SetQueriesPayload>
+  | ActionOf<SplitOpenPayload>
+  | ActionOf<ToggleTablePayload>
+  | ActionOf<ToggleGraphPayload>
+  | ActionOf<ToggleLogsPayload>
+  | ActionOf<UpdateDatasourceInstancePayload>
+  | ActionOf<QueriesImportedPayload>;

+ 162 - 252
public/app/features/explore/state/actions.ts

@@ -30,40 +30,54 @@ import {
   DataQuery,
   DataSourceSelectItem,
   QueryHint,
+  QueryFixAction,
 } from '@grafana/ui/src/types';
+import { ExploreId, ExploreUrlState, RangeScanner, ResultType, QueryOptions, ExploreUIState } from 'app/types/explore';
 import {
-  ExploreId,
-  ExploreUrlState,
-  RangeScanner,
-  ResultType,
-  QueryOptions,
-  QueryTransaction,
-} from 'app/types/explore';
-
-import {
-  Action as ThunkableAction,
-  ActionTypes,
-  AddQueryRowAction,
-  ChangeSizeAction,
-  HighlightLogsExpressionAction,
-  LoadDatasourceFailureAction,
-  LoadDatasourceMissingAction,
-  LoadDatasourcePendingAction,
-  LoadDatasourceSuccessAction,
-  QueryTransactionStartAction,
-  ScanStopAction,
-  UpdateDatasourceInstanceAction,
-  QueriesImported,
+  Action,
+  updateDatasourceInstanceAction,
+  changeQueryAction,
+  changeSizeAction,
+  ChangeSizePayload,
+  changeTimeAction,
+  scanStopAction,
+  clearQueriesAction,
+  initializeExploreAction,
+  loadDatasourceMissingAction,
+  loadDatasourceFailureAction,
+  loadDatasourcePendingAction,
+  queriesImportedAction,
+  LoadDatasourceSuccessPayload,
+  loadDatasourceSuccessAction,
+  modifyQueriesAction,
+  queryTransactionFailureAction,
+  queryTransactionStartAction,
+  queryTransactionSuccessAction,
+  scanRangeAction,
+  runQueriesEmptyAction,
+  scanStartAction,
+  setQueriesAction,
+  splitCloseAction,
+  splitOpenAction,
+  addQueryRowAction,
+  AddQueryRowPayload,
+  toggleGraphAction,
+  toggleLogsAction,
+  toggleTableAction,
+  ToggleGraphPayload,
+  ToggleLogsPayload,
+  ToggleTablePayload,
 } from './actionTypes';
+import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 
-type ThunkResult<R> = ThunkAction<R, StoreState, undefined, ThunkableAction>;
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
-/**
- * Adds a query row after the row with the given index.
- */
-export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction {
+// /**
+//  * Adds a query row after the row with the given index.
+//  */
+export function addQueryRow(exploreId: ExploreId, index: number): ActionOf<AddQueryRowPayload> {
   const query = generateEmptyQuery(index + 1);
-  return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } };
+  return addQueryRowAction({ exploreId, index, query });
 }
 
 /**
@@ -73,12 +87,20 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun
   return async (dispatch, getState) => {
     const newDataSourceInstance = await getDatasourceSrv().get(datasource);
     const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance;
-    const modifiedQueries = getState().explore[exploreId].modifiedQueries;
+    const queries = getState().explore[exploreId].queries;
 
-    await dispatch(importQueries(exploreId, modifiedQueries, currentDataSourceInstance, newDataSourceInstance));
+    await dispatch(importQueries(exploreId, queries, currentDataSourceInstance, newDataSourceInstance));
 
-    dispatch(updateDatasourceInstance(exploreId, newDataSourceInstance));
-    dispatch(loadDatasource(exploreId, newDataSourceInstance));
+    dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: newDataSourceInstance }));
+
+    try {
+      await dispatch(loadDatasource(exploreId, newDataSourceInstance));
+    } catch (error) {
+      console.error(error);
+      return;
+    }
+
+    dispatch(runQueries(exploreId));
   };
 }
 
@@ -98,7 +120,7 @@ export function changeQuery(
       query = { ...generateEmptyQuery(index) };
     }
 
-    dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } });
+    dispatch(changeQueryAction({ exploreId, query, index, override }));
     if (override) {
       dispatch(runQueries(exploreId));
     }
@@ -112,8 +134,8 @@ export function changeQuery(
 export function changeSize(
   exploreId: ExploreId,
   { height, width }: { height: number; width: number }
-): ChangeSizeAction {
-  return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } };
+): ActionOf<ChangeSizePayload> {
+  return changeSizeAction({ exploreId, height, width });
 }
 
 /**
@@ -121,7 +143,7 @@ export function changeSize(
  */
 export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
   return dispatch => {
-    dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } });
+    dispatch(changeTimeAction({ exploreId, range }));
     dispatch(runQueries(exploreId));
   };
 }
@@ -131,19 +153,12 @@ export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<
  */
 export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
   return dispatch => {
-    dispatch(scanStop(exploreId));
-    dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } });
+    dispatch(scanStopAction({ exploreId }));
+    dispatch(clearQueriesAction({ exploreId }));
     dispatch(stateSave());
   };
 }
 
-/**
- * Highlight expressions in the log results
- */
-export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction {
-  return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } };
-}
-
 /**
  * Initialize Explore state with state from the URL and the React component.
  * Call this only on components for with the Explore state has not been initialized.
@@ -154,7 +169,8 @@ export function initializeExplore(
   queries: DataQuery[],
   range: RawTimeRange,
   containerWidth: number,
-  eventBridge: Emitter
+  eventBridge: Emitter,
+  ui: ExploreUIState
 ): ThunkResult<void> {
   return async dispatch => {
     const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
@@ -165,18 +181,17 @@ export function initializeExplore(
         meta: ds.meta,
       }));
 
-    dispatch({
-      type: ActionTypes.InitializeExplore,
-      payload: {
+    dispatch(
+      initializeExploreAction({
         exploreId,
         containerWidth,
-        datasourceName,
         eventBridge,
         exploreDatasources,
         queries,
         range,
-      },
-    });
+        ui,
+      })
+    );
 
     if (exploreDatasources.length >= 1) {
       let instance;
@@ -193,75 +208,27 @@ export function initializeExplore(
         instance = await getDatasourceSrv().get();
       }
 
-      dispatch(updateDatasourceInstance(exploreId, instance));
-      dispatch(loadDatasource(exploreId, instance));
+      dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: instance }));
+
+      try {
+        await dispatch(loadDatasource(exploreId, instance));
+      } catch (error) {
+        console.error(error);
+        return;
+      }
+      dispatch(runQueries(exploreId, true));
     } else {
-      dispatch(loadDatasourceMissing(exploreId));
+      dispatch(loadDatasourceMissingAction({ exploreId }));
     }
   };
 }
 
-/**
- * Initialize the wrapper split state
- */
-export function initializeExploreSplit() {
-  return async dispatch => {
-    dispatch({ type: ActionTypes.InitializeExploreSplit });
-  };
-}
-
-/**
- * Display an error that happened during the selection of a datasource
- */
-export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({
-  type: ActionTypes.LoadDatasourceFailure,
-  payload: {
-    exploreId,
-    error,
-  },
-});
-
-/**
- * Display an error when no datasources have been configured
- */
-export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({
-  type: ActionTypes.LoadDatasourceMissing,
-  payload: { exploreId },
-});
-
-/**
- * Start the async process of loading a datasource to display a loading indicator
- */
-export const loadDatasourcePending = (
-  exploreId: ExploreId,
-  requestedDatasourceName: string
-): LoadDatasourcePendingAction => ({
-  type: ActionTypes.LoadDatasourcePending,
-  payload: {
-    exploreId,
-    requestedDatasourceName,
-  },
-});
-
-export const queriesImported = (exploreId: ExploreId, queries: DataQuery[]): QueriesImported => {
-  return {
-    type: ActionTypes.QueriesImported,
-    payload: {
-      exploreId,
-      queries,
-    },
-  };
-};
-
 /**
  * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
  * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
  * e.g., Prometheus -> Loki queries.
  */
-export const loadDatasourceSuccess = (
-  exploreId: ExploreId,
-  instance: any,
-): LoadDatasourceSuccessAction => {
+export const loadDatasourceSuccess = (exploreId: ExploreId, instance: any): ActionOf<LoadDatasourceSuccessPayload> => {
   // Capabilities
   const supportsGraph = instance.meta.metrics;
   const supportsLogs = instance.meta.logs;
@@ -274,37 +241,18 @@ export const loadDatasourceSuccess = (
   // Save last-used datasource
   store.set(LAST_USED_DATASOURCE_KEY, instance.name);
 
-  return {
-    type: ActionTypes.LoadDatasourceSuccess,
-    payload: {
-      exploreId,
-      StartPage,
-      datasourceInstance: instance,
-      history,
-      showingStartPage: Boolean(StartPage),
-      supportsGraph,
-      supportsLogs,
-      supportsTable,
-    },
-  };
+  return loadDatasourceSuccessAction({
+    exploreId,
+    StartPage,
+    datasourceInstance: instance,
+    history,
+    showingStartPage: Boolean(StartPage),
+    supportsGraph,
+    supportsLogs,
+    supportsTable,
+  });
 };
 
-/**
- * Updates datasource instance before datasouce loading has started
- */
-export function updateDatasourceInstance(
-  exploreId: ExploreId,
-  instance: DataSourceApi
-): UpdateDatasourceInstanceAction {
-  return {
-    type: ActionTypes.UpdateDatasourceInstance,
-    payload: {
-      exploreId,
-      datasourceInstance: instance,
-    },
-  };
-}
-
 export function importQueries(
   exploreId: ExploreId,
   queries: DataQuery[],
@@ -326,11 +274,11 @@ export function importQueries(
     }
 
     const nextQueries = importedQueries.map((q, i) => ({
-      ...importedQueries[i],
+      ...q,
       ...generateEmptyQuery(i),
     }));
 
-    dispatch(queriesImported(exploreId, nextQueries));
+    dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
   };
 }
 
@@ -342,9 +290,9 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
     const datasourceName = instance.name;
 
     // Keep ID to track selection
-    dispatch(loadDatasourcePending(exploreId, datasourceName));
-
+    dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
     let datasourceError = null;
+
     try {
       const testResult = await instance.testDatasource();
       datasourceError = testResult.status === 'success' ? null : testResult.message;
@@ -353,8 +301,8 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
     }
 
     if (datasourceError) {
-      dispatch(loadDatasourceFailure(exploreId, datasourceError));
-      return;
+      dispatch(loadDatasourceFailureAction({ exploreId, error: datasourceError }));
+      return Promise.reject(`${datasourceName} loading failed`);
     }
 
     if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
@@ -372,7 +320,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
     }
 
     dispatch(loadDatasourceSuccess(exploreId, instance));
-    dispatch(runQueries(exploreId));
+    return Promise.resolve();
   };
 }
 
@@ -385,12 +333,12 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
  */
 export function modifyQueries(
   exploreId: ExploreId,
-  modification: any,
+  modification: QueryFixAction,
   index: number,
   modifier: any
 ): ThunkResult<void> {
   return dispatch => {
-    dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } });
+    dispatch(modifyQueriesAction({ exploreId, modification, index, modifier }));
     if (!modification.preventSubmit) {
       dispatch(runQueries(exploreId));
     }
@@ -455,29 +403,10 @@ export function queryTransactionFailure(
       return qt;
     });
 
-    dispatch({
-      type: ActionTypes.QueryTransactionFailure,
-      payload: { exploreId, queryTransactions: nextQueryTransactions },
-    });
+    dispatch(queryTransactionFailureAction({ exploreId, queryTransactions: nextQueryTransactions }));
   };
 }
 
-/**
- * Start a query transaction for the given result type.
- * @param exploreId Explore area
- * @param transaction Query options and `done` status.
- * @param resultType Associate the transaction with a result viewer, e.g., Graph
- * @param rowIndex Index is used to associate latency for this transaction with a query row
- */
-export function queryTransactionStart(
-  exploreId: ExploreId,
-  transaction: QueryTransaction,
-  resultType: ResultType,
-  rowIndex: number
-): QueryTransactionStartAction {
-  return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } };
-}
-
 /**
  * Complete a query transaction, mark the transaction as `done` and store query state in URL.
  * If the transaction was started by a scanner, it keeps on scanning for more results.
@@ -534,14 +463,13 @@ export function queryTransactionSuccess(
     // Side-effect: Saving history in localstorage
     const nextHistory = updateHistory(history, datasourceId, queries);
 
-    dispatch({
-      type: ActionTypes.QueryTransactionSuccess,
-      payload: {
+    dispatch(
+      queryTransactionSuccessAction({
         exploreId,
         history: nextHistory,
         queryTransactions: nextQueryTransactions,
-      },
-    });
+      })
+    );
 
     // Keep scanning for results if this was the last scanning transaction
     if (scanning) {
@@ -549,34 +477,24 @@ export function queryTransactionSuccess(
         const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
         if (!other) {
           const range = scanner();
-          dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+          dispatch(scanRangeAction({ exploreId, range }));
         }
       } else {
         // We can stop scanning if we have a result
-        dispatch(scanStop(exploreId));
+        dispatch(scanStopAction({ exploreId }));
       }
     }
   };
 }
 
-/**
- * Remove query row of the given index, as well as associated query results.
- */
-export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
-  return dispatch => {
-    dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } });
-    dispatch(runQueries(exploreId));
-  };
-}
-
 /**
  * Main action to run queries and dispatches sub-actions based on which result viewers are active
  */
-export function runQueries(exploreId: ExploreId) {
+export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
   return (dispatch, getState) => {
     const {
       datasourceInstance,
-      modifiedQueries,
+      queries,
       showingLogs,
       showingGraph,
       showingTable,
@@ -585,8 +503,8 @@ export function runQueries(exploreId: ExploreId) {
       supportsTable,
     } = getState().explore[exploreId];
 
-    if (!hasNonEmptyQuery(modifiedQueries)) {
-      dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } });
+    if (!hasNonEmptyQuery(queries)) {
+      dispatch(runQueriesEmptyAction({ exploreId }));
       dispatch(stateSave()); // Remember to saves to state and update location
       return;
     }
@@ -596,7 +514,7 @@ export function runQueries(exploreId: ExploreId) {
     const interval = datasourceInstance.interval;
 
     // Keep table queries first since they need to return quickly
-    if (showingTable && supportsTable) {
+    if ((ignoreUIState || showingTable) && supportsTable) {
       dispatch(
         runQueriesForType(
           exploreId,
@@ -611,7 +529,7 @@ export function runQueries(exploreId: ExploreId) {
         )
       );
     }
-    if (showingGraph && supportsGraph) {
+    if ((ignoreUIState || showingGraph) && supportsGraph) {
       dispatch(
         runQueriesForType(
           exploreId,
@@ -625,9 +543,10 @@ export function runQueries(exploreId: ExploreId) {
         )
       );
     }
-    if (showingLogs && supportsLogs) {
+    if ((ignoreUIState || showingLogs) && supportsLogs) {
       dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
     }
+
     dispatch(stateSave());
   };
 }
@@ -646,14 +565,7 @@ function runQueriesForType(
   resultGetter?: any
 ) {
   return async (dispatch, getState) => {
-    const {
-      datasourceInstance,
-      eventBridge,
-      modifiedQueries: queries,
-      queryIntervals,
-      range,
-      scanning,
-    } = getState().explore[exploreId];
+    const { datasourceInstance, eventBridge, queries, queryIntervals, range, scanning } = getState().explore[exploreId];
     const datasourceId = datasourceInstance.meta.id;
 
     // Run all queries concurrently
@@ -667,7 +579,7 @@ function runQueriesForType(
         queryIntervals,
         scanning
       );
-      dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex));
+      dispatch(queryTransactionStartAction({ exploreId, resultType, rowIndex, transaction }));
       try {
         const now = Date.now();
         const res = await datasourceInstance.query(transaction.options);
@@ -691,21 +603,14 @@ function runQueriesForType(
 export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult<void> {
   return dispatch => {
     // Register the scanner
-    dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } });
+    dispatch(scanStartAction({ exploreId, scanner }));
     // Scanning must trigger query run, and return the new range
     const range = scanner();
     // Set the new range to be displayed
-    dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+    dispatch(scanRangeAction({ exploreId, range }));
   };
 }
 
-/**
- * Stop any scanning for more results.
- */
-export function scanStop(exploreId: ExploreId): ScanStopAction {
-  return { type: ActionTypes.ScanStop, payload: { exploreId } };
-}
-
 /**
  * Reset queries to the given queries. Any modifications will be discarded.
  * Use this action for clicks on query examples. Triggers a query run.
@@ -714,13 +619,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk
   return dispatch => {
     // Inject react keys into query objects
     const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
-    dispatch({
-      type: ActionTypes.SetQueries,
-      payload: {
-        exploreId,
-        queries,
-      },
-    });
+    dispatch(setQueriesAction({ exploreId, queries }));
     dispatch(runQueries(exploreId));
   };
 }
@@ -730,7 +629,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk
  */
 export function splitClose(): ThunkResult<void> {
   return dispatch => {
-    dispatch({ type: ActionTypes.SplitClose });
+    dispatch(splitCloseAction());
     dispatch(stateSave());
   };
 }
@@ -747,9 +646,9 @@ export function splitOpen(): ThunkResult<void> {
     const itemState = {
       ...leftState,
       queryTransactions: [],
-      initialQueries: leftState.modifiedQueries.slice(),
+      queries: leftState.queries.slice(),
     };
-    dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } });
+    dispatch(splitOpenAction({ itemState }));
     dispatch(stateSave());
   };
 }
@@ -764,63 +663,74 @@ export function stateSave() {
     const urlStates: { [index: string]: string } = {};
     const leftUrlState: ExploreUrlState = {
       datasource: left.datasourceInstance.name,
-      queries: left.modifiedQueries.map(clearQueryKeys),
+      queries: left.queries.map(clearQueryKeys),
       range: left.range,
+      ui: {
+        showingGraph: left.showingGraph,
+        showingLogs: left.showingLogs,
+        showingTable: left.showingTable,
+      },
     };
     urlStates.left = serializeStateToUrlParam(leftUrlState, true);
     if (split) {
       const rightUrlState: ExploreUrlState = {
         datasource: right.datasourceInstance.name,
-        queries: right.modifiedQueries.map(clearQueryKeys),
+        queries: right.queries.map(clearQueryKeys),
         range: right.range,
+        ui: { showingGraph: right.showingGraph, showingLogs: right.showingLogs, showingTable: right.showingTable },
       };
+
       urlStates.right = serializeStateToUrlParam(rightUrlState, true);
     }
+
     dispatch(updateLocation({ query: urlStates }));
   };
 }
 
 /**
- * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ * Creates action to collapse graph/logs/table panel. When panel is collapsed,
+ * queries won't be run
  */
-export function toggleGraph(exploreId: ExploreId): ThunkResult<void> {
+const togglePanelActionCreator = (
+  actionCreator:
+    | ActionCreator<ToggleGraphPayload>
+    | ActionCreator<ToggleLogsPayload>
+    | ActionCreator<ToggleTablePayload>
+) => (exploreId: ExploreId) => {
   return (dispatch, getState) => {
-    dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
-    if (getState().explore[exploreId].showingGraph) {
+    let shouldRunQueries;
+    dispatch(actionCreator({ exploreId }));
+    dispatch(stateSave());
+
+    switch (actionCreator.type) {
+      case toggleGraphAction.type:
+        shouldRunQueries = getState().explore[exploreId].showingGraph;
+        break;
+      case toggleLogsAction.type:
+        shouldRunQueries = getState().explore[exploreId].showingLogs;
+        break;
+      case toggleTableAction.type:
+        shouldRunQueries = getState().explore[exploreId].showingTable;
+        break;
+    }
+
+    if (shouldRunQueries) {
       dispatch(runQueries(exploreId));
     }
   };
-}
+};
 
 /**
- * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
  */
-export function toggleLogs(exploreId: ExploreId): ThunkResult<void> {
-  return (dispatch, getState) => {
-    dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } });
-    if (getState().explore[exploreId].showingLogs) {
-      dispatch(runQueries(exploreId));
-    }
-  };
-}
+export const toggleGraph = togglePanelActionCreator(toggleGraphAction);
 
 /**
- * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
+ * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
  */
-export function toggleTable(exploreId: ExploreId): ThunkResult<void> {
-  return (dispatch, getState) => {
-    dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } });
-    if (getState().explore[exploreId].showingTable) {
-      dispatch(runQueries(exploreId));
-    }
-  };
-}
+export const toggleLogs = togglePanelActionCreator(toggleLogsAction);
 
 /**
- * Resets state for explore.
+ * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
  */
-export function resetExplore(): ThunkResult<void> {
-  return dispatch => {
-    dispatch({ type: ActionTypes.ResetExplore, payload: {} });
-  };
-}
+export const toggleTable = togglePanelActionCreator(toggleTableAction);

+ 35 - 30
public/app/features/explore/state/reducers.test.ts

@@ -1,42 +1,47 @@
-import { Action, ActionTypes } from './actionTypes';
 import { itemReducer, makeExploreItemState } from './reducers';
-import { ExploreId } from 'app/types/explore';
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { reducerTester } from 'test/core/redux/reducerTester';
+import { scanStartAction, scanStopAction } from './actionTypes';
+import { Reducer } from 'redux';
+import { ActionOf } from 'app/core/redux/actionCreatorFactory';
 
 describe('Explore item reducer', () => {
   describe('scanning', () => {
     test('should start scanning', () => {
-      let state = makeExploreItemState();
-      const action: Action = {
-        type: ActionTypes.ScanStart,
-        payload: {
-          exploreId: ExploreId.left,
-          scanner: jest.fn(),
-        },
+      const scanner = jest.fn();
+      const initalState = {
+        ...makeExploreItemState(),
+        scanning: false,
+        scanner: undefined,
       };
-      state = itemReducer(state, action);
-      expect(state.scanning).toBeTruthy();
-      expect(state.scanner).toBe(action.payload.scanner);
+
+      reducerTester()
+        .givenReducer(itemReducer as Reducer<ExploreItemState, ActionOf<any>>, initalState)
+        .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left, scanner }))
+        .thenStateShouldEqual({
+          ...makeExploreItemState(),
+          scanning: true,
+          scanner,
+        });
     });
     test('should stop scanning', () => {
-      let state = makeExploreItemState();
-      const start: Action = {
-        type: ActionTypes.ScanStart,
-        payload: {
-          exploreId: ExploreId.left,
-          scanner: jest.fn(),
-        },
-      };
-      state = itemReducer(state, start);
-      expect(state.scanning).toBeTruthy();
-      const action: Action = {
-        type: ActionTypes.ScanStop,
-        payload: {
-          exploreId: ExploreId.left,
-        },
+      const scanner = jest.fn();
+      const initalState = {
+        ...makeExploreItemState(),
+        scanning: true,
+        scanner,
+        scanRange: {},
       };
-      state = itemReducer(state, action);
-      expect(state.scanning).toBeFalsy();
-      expect(state.scanner).toBeUndefined();
+
+      reducerTester()
+        .givenReducer(itemReducer as Reducer<ExploreItemState, ActionOf<any>>, initalState)
+        .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left }))
+        .thenStateShouldEqual({
+          ...makeExploreItemState(),
+          scanning: false,
+          scanner: undefined,
+          scanRange: undefined,
+        });
     });
   });
 });

+ 212 - 179
public/app/features/explore/state/reducers.ts

@@ -3,11 +3,41 @@ import {
   generateEmptyQuery,
   getIntervals,
   ensureQueries,
+  getQueryKeys,
 } from 'app/core/utils/explore';
 import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore';
 import { DataQuery } from '@grafana/ui/src/types';
 
-import { Action, ActionTypes } from './actionTypes';
+import { HigherOrderAction, ActionTypes } from './actionTypes';
+import { reducerFactory } from 'app/core/redux';
+import {
+  addQueryRowAction,
+  changeQueryAction,
+  changeSizeAction,
+  changeTimeAction,
+  clearQueriesAction,
+  highlightLogsExpressionAction,
+  initializeExploreAction,
+  updateDatasourceInstanceAction,
+  loadDatasourceFailureAction,
+  loadDatasourceMissingAction,
+  loadDatasourcePendingAction,
+  loadDatasourceSuccessAction,
+  modifyQueriesAction,
+  queryTransactionFailureAction,
+  queryTransactionStartAction,
+  queryTransactionSuccessAction,
+  removeQueryRowAction,
+  runQueriesEmptyAction,
+  scanRangeAction,
+  scanStartAction,
+  scanStopAction,
+  setQueriesAction,
+  toggleGraphAction,
+  toggleLogsAction,
+  toggleTableAction,
+  queriesImportedAction,
+} from './actionTypes';
 
 export const DEFAULT_RANGE = {
   from: 'now-6h',
@@ -30,9 +60,8 @@ export const makeExploreItemState = (): ExploreItemState => ({
   datasourceMissing: false,
   exploreDatasources: [],
   history: [],
-  initialQueries: [],
+  queries: [],
   initialized: false,
-  modifiedQueries: [],
   queryTransactions: [],
   queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
   range: DEFAULT_RANGE,
@@ -44,6 +73,7 @@ export const makeExploreItemState = (): ExploreItemState => ({
   supportsGraph: null,
   supportsLogs: null,
   supportsTable: null,
+  queryKeys: [],
 });
 
 /**
@@ -58,21 +88,15 @@ export const initialExploreState: ExploreState = {
 /**
  * Reducer for an Explore area, to be used by the global Explore reducer.
  */
-export const itemReducer = (state, action: Action): ExploreItemState => {
-  switch (action.type) {
-    case ActionTypes.AddQueryRow: {
-      const { initialQueries, modifiedQueries, queryTransactions } = state;
+export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemState)
+  .addMapper({
+    filter: addQueryRowAction,
+    mapper: (state, action): ExploreItemState => {
+      const { queries, queryTransactions } = state;
       const { index, query } = action.payload;
 
-      // Add new query row after given index, keep modifications of existing rows
-      const nextModifiedQueries = [
-        ...modifiedQueries.slice(0, index + 1),
-        { ...query },
-        ...initialQueries.slice(index + 1),
-      ];
-
-      // Add to initialQueries, which will cause a new row to be rendered
-      const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...initialQueries.slice(index + 1)];
+      // Add to queries, which will cause a new row to be rendered
+      const nextQueries = [...queries.slice(0, index + 1), { ...query }, ...queries.slice(index + 1)];
 
       // Ongoing transactions need to update their row indices
       const nextQueryTransactions = queryTransactions.map(qt => {
@@ -87,48 +111,38 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
 
       return {
         ...state,
-        initialQueries: nextQueries,
+        queries: nextQueries,
         logsHighlighterExpressions: undefined,
-        modifiedQueries: nextModifiedQueries,
         queryTransactions: nextQueryTransactions,
+        queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
       };
-    }
-
-    case ActionTypes.ChangeQuery: {
-      const { initialQueries, queryTransactions } = state;
-      let { modifiedQueries } = state;
-      const { query, index, override } = action.payload;
-
-      // Fast path: only change modifiedQueries to not trigger an update
-      modifiedQueries[index] = query;
-      if (!override) {
-        return {
-          ...state,
-          modifiedQueries,
-        };
-      }
+    },
+  })
+  .addMapper({
+    filter: changeQueryAction,
+    mapper: (state, action): ExploreItemState => {
+      const { queries, queryTransactions } = state;
+      const { query, index } = action.payload;
 
       // Override path: queries are completely reset
-      const nextQuery: DataQuery = {
-        ...query,
-        ...generateEmptyQuery(index),
-      };
-      const nextQueries = [...initialQueries];
+      const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(index) };
+      const nextQueries = [...queries];
       nextQueries[index] = nextQuery;
-      modifiedQueries = [...nextQueries];
 
       // Discard ongoing transaction related to row query
       const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
 
       return {
         ...state,
-        initialQueries: nextQueries,
-        modifiedQueries: nextQueries.slice(),
+        queries: nextQueries,
         queryTransactions: nextQueryTransactions,
+        queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
       };
-    }
-
-    case ActionTypes.ChangeSize: {
+    },
+  })
+  .addMapper({
+    filter: changeSizeAction,
+    mapper: (state, action): ExploreItemState => {
       const { range, datasourceInstance } = state;
       let interval = '1s';
       if (datasourceInstance && datasourceInstance.interval) {
@@ -137,67 +151,79 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
       const containerWidth = action.payload.width;
       const queryIntervals = getIntervals(range, interval, containerWidth);
       return { ...state, containerWidth, queryIntervals };
-    }
-
-    case ActionTypes.ChangeTime: {
-      return {
-        ...state,
-        range: action.payload.range,
-      };
-    }
-
-    case ActionTypes.ClearQueries: {
+    },
+  })
+  .addMapper({
+    filter: changeTimeAction,
+    mapper: (state, action): ExploreItemState => {
+      return { ...state, range: action.payload.range };
+    },
+  })
+  .addMapper({
+    filter: clearQueriesAction,
+    mapper: (state): ExploreItemState => {
       const queries = ensureQueries();
       return {
         ...state,
-        initialQueries: queries.slice(),
-        modifiedQueries: queries.slice(),
+        queries: queries.slice(),
         queryTransactions: [],
         showingStartPage: Boolean(state.StartPage),
+        queryKeys: getQueryKeys(queries, state.datasourceInstance),
       };
-    }
-
-    case ActionTypes.HighlightLogsExpression: {
+    },
+  })
+  .addMapper({
+    filter: highlightLogsExpressionAction,
+    mapper: (state, action): ExploreItemState => {
       const { expressions } = action.payload;
       return { ...state, logsHighlighterExpressions: expressions };
-    }
-
-    case ActionTypes.InitializeExplore: {
-      const { containerWidth, eventBridge, exploreDatasources, queries, range } = action.payload;
+    },
+  })
+  .addMapper({
+    filter: initializeExploreAction,
+    mapper: (state, action): ExploreItemState => {
+      const { containerWidth, eventBridge, exploreDatasources, queries, range, ui } = action.payload;
       return {
         ...state,
         containerWidth,
         eventBridge,
         exploreDatasources,
         range,
-        initialQueries: queries,
+        queries,
         initialized: true,
-        modifiedQueries: queries.slice(),
+        queryKeys: getQueryKeys(queries, state.datasourceInstance),
+        ...ui,
       };
-    }
-
-    case ActionTypes.UpdateDatasourceInstance: {
+    },
+  })
+  .addMapper({
+    filter: updateDatasourceInstanceAction,
+    mapper: (state, action): ExploreItemState => {
       const { datasourceInstance } = action.payload;
-      return {
-        ...state,
-        datasourceInstance,
-        datasourceName: datasourceInstance.name,
-      };
-    }
-
-    case ActionTypes.LoadDatasourceFailure: {
+      return { ...state, datasourceInstance, queryKeys: getQueryKeys(state.queries, datasourceInstance) };
+    },
+  })
+  .addMapper({
+    filter: loadDatasourceFailureAction,
+    mapper: (state, action): ExploreItemState => {
       return { ...state, datasourceError: action.payload.error, datasourceLoading: false };
-    }
-
-    case ActionTypes.LoadDatasourceMissing: {
+    },
+  })
+  .addMapper({
+    filter: loadDatasourceMissingAction,
+    mapper: (state): ExploreItemState => {
       return { ...state, datasourceMissing: true, datasourceLoading: false };
-    }
-
-    case ActionTypes.LoadDatasourcePending: {
+    },
+  })
+  .addMapper({
+    filter: loadDatasourcePendingAction,
+    mapper: (state, action): ExploreItemState => {
       return { ...state, datasourceLoading: true, requestedDatasourceName: action.payload.requestedDatasourceName };
-    }
-
-    case ActionTypes.LoadDatasourceSuccess: {
+    },
+  })
+  .addMapper({
+    filter: loadDatasourceSuccessAction,
+    mapper: (state, action): ExploreItemState => {
       const { containerWidth, range } = state;
       const {
         StartPage,
@@ -226,32 +252,29 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
         logsHighlighterExpressions: undefined,
         queryTransactions: [],
       };
-    }
-
-    case ActionTypes.ModifyQueries: {
-      const { initialQueries, modifiedQueries, queryTransactions } = state;
-      const { modification, index, modifier } = action.payload as any;
+    },
+  })
+  .addMapper({
+    filter: modifyQueriesAction,
+    mapper: (state, action): ExploreItemState => {
+      const { queries, queryTransactions } = state;
+      const { modification, index, modifier } = action.payload;
       let nextQueries: DataQuery[];
       let nextQueryTransactions;
       if (index === undefined) {
         // Modify all queries
-        nextQueries = initialQueries.map((query, i) => ({
-          ...modifier(modifiedQueries[i], modification),
+        nextQueries = queries.map((query, i) => ({
+          ...modifier({ ...query }, modification),
           ...generateEmptyQuery(i),
         }));
         // Discard all ongoing transactions
         nextQueryTransactions = [];
       } else {
         // Modify query only at index
-        nextQueries = initialQueries.map((query, i) => {
+        nextQueries = queries.map((query, i) => {
           // Synchronize all queries with local query cache to ensure consistency
           // TODO still needed?
-          return i === index
-            ? {
-                ...modifier(modifiedQueries[i], modification),
-                ...generateEmptyQuery(i),
-              }
-            : query;
+          return i === index ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(i) } : query;
         });
         nextQueryTransactions = queryTransactions
           // Consume the hint corresponding to the action
@@ -266,22 +289,22 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
       }
       return {
         ...state,
-        initialQueries: nextQueries,
-        modifiedQueries: nextQueries.slice(),
+        queries: nextQueries,
+        queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
         queryTransactions: nextQueryTransactions,
       };
-    }
-
-    case ActionTypes.QueryTransactionFailure: {
+    },
+  })
+  .addMapper({
+    filter: queryTransactionFailureAction,
+    mapper: (state, action): ExploreItemState => {
       const { queryTransactions } = action.payload;
-      return {
-        ...state,
-        queryTransactions,
-        showingStartPage: false,
-      };
-    }
-
-    case ActionTypes.QueryTransactionStart: {
+      return { ...state, queryTransactions, showingStartPage: false };
+    },
+  })
+  .addMapper({
+    filter: queryTransactionStartAction,
+    mapper: (state, action): ExploreItemState => {
       const { queryTransactions } = state;
       const { resultType, rowIndex, transaction } = action.payload;
       // Discarding existing transactions of same type
@@ -292,14 +315,12 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
       // Append new transaction
       const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction];
 
-      return {
-        ...state,
-        queryTransactions: nextQueryTransactions,
-        showingStartPage: false,
-      };
-    }
-
-    case ActionTypes.QueryTransactionSuccess: {
+      return { ...state, queryTransactions: nextQueryTransactions, showingStartPage: false };
+    },
+  })
+  .addMapper({
+    filter: queryTransactionSuccessAction,
+    mapper: (state, action): ExploreItemState => {
       const { datasourceInstance, queryIntervals } = state;
       const { history, queryTransactions } = action.payload;
       const results = calculateResultsFromQueryTransactions(
@@ -308,30 +329,24 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
         queryIntervals.intervalMs
       );
 
-      return {
-        ...state,
-        ...results,
-        history,
-        queryTransactions,
-        showingStartPage: false,
-      };
-    }
-
-    case ActionTypes.RemoveQueryRow: {
-      const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state;
-      let { modifiedQueries } = state;
+      return { ...state, ...results, history, queryTransactions, showingStartPage: false };
+    },
+  })
+  .addMapper({
+    filter: removeQueryRowAction,
+    mapper: (state, action): ExploreItemState => {
+      const { datasourceInstance, queries, queryIntervals, queryTransactions, queryKeys } = state;
       const { index } = action.payload;
 
-      modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)];
-
-      if (initialQueries.length <= 1) {
+      if (queries.length <= 1) {
         return state;
       }
 
-      const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
+      const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
+      const nextQueryKeys = [...queryKeys.slice(0, index), ...queryKeys.slice(index + 1)];
 
       // Discard transactions related to row query
-      const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+      const nextQueryTransactions = queryTransactions.filter(qt => nextQueries.some(nq => nq.key === qt.query.key));
       const results = calculateResultsFromQueryTransactions(
         nextQueryTransactions,
         datasourceInstance,
@@ -341,26 +356,34 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
       return {
         ...state,
         ...results,
-        initialQueries: nextQueries,
+        queries: nextQueries,
         logsHighlighterExpressions: undefined,
-        modifiedQueries: nextQueries.slice(),
         queryTransactions: nextQueryTransactions,
+        queryKeys: nextQueryKeys,
       };
-    }
-
-    case ActionTypes.RunQueriesEmpty: {
+    },
+  })
+  .addMapper({
+    filter: runQueriesEmptyAction,
+    mapper: (state): ExploreItemState => {
       return { ...state, queryTransactions: [] };
-    }
-
-    case ActionTypes.ScanRange: {
+    },
+  })
+  .addMapper({
+    filter: scanRangeAction,
+    mapper: (state, action): ExploreItemState => {
       return { ...state, scanRange: action.payload.range };
-    }
-
-    case ActionTypes.ScanStart: {
+    },
+  })
+  .addMapper({
+    filter: scanStartAction,
+    mapper: (state, action): ExploreItemState => {
       return { ...state, scanning: true, scanner: action.payload.scanner };
-    }
-
-    case ActionTypes.ScanStop: {
+    },
+  })
+  .addMapper({
+    filter: scanStopAction,
+    mapper: (state): ExploreItemState => {
       const { queryTransactions } = state;
       const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
       return {
@@ -370,14 +393,22 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
         scanRange: undefined,
         scanner: undefined,
       };
-    }
-
-    case ActionTypes.SetQueries: {
+    },
+  })
+  .addMapper({
+    filter: setQueriesAction,
+    mapper: (state, action): ExploreItemState => {
       const { queries } = action.payload;
-      return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() };
-    }
-
-    case ActionTypes.ToggleGraph: {
+      return {
+        ...state,
+        queries: queries.slice(),
+        queryKeys: getQueryKeys(queries, state.datasourceInstance),
+      };
+    },
+  })
+  .addMapper({
+    filter: toggleGraphAction,
+    mapper: (state): ExploreItemState => {
       const showingGraph = !state.showingGraph;
       let nextQueryTransactions = state.queryTransactions;
       if (!showingGraph) {
@@ -385,9 +416,11 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
         nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
       }
       return { ...state, queryTransactions: nextQueryTransactions, showingGraph };
-    }
-
-    case ActionTypes.ToggleLogs: {
+    },
+  })
+  .addMapper({
+    filter: toggleLogsAction,
+    mapper: (state): ExploreItemState => {
       const showingLogs = !state.showingLogs;
       let nextQueryTransactions = state.queryTransactions;
       if (!showingLogs) {
@@ -395,9 +428,11 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
         nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
       }
       return { ...state, queryTransactions: nextQueryTransactions, showingLogs };
-    }
-
-    case ActionTypes.ToggleTable: {
+    },
+  })
+  .addMapper({
+    filter: toggleTableAction,
+    mapper: (state): ExploreItemState => {
       const showingTable = !state.showingTable;
       if (showingTable) {
         return { ...state, showingTable, queryTransactions: state.queryTransactions };
@@ -412,25 +447,26 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
       );
 
       return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable };
-    }
-
-    case ActionTypes.QueriesImported: {
+    },
+  })
+  .addMapper({
+    filter: queriesImportedAction,
+    mapper: (state, action): ExploreItemState => {
+      const { queries } = action.payload;
       return {
         ...state,
-        initialQueries: action.payload.queries,
-        modifiedQueries: action.payload.queries.slice(),
+        queries,
+        queryKeys: getQueryKeys(queries, state.datasourceInstance),
       };
-    }
-  }
-
-  return state;
-};
+    },
+  })
+  .create();
 
 /**
  * Global Explore reducer that handles multiple Explore areas (left and right).
  * Actions that have an `exploreId` get routed to the ExploreItemReducer.
  */
-export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
+export const exploreReducer = (state = initialExploreState, action: HigherOrderAction): ExploreState => {
   switch (action.type) {
     case ActionTypes.SplitClose: {
       return { ...state, split: false };
@@ -453,10 +489,7 @@ export const exploreReducer = (state = initialExploreState, action: Action): Exp
     const { exploreId } = action.payload as any;
     if (exploreId !== undefined) {
       const exploreItemState = state[exploreId];
-      return {
-        ...state,
-        [exploreId]: itemReducer(exploreItemState, action),
-      };
+      return { ...state, [exploreId]: itemReducer(exploreItemState, action) };
     }
   }
 

+ 17 - 0
public/app/plugins/datasource/cloudwatch/datasource.ts

@@ -232,6 +232,14 @@ export default class CloudWatchDatasource {
     });
   }
 
+  getResourceARNs(region, resourceType, tags) {
+    return this.doMetricQueryRequest('resource_arns', {
+      region: this.templateSrv.replace(this.getActualRegion(region)),
+      resourceType: this.templateSrv.replace(resourceType),
+      tags: tags,
+    });
+  }
+
   metricFindQuery(query) {
     let region;
     let namespace;
@@ -293,6 +301,15 @@ export default class CloudWatchDatasource {
       return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson);
     }
 
+
+    const resourceARNsQuery = query.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/);
+    if (resourceARNsQuery) {
+      region = resourceARNsQuery[1];
+      const resourceType = resourceARNsQuery[2];
+      const tagsJSON = JSON.parse(this.templateSrv.replace(resourceARNsQuery[3]));
+      return this.getResourceARNs(region, resourceType, tagsJSON);
+    }
+
     return this.$q.when([]);
   }
 

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

@@ -380,6 +380,29 @@ describe('CloudWatchDatasource', () => {
     });
   });
 
+  describeMetricFindQuery('resource_arns(default,ec2:instance,{"environment":["production"]})', scenario => {
+    scenario.setup(() => {
+      scenario.requestResponse = {
+        results: {
+          metricFindQuery: {
+            tables: [{
+              rows: [[
+                'arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567',
+                'arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321'
+              ]]
+            }],
+          },
+        },
+      };
+    });
+
+    it('should call __ListMetrics and return result', () => {
+      expect(scenario.result[0].text).toContain('arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('resource_arns');
+    });
+  });
+
   it('should caclculate the correct period', () => {
     const hourSec = 60 * 60;
     const daySec = hourSec * 24;

+ 86 - 0
public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx

@@ -0,0 +1,86 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Components
+import { Select, SelectOptionItem } from '@grafana/ui';
+
+// Types
+import { QueryEditorProps } from '@grafana/ui/src/types';
+import { LokiDatasource } from '../datasource';
+import { LokiQuery } from '../types';
+import { LokiQueryField } from './LokiQueryField';
+
+type Props = QueryEditorProps<LokiDatasource, LokiQuery>;
+
+interface State {
+  query: LokiQuery;
+}
+
+export class LokiQueryEditor extends PureComponent<Props> {
+  state: State = {
+    query: this.props.query,
+  };
+
+  onRunQuery = () => {
+    const { query } = this.state;
+
+    this.props.onChange(query);
+    this.props.onRunQuery();
+  };
+
+  onFieldChange = (query: LokiQuery, override?) => {
+    this.setState({
+      query: {
+        ...this.state.query,
+        expr: query.expr,
+      },
+    });
+  };
+
+  onFormatChanged = (option: SelectOptionItem) => {
+    this.props.onChange({
+      ...this.state.query,
+      resultFormat: option.value,
+    });
+  };
+
+  render() {
+    const { query } = this.state;
+    const { datasource } = this.props;
+    const formatOptions: SelectOptionItem[] = [
+      { label: 'Time Series', value: 'time_series' },
+      { label: 'Table', value: 'table' },
+    ];
+
+    query.resultFormat = query.resultFormat || 'time_series';
+    const currentFormat = formatOptions.find(item => item.value === query.resultFormat);
+
+    return (
+      <div>
+        <LokiQueryField
+          datasource={datasource}
+          query={query}
+          onQueryChange={this.onFieldChange}
+          onExecuteQuery={this.onRunQuery}
+          history={[]}
+        />
+        <div className="gf-form-inline">
+          <div className="gf-form">
+            <div className="gf-form-label">Format as</div>
+            <Select
+              isSearchable={false}
+              options={formatOptions}
+              onChange={this.onFormatChanged}
+              value={currentFormat}
+            />
+          </div>
+          <div className="gf-form gf-form--grow">
+            <div className="gf-form-label gf-form-label--grow" />
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default LokiQueryEditor;

+ 21 - 25
public/app/plugins/datasource/loki/components/LokiQueryField.tsx

@@ -15,8 +15,9 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
 
 // Types
 import { LokiQuery } from '../types';
-import { TypeaheadOutput } from 'app/types/explore';
+import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
 import { makePromiseCancelable, CancelablePromise } from 'app/core/utils/CancelablePromise';
+import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
 
 const PRISM_SYNTAX = 'promql';
 
@@ -64,15 +65,8 @@ interface CascaderOption {
   disabled?: boolean;
 }
 
-interface LokiQueryFieldProps {
-  datasource: any;
-  error?: string | JSX.Element;
-  hint?: any;
-  history?: any[];
-  initialQuery?: LokiQuery;
-  onClickHintFix?: (action: any) => void;
-  onPressEnter?: () => void;
-  onQueryChange?: (value: LokiQuery, override?: boolean) => void;
+interface LokiQueryFieldProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> {
+  history: HistoryItem[];
 }
 
 interface LokiQueryFieldState {
@@ -80,7 +74,7 @@ interface LokiQueryFieldState {
   syntaxLoaded: boolean;
 }
 
-class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
+export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
   plugins: any[];
   pluginsSearch: any[];
   languageProvider: any;
@@ -97,14 +91,14 @@ class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryF
 
     this.plugins = [
       BracesPlugin(),
-      RunnerPlugin({ handler: props.onPressEnter }),
+      RunnerPlugin({ handler: props.onExecuteQuery }),
       PluginPrism({
         onlyIn: node => node.type === 'code_block',
         getSyntax: node => 'promql',
       }),
     ];
 
-    this.pluginsSearch = [RunnerPlugin({ handler: props.onPressEnter })];
+    this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })];
 
     this.state = {
       logLabelOptions: [],
@@ -168,20 +162,21 @@ class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryF
 
   onChangeQuery = (value: string, override?: boolean) => {
     // Send text change to parent
-    const { initialQuery, onQueryChange } = this.props;
+    const { query, onQueryChange, onExecuteQuery } = this.props;
     if (onQueryChange) {
-      const query = {
-        ...initialQuery,
-        expr: value,
-      };
-      onQueryChange(query, override);
+      const nextQuery = { ...query, expr: value };
+      onQueryChange(nextQuery);
+
+      if (override && onExecuteQuery) {
+        onExecuteQuery();
+      }
     }
   };
 
   onClickHintFix = () => {
-    const { hint, onClickHintFix } = this.props;
-    if (onClickHintFix && hint && hint.fix) {
-      onClickHintFix(hint.fix.action);
+    const { hint, onExecuteHint } = this.props;
+    if (onExecuteHint && hint && hint.fix) {
+      onExecuteHint(hint.fix.action);
     }
   };
 
@@ -219,7 +214,7 @@ class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryF
   };
 
   render() {
-    const { error, hint, initialQuery } = this.props;
+    const { error, hint, query } = this.props;
     const { logLabelOptions, syntaxLoaded } = this.state;
     const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
     const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
@@ -239,10 +234,11 @@ class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryF
             <QueryField
               additionalPlugins={this.plugins}
               cleanText={cleanText}
-              initialQuery={initialQuery.expr}
+              initialQuery={query.expr}
               onTypeahead={this.onTypeahead}
               onWillApplySuggestion={willApplySuggestion}
-              onValueChanged={this.onChangeQuery}
+              onQueryChange={this.onChangeQuery}
+              onExecuteQuery={this.props.onExecuteQuery}
               placeholder="Enter a Loki query"
               portalOrigin="loki"
               syntaxLoaded={syntaxLoaded}

+ 2 - 5
public/app/plugins/datasource/loki/components/LokiStartPage.tsx

@@ -1,11 +1,8 @@
 import React, { PureComponent } from 'react';
 import LokiCheatSheet from './LokiCheatSheet';
+import { ExploreStartPageProps } from '@grafana/ui';
 
-interface Props {
-  onClickExample: () => void;
-}
-
-export default class LokiStartPage extends PureComponent<Props> {
+export default class LokiStartPage extends PureComponent<ExploreStartPageProps> {
   render() {
     return (
       <div className="grafana-info-box grafana-info-box--max-lg">

+ 41 - 2
public/app/plugins/datasource/loki/datasource.test.ts

@@ -7,6 +7,17 @@ describe('LokiDatasource', () => {
     url: 'myloggingurl',
   };
 
+  const testResp = {
+    data: {
+      streams: [
+        {
+          entries: [{ ts: '2019-02-01T10:27:37.498180581Z', line: 'hello' }],
+          labels: '{}',
+        },
+      ],
+    },
+  };
+
   describe('when querying', () => {
     const backendSrvMock = { datasourceRequest: jest.fn() };
 
@@ -17,7 +28,7 @@ describe('LokiDatasource', () => {
 
     test('should use default max lines when no limit given', () => {
       const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
-      backendSrvMock.datasourceRequest = jest.fn();
+      backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
       const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] });
 
       ds.query(options);
@@ -30,7 +41,7 @@ describe('LokiDatasource', () => {
       const customData = { ...(instanceSettings.jsonData || {}), maxLines: 20 };
       const customSettings = { ...instanceSettings, jsonData: customData };
       const ds = new LokiDatasource(customSettings, backendSrvMock, templateSrvMock);
-      backendSrvMock.datasourceRequest = jest.fn();
+      backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
 
       const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] });
       ds.query(options);
@@ -38,6 +49,34 @@ describe('LokiDatasource', () => {
       expect(backendSrvMock.datasourceRequest.mock.calls.length).toBe(1);
       expect(backendSrvMock.datasourceRequest.mock.calls[0][0].url).toContain('limit=20');
     });
+
+    test('should return log streams when resultFormat is undefined', async done => {
+      const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
+      backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
+
+      const options = getQueryOptions<LokiQuery>({
+        targets: [{ expr: 'foo', refId: 'B' }],
+      });
+
+      const res = await ds.query(options);
+
+      expect(res.data[0].entries[0].line).toBe('hello');
+      done();
+    });
+
+    test('should return time series when resultFormat is time_series', async done => {
+      const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
+      backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
+
+      const options = getQueryOptions<LokiQuery>({
+        targets: [{ expr: 'foo', refId: 'B', resultFormat: 'time_series' }],
+      });
+
+      const res = await ds.query(options);
+
+      expect(res.data[0].datapoints).toBeDefined();
+      done();
+    });
   });
 
   describe('when performing testDataSource', () => {

+ 55 - 43
public/app/plugins/datasource/loki/datasource.ts

@@ -32,7 +32,7 @@ function serializeParams(data: any) {
     .join('&');
 }
 
-export default class LokiDatasource {
+export class LokiDatasource {
   languageProvider: LanguageProvider;
   maxLines: number;
 
@@ -73,10 +73,11 @@ export default class LokiDatasource {
     };
   }
 
-  query(options: DataQueryOptions<LokiQuery>): Promise<{ data: LogsStream[] }> {
+  async query(options: DataQueryOptions<LokiQuery>) {
     const queryTargets = options.targets
-      .filter(target => target.expr)
+      .filter(target => target.expr && !target.hide)
       .map(target => this.prepareQueryTarget(target, options));
+
     if (queryTargets.length === 0) {
       return Promise.resolve({ data: [] });
     }
@@ -84,20 +85,29 @@ export default class LokiDatasource {
     const queries = queryTargets.map(target => this._request('/api/prom/query', target));
 
     return Promise.all(queries).then((results: any[]) => {
-      // Flatten streams from multiple queries
-      const allStreams: LogsStream[] = results.reduce((acc, response, i) => {
-        if (!response) {
-          return acc;
+      const allStreams: LogsStream[] = [];
+
+      for (let i = 0; i < results.length; i++) {
+        const result = results[i];
+        const query = queryTargets[i];
+
+        // add search term to stream & add to array
+        if (result.data)  {
+          for (const stream of (result.data.streams || [])) {
+            stream.search = query.regexp;
+            allStreams.push(stream);
+          }
         }
-        const streams: LogsStream[] = response.data.streams || [];
-        // Inject search for match highlighting
-        const search: string = queryTargets[i].regexp;
-        streams.forEach(s => {
-          s.search = search;
-        });
-        return [...acc, ...streams];
-      }, []);
-      return { data: allStreams };
+      }
+
+      // check resultType
+      if (options.targets[0].resultFormat === 'time_series') {
+        const logs = mergeStreamsToLogs(allStreams, this.maxLines);
+        logs.series = makeSeriesForLogs(logs.rows, options.intervalMs);
+        return { data: logs.series };
+      } else {
+        return { data: allStreams };
+      }
     });
   }
 
@@ -142,34 +152,36 @@ export default class LokiDatasource {
 
   testDatasource() {
     return this._request('/api/prom/label')
-      .then(res => {
-        if (res && res.data && res.data.values && res.data.values.length > 0) {
-          return { status: 'success', message: 'Data source connected and labels found.' };
-        }
-        return {
-          status: 'error',
-          message:
-            'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.',
-        };
-      })
-      .catch(err => {
-        let message = 'Loki: ';
-        if (err.statusText) {
-          message += err.statusText;
-        } else {
-          message += 'Cannot connect to Loki';
-        }
+    .then(res => {
+      if (res && res.data && res.data.values && res.data.values.length > 0) {
+        return { status: 'success', message: 'Data source connected and labels found.' };
+      }
+      return {
+        status: 'error',
+        message:
+          'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.',
+      };
+    })
+    .catch(err => {
+      let message = 'Loki: ';
+      if (err.statusText) {
+        message += err.statusText;
+      } else {
+        message += 'Cannot connect to Loki';
+      }
 
-        if (err.status) {
-          message += `. ${err.status}`;
-        }
+      if (err.status) {
+        message += `. ${err.status}`;
+      }
 
-        if (err.data && err.data.message) {
-          message += `. ${err.data.message}`;
-        } else if (err.data) {
-          message += `. ${err.data}`;
-        }
-        return { status: 'error', message: message };
-      });
+      if (err.data && err.data.message) {
+        message += `. ${err.data.message}`;
+      } else if (err.data) {
+        message += `. ${err.data}`;
+      }
+      return { status: 'error', message: message };
+    });
   }
 }
+
+export default LokiDatasource;

+ 2 - 0
public/app/plugins/datasource/loki/module.ts

@@ -2,6 +2,7 @@ import Datasource from './datasource';
 
 import LokiStartPage from './components/LokiStartPage';
 import LokiQueryField from './components/LokiQueryField';
+import LokiQueryEditor from './components/LokiQueryEditor';
 
 export class LokiConfigCtrl {
   static templateUrl = 'partials/config.html';
@@ -9,6 +10,7 @@ export class LokiConfigCtrl {
 
 export {
   Datasource,
+  LokiQueryEditor as QueryEditor,
   LokiConfigCtrl as ConfigCtrl,
   LokiQueryField as ExploreQueryField,
   LokiStartPage as ExploreStartPage,

+ 3 - 1
public/app/plugins/datasource/loki/plugin.json

@@ -2,12 +2,14 @@
   "type": "datasource",
   "name": "Loki",
   "id": "loki",
-  "metrics": false,
+
+  "metrics": true,
   "alerting": false,
   "annotations": false,
   "logs": true,
   "explore": true,
   "tables": false,
+
   "info": {
     "description": "Loki Logging Data Source for Grafana",
     "author": {

+ 3 - 0
public/app/plugins/datasource/loki/types.ts

@@ -2,5 +2,8 @@ import { DataQuery } from '@grafana/ui/src/types';
 
 export interface LokiQuery extends DataQuery {
   expr: string;
+  resultFormat?: LokiQueryResultFormats;
 }
 
+export type LokiQueryResultFormats = 'time_series' | 'logs';
+

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

@@ -4,7 +4,7 @@ import Cascader from 'rc-cascader';
 import PluginPrism from 'slate-prism';
 import Prism from 'prismjs';
 
-import { TypeaheadOutput } from 'app/types/explore';
+import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
 
 // dom also includes Element polyfills
 import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
@@ -13,6 +13,7 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
 import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
 import { PromQuery } from '../types';
 import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
+import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
 
 const HISTOGRAM_GROUP = '__histograms__';
 const METRIC_MARK = 'metric';
@@ -86,15 +87,8 @@ interface CascaderOption {
   disabled?: boolean;
 }
 
-interface PromQueryFieldProps {
-  datasource: any;
-  error?: string | JSX.Element;
-  initialQuery: PromQuery;
-  hint?: any;
-  history?: any[];
-  onClickHintFix?: (action: any) => void;
-  onPressEnter?: () => void;
-  onQueryChange?: (value: PromQuery, override?: boolean) => void;
+interface PromQueryFieldProps extends ExploreQueryFieldProps<ExploreDataSourceApi, PromQuery> {
+  history: HistoryItem[];
 }
 
 interface PromQueryFieldState {
@@ -116,7 +110,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
 
     this.plugins = [
       BracesPlugin(),
-      RunnerPlugin({ handler: props.onPressEnter }),
+      RunnerPlugin({ handler: props.onExecuteQuery }),
       PluginPrism({
         onlyIn: node => node.type === 'code_block',
         getSyntax: node => 'promql',
@@ -174,20 +168,21 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
 
   onChangeQuery = (value: string, override?: boolean) => {
     // Send text change to parent
-    const { initialQuery, onQueryChange } = this.props;
+    const { query, onQueryChange, onExecuteQuery } = this.props;
     if (onQueryChange) {
-      const query: PromQuery = {
-        ...initialQuery,
-        expr: value,
-      };
-      onQueryChange(query, override);
+      const nextQuery: PromQuery = { ...query, expr: value };
+      onQueryChange(nextQuery);
+
+      if (override && onExecuteQuery) {
+        onExecuteQuery();
+      }
     }
   };
 
   onClickHintFix = () => {
-    const { hint, onClickHintFix } = this.props;
-    if (onClickHintFix && hint && hint.fix) {
-      onClickHintFix(hint.fix.action);
+    const { hint, onExecuteHint } = this.props;
+    if (onExecuteHint && hint && hint.fix) {
+      onExecuteHint(hint.fix.action);
     }
   };
 
@@ -242,29 +237,30 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   };
 
   render() {
-    const { error, hint, initialQuery } = this.props;
+    const { error, hint, query } = this.props;
     const { metricsOptions, syntaxLoaded } = this.state;
     const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
     const chooserText = syntaxLoaded ? 'Metrics' : 'Loading metrics...';
 
     return (
       <>
-        <div className="gf-form-inline">
-          <div className="gf-form">
+        <div className="gf-form-inline gf-form-inline--nowrap">
+          <div className="gf-form flex-shrink-0">
             <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
               <button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
                 {chooserText} <i className="fa fa-caret-down" />
               </button>
             </Cascader>
           </div>
-          <div className="gf-form gf-form--grow">
+          <div className="gf-form gf-form--grow flex-shrink-1">
             <QueryField
               additionalPlugins={this.plugins}
               cleanText={cleanText}
-              initialQuery={initialQuery.expr}
+              initialQuery={query.expr}
               onTypeahead={this.onTypeahead}
               onWillApplySuggestion={willApplySuggestion}
-              onValueChanged={this.onChangeQuery}
+              onQueryChange={this.onChangeQuery}
+              onExecuteQuery={this.props.onExecuteQuery}
               placeholder="Enter a PromQL query"
               portalOrigin="prometheus"
               syntaxLoaded={syntaxLoaded}

+ 2 - 5
public/app/plugins/datasource/prometheus/components/PromStart.tsx

@@ -1,11 +1,8 @@
 import React, { PureComponent } from 'react';
 import PromCheatSheet from './PromCheatSheet';
+import { ExploreStartPageProps } from '@grafana/ui';
 
-interface Props {
-  onClickExample: () => void;
-}
-
-export default class PromStart extends PureComponent<Props> {
+export default class PromStart extends PureComponent<ExploreStartPageProps> {
   render() {
     return (
       <div className="grafana-info-box grafana-info-box--max-lg">

+ 4 - 4
public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx

@@ -10,21 +10,21 @@ import { Alignments } from './Alignments';
 import { AlignmentPeriods } from './AlignmentPeriods';
 import { AliasBy } from './AliasBy';
 import { Help } from './Help';
-import { Target, MetricDescriptor } from '../types';
+import { StackdriverQuery, MetricDescriptor } from '../types';
 import { getAlignmentPickerData } from '../functions';
 import StackdriverDatasource from '../datasource';
 import { SelectOptionItem } from '@grafana/ui';
 
 export interface Props {
-  onQueryChange: (target: Target) => void;
+  onQueryChange: (target: StackdriverQuery) => void;
   onExecuteQuery: () => void;
-  target: Target;
+  target: StackdriverQuery;
   events: any;
   datasource: StackdriverDatasource;
   templateSrv: TemplateSrv;
 }
 
-interface State extends Target {
+interface State extends StackdriverQuery {
   alignOptions: SelectOptionItem[];
   lastQuery: string;
   lastQueryError: string;

+ 12 - 7
public/app/plugins/datasource/stackdriver/datasource.ts

@@ -2,9 +2,10 @@ import { stackdriverUnitMappings } from './constants';
 import appEvents from 'app/core/app_events';
 import _ from 'lodash';
 import StackdriverMetricFindQuery from './StackdriverMetricFindQuery';
-import { MetricDescriptor } from './types';
+import { StackdriverQuery, MetricDescriptor } from './types';
+import { DataSourceApi, DataQueryOptions } from '@grafana/ui/src/types';
 
-export default class StackdriverDatasource {
+export default class StackdriverDatasource implements DataSourceApi<StackdriverQuery> {
   id: number;
   url: string;
   baseUrl: string;
@@ -39,9 +40,7 @@ export default class StackdriverDatasource {
           alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod, options.scopedVars || {}),
           groupBys: this.interpolateGroupBys(t.groupBys, options.scopedVars),
           view: t.view || 'FULL',
-          filters: (t.filters || []).map(f => {
-            return this.templateSrv.replace(f, options.scopedVars || {});
-          }),
+          filters: this.interpolateFilters(t.filters, options.scopedVars),
           aliasBy: this.templateSrv.replace(t.aliasBy, options.scopedVars || {}),
           type: 'timeSeriesQuery',
         };
@@ -63,7 +62,13 @@ export default class StackdriverDatasource {
     }
   }
 
-  async getLabels(metricType, refId) {
+  interpolateFilters(filters: string[], scopedVars: object) {
+    return (filters || []).map(f => {
+      return this.templateSrv.replace(f, scopedVars || {}, 'regex');
+    });
+  }
+
+  async getLabels(metricType: string, refId: string) {
     const response = await this.getTimeSeries({
       targets: [
         {
@@ -103,7 +108,7 @@ export default class StackdriverDatasource {
     return unit;
   }
 
-  async query(options) {
+  async query(options: DataQueryOptions<StackdriverQuery>) {
     const result = [];
     const data = await this.getTimeSeries(options);
     if (data.results) {

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


File diff suppressed because it is too large
+ 0 - 0
public/app/plugins/datasource/stackdriver/img/stackdriver_logo.svg


+ 2 - 2
public/app/plugins/datasource/stackdriver/plugin.json

@@ -14,8 +14,8 @@
     "description": "Google Stackdriver Datasource for Grafana",
     "version": "1.0.0",
     "logos": {
-      "small": "img/stackdriver_logo.png",
-      "large": "img/stackdriver_logo.png"
+      "small": "img/stackdriver_logo.svg",
+      "large": "img/stackdriver_logo.svg"
     },
     "author": {
       "name": "Grafana Project",

+ 2 - 2
public/app/plugins/datasource/stackdriver/query_ctrl.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash';
 
 import { QueryCtrl } from 'app/plugins/sdk';
-import { Target } from './types';
+import { StackdriverQuery } from './types';
 import { TemplateSrv } from 'app/features/templating/template_srv';
 
 export class StackdriverQueryCtrl extends QueryCtrl {
@@ -16,7 +16,7 @@ export class StackdriverQueryCtrl extends QueryCtrl {
     this.onExecuteQuery = this.onExecuteQuery.bind(this);
   }
 
-  onQueryChange(target: Target) {
+  onQueryChange(target: StackdriverQuery) {
     Object.assign(this.target, target);
   }
 

+ 51 - 10
public/app/plugins/datasource/stackdriver/specs/datasource.test.ts

@@ -1,7 +1,8 @@
 import StackdriverDataSource from '../datasource';
 import { metricDescriptors } from './testData';
 import moment from 'moment';
-import { TemplateSrvStub } from 'test/specs/helpers';
+import { TemplateSrv } from 'app/features/templating/template_srv';
+import { CustomVariable } from 'app/features/templating/all';
 
 describe('StackdriverDataSource', () => {
   const instanceSettings = {
@@ -9,7 +10,7 @@ describe('StackdriverDataSource', () => {
       defaultProject: 'testproject',
     },
   };
-  const templateSrv = new TemplateSrvStub();
+  const templateSrv = new TemplateSrv();
   const timeSrv = {};
 
   describe('when performing testDataSource', () => {
@@ -154,15 +155,41 @@ describe('StackdriverDataSource', () => {
     });
   });
 
+  describe('when interpolating a template variable for the filter', () => {
+    let interpolated;
+    describe('and is single value variable', () => {
+      beforeEach(() => {
+        const filterTemplateSrv = initTemplateSrv('filtervalue1');
+        const ds = new StackdriverDataSource(instanceSettings, {}, filterTemplateSrv, timeSrv);
+        interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '${test}'], {});
+      });
+
+      it('should replace the variable with the value', () => {
+        expect(interpolated.length).toBe(3);
+        expect(interpolated[2]).toBe('filtervalue1');
+      });
+    });
+
+    describe('and is multi value variable', () => {
+      beforeEach(() => {
+        const filterTemplateSrv = initTemplateSrv(['filtervalue1', 'filtervalue2'], true);
+        const ds = new StackdriverDataSource(instanceSettings, {}, filterTemplateSrv, timeSrv);
+        interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '[[test]]'], {});
+      });
+
+      it('should replace the variable with a regex expression', () => {
+        expect(interpolated[2]).toBe('(filtervalue1|filtervalue2)');
+      });
+    });
+  });
+
   describe('when interpolating a template variable for group bys', () => {
     let interpolated;
 
     describe('and is single value variable', () => {
       beforeEach(() => {
-        templateSrv.data = {
-          test: 'groupby1',
-        };
-        const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+        const groupByTemplateSrv = initTemplateSrv('groupby1');
+        const ds = new StackdriverDataSource(instanceSettings, {}, groupByTemplateSrv, timeSrv);
         interpolated = ds.interpolateGroupBys(['[[test]]'], {});
       });
 
@@ -174,10 +201,8 @@ describe('StackdriverDataSource', () => {
 
     describe('and is multi value variable', () => {
       beforeEach(() => {
-        templateSrv.data = {
-          test: 'groupby1,groupby2',
-        };
-        const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+        const groupByTemplateSrv = initTemplateSrv(['groupby1', 'groupby2'], true);
+        const ds = new StackdriverDataSource(instanceSettings, {}, groupByTemplateSrv, timeSrv);
         interpolated = ds.interpolateGroupBys(['[[test]]'], {});
       });
 
@@ -241,3 +266,19 @@ describe('StackdriverDataSource', () => {
     });
   });
 });
+function initTemplateSrv(values: any, multi = false) {
+  const templateSrv = new TemplateSrv();
+  templateSrv.init([
+    new CustomVariable(
+      {
+        name: 'test',
+        current: {
+          value: values,
+        },
+        multi: multi,
+      },
+      {}
+    ),
+  ]);
+  return templateSrv;
+}

+ 12 - 8
public/app/plugins/datasource/stackdriver/types.ts

@@ -1,3 +1,5 @@
+import { DataQuery } from '@grafana/ui/src/types';
+
 export enum MetricFindQueryTypes {
   Services = 'services',
   MetricTypes = 'metricTypes',
@@ -20,20 +22,22 @@ export interface VariableQueryData {
   services: Array<{ value: string; name: string }>;
 }
 
-export interface Target {
-  defaultProject: string;
-  unit: string;
+export interface StackdriverQuery extends DataQuery {
+  defaultProject?: string;
+  unit?: string;
   metricType: string;
-  service: string;
+  service?: string;
   refId: string;
   crossSeriesReducer: string;
-  alignmentPeriod: string;
+  alignmentPeriod?: string;
   perSeriesAligner: string;
-  groupBys: string[];
-  filters: string[];
-  aliasBy: string;
+  groupBys?: string[];
+  filters?: string[];
+  aliasBy?: string;
   metricKind: string;
   valueType: string;
+  datasourceId?: number;
+  view?: string;
 }
 
 export interface AnnotationTarget {

+ 2 - 2
public/app/plugins/datasource/testdata/QueryEditor.tsx

@@ -41,9 +41,9 @@ export class QueryEditor extends PureComponent<Props> {
   }
 
   onScenarioChange = (item: SelectOptionItem) => {
-    this.props.onQueryChange({
+    this.props.onChange({
+      ...this.props.query,
       scenarioId: item.value,
-      ...this.props.query
     });
   }
 

+ 20 - 9
public/app/plugins/panel/gauge/GaugePanel.tsx

@@ -9,28 +9,39 @@ import { Gauge } from '@grafana/ui';
 
 // Types
 import { GaugeOptions } from './types';
-import { PanelProps, NullValueMode } from '@grafana/ui/src/types';
+import { PanelProps, NullValueMode, TimeSeriesValue } from '@grafana/ui/src/types';
 import { ThemeProvider } from 'app/core/utils/ConfigProvider';
 
 interface Props extends PanelProps<GaugeOptions> {}
 
 export class GaugePanel extends PureComponent<Props> {
   render() {
-    const { timeSeries, width, height, onInterpolate, options } = this.props;
+    const { panelData, width, height, onInterpolate, options } = this.props;
 
     const prefix = onInterpolate(options.prefix);
     const suffix = onInterpolate(options.suffix);
-
-    const vmSeries = processTimeSeries({
-      timeSeries: timeSeries,
-      nullValueMode: NullValueMode.Null,
-    });
+    let value: TimeSeriesValue;
+
+    if (panelData.timeSeries) {
+      const vmSeries = processTimeSeries({
+        timeSeries: panelData.timeSeries,
+        nullValueMode: NullValueMode.Null,
+      });
+
+      if (vmSeries[0]) {
+        value = vmSeries[0].stats[options.stat];
+      } else {
+        value = null;
+      }
+    } else if (panelData.tableData) {
+      value = panelData.tableData.rows[0].find(prop => prop > 0);
+    }
 
     return (
       <ThemeProvider>
-        {(theme) => (
+        {theme => (
           <Gauge
-            timeSeries={vmSeries}
+            value={value}
             {...this.props.options}
             width={width}
             height={height}

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

@@ -281,7 +281,7 @@ class GraphCtrl extends MetricsPanelCtrl {
 
   toggleLegend() {
     this.panel.legend.show = !this.panel.legend.show;
-    this.refresh();
+    this.render();
   }
 
   legendValuesOptionChanged() {

+ 1 - 1
public/app/plugins/panel/graph/tab_legend.html

@@ -3,7 +3,7 @@
 		<h5 class="section-heading">Options</h5>
 		<gf-form-switch class="gf-form"
 			label="Show" label-class="width-7"
-			checked="ctrl.panel.legend.show" on-change="ctrl.refresh()">
+			checked="ctrl.panel.legend.show" on-change="ctrl.render()">
 		</gf-form-switch>
 		<gf-form-switch class="gf-form"
 			label="As Table" label-class="width-7"

+ 8 - 5
public/app/plugins/panel/graph2/GraphPanel.tsx

@@ -16,13 +16,16 @@ interface Props extends PanelProps<Options> {}
 
 export class GraphPanel extends PureComponent<Props> {
   render() {
-    const { timeSeries, timeRange, width, height } = this.props;
+    const { panelData, timeRange, width, height } = this.props;
     const { showLines, showBars, showPoints } = this.props.options;
 
-    const vmSeries = processTimeSeries({
-      timeSeries: timeSeries,
-      nullValueMode: NullValueMode.Ignore,
-    });
+    let vmSeries;
+    if (panelData.timeSeries) {
+      vmSeries = processTimeSeries({
+        timeSeries: panelData.timeSeries,
+        nullValueMode: NullValueMode.Ignore,
+      });
+    }
 
     return (
       <Graph

+ 22 - 0
public/app/routes/GrafanaCtrl.ts

@@ -280,6 +280,28 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
         if (popover.length > 0 && target.parents('.graph-legend').length === 0) {
           popover.hide();
         }
+
+        // hide time picker
+        const timePickerDropDownIsOpen = elem.find('.gf-timepicker-dropdown').length > 0;
+        if (timePickerDropDownIsOpen) {
+          const targetIsInTimePickerDropDown = target.parents('.gf-timepicker-dropdown').length > 0;
+          const targetIsInTimePickerNav = target.parents('.gf-timepicker-nav').length > 0;
+          const targetIsDatePickerRowBtn = target.parents('td[id^="datepicker-"]').length > 0;
+          const targetIsDatePickerHeaderBtn = target.parents('button[id^="datepicker-"]').length > 0;
+
+          if (
+            targetIsInTimePickerNav ||
+            targetIsInTimePickerDropDown ||
+            targetIsDatePickerRowBtn ||
+            targetIsDatePickerHeaderBtn
+          ) {
+            return;
+          }
+
+          scope.$apply(() => {
+            scope.appEvent('closeTimepicker');
+          });
+        }
       });
     },
   };

+ 2 - 2
public/app/store/configureStore.ts

@@ -1,6 +1,6 @@
 import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
 import thunk from 'redux-thunk';
-// import { createLogger } from 'redux-logger';
+import { createLogger } from 'redux-logger';
 import sharedReducers from 'app/core/reducers';
 import alertingReducers from 'app/features/alerting/state/reducers';
 import teamsReducers from 'app/features/teams/state/reducers';
@@ -39,7 +39,7 @@ export function configureStore() {
 
   if (process.env.NODE_ENV !== 'production') {
     // DEV builds we had the logger middleware
-    setStore(createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk))));
+    setStore(createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger()))));
   } else {
     setStore(createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk))));
   }

+ 26 - 11
public/app/types/explore.ts

@@ -1,5 +1,14 @@
+import { ComponentClass } from 'react';
 import { Value } from 'slate';
-import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi, QueryHint } from '@grafana/ui';
+import {
+  RawTimeRange,
+  TimeRange,
+  DataQuery,
+  DataSourceSelectItem,
+  DataSourceApi,
+  QueryHint,
+  ExploreStartPageProps,
+} from '@grafana/ui';
 
 import { Emitter } from 'app/core/core';
 import { LogsModel } from 'app/core/logs_model';
@@ -102,7 +111,7 @@ export interface ExploreItemState {
   /**
    * React component to be shown when no queries have been run yet, e.g., for a query language cheat sheet.
    */
-  StartPage?: any;
+  StartPage?: ComponentClass<ExploreStartPageProps>;
   /**
    * Width used for calculating the graph interval (can't have more datapoints than pixels)
    */
@@ -144,10 +153,10 @@ export interface ExploreItemState {
    */
   history: HistoryItem[];
   /**
-   * Initial queries for this Explore, e.g., set via URL. Each query will be
-   * converted to a query row. Query edits should be tracked in `modifiedQueries` though.
+   * Queries for this Explore, e.g., set via URL. Each query will be
+   * converted to a query row.
    */
-  initialQueries: DataQuery[];
+  queries: DataQuery[];
   /**
    * True if this Explore area has been initialized.
    * Used to distinguish URL state injection versus split view state injection.
@@ -162,12 +171,6 @@ export interface ExploreItemState {
    * Log query result to be displayed in the logs result viewer.
    */
   logsResult?: LogsModel;
-  /**
-   * Copy of `initialQueries` that tracks user edits.
-   * Don't connect this property to a react component as it is updated on every query change.
-   * Used when running queries. Needs to be reset to `initialQueries` when those are reset as well.
-   */
-  modifiedQueries: DataQuery[];
   /**
    * Query intervals for graph queries to determine how many datapoints to return.
    * Needs to be updated when `datasourceInstance` or `containerWidth` is changed.
@@ -229,12 +232,24 @@ export interface ExploreItemState {
    * Table model that combines all query table results into a single table.
    */
   tableResult?: TableModel;
+
+  /**
+   * React keys for rendering of QueryRows
+   */
+  queryKeys: string[];
+}
+
+export interface ExploreUIState {
+  showingTable: boolean;
+  showingGraph: boolean;
+  showingLogs: boolean;
 }
 
 export interface ExploreUrlState {
   datasource: string;
   queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
   range: RawTimeRange;
+  ui: ExploreUIState;
 }
 
 export interface HistoryItem<TQuery extends DataQuery = DataQuery> {

+ 1 - 1
public/img/icons_dark_theme/icon_advanced.svg

@@ -4,7 +4,7 @@
 <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
 	 width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
 <style type="text/css">
-	.st0{fill:#0A0A0C;}
+	.st0{fill:#161719;}
 	.st1{fill:#E3E2E2;}
 </style>
 <g>

+ 1 - 1
public/img/icons_dark_theme/icon_advanced_active.svg

@@ -5,7 +5,7 @@
 	 width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve">
 <style type="text/css">
 	.st0{fill:url(#SVGID_1_);}
-	.st1{fill:#0A0A0C;}
+	.st1{fill:#161719;}
 	.st2{fill:url(#SVGID_2_);}
 	.st3{fill:url(#SVGID_3_);}
 	.st4{fill:url(#SVGID_4_);}

Some files were not shown because too many files changed in this diff