فهرست منبع

Merge remote-tracking branch 'grafana/master'

* grafana/master: (54 commits)
  now /api/login/ping returns Response
  fix: Explore: Query wrapping on long queries #15222
  fix: Set ace editor min height to avoid problem with scrollbar overlapping ace content #15122
  fix: Data source picker in panel queries options should overlap content below, including ace scrollbar #15122
  fix util for splitting host and port
  changelog: add notes about closing #14231
  fixing logging action
  devenv: switching back using loki master plus various fixes
  Fix save provisioned dashboard modal
  Add AWS/Neptune to metricsMap and dimensionsMap
  did not add file, removing centerered
  Legend toggle should only trigger a re-render, not a refresh
  first stuff
  updated snapshot
  Adding pointer to colorpicker
  Minor post review changes
  More style tweaks to panel option group add button
  Made some style tweaks
  setting margin on label
  Make runQueries action independent from datasource loading
  ...
ryan 6 سال پیش
والد
کامیت
693bb43452
100فایلهای تغییر یافته به همراه1569 افزوده شده و 781 حذف شده
  1. 1 0
      CHANGELOG.md
  2. 37 15
      README.md
  3. 3 0
      conf/defaults.ini
  4. 3 0
      conf/sample.ini
  5. 27 0
      devenv/docker/blocks/loki/config.yaml
  6. 2 10
      devenv/docker/blocks/loki/docker-compose.yaml
  7. 1 0
      packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
  8. 1 2
      packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
  9. 4 22
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  10. 23 11
      packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
  11. 41 2
      packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
  12. 1 1
      packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx
  13. 7 7
      packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
  14. 10 0
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx
  15. 1 1
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
  16. 3 11
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
  17. 29 47
      packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
  18. 1 1
      packages/grafana-ui/src/types/datasource.ts
  19. 1 1
      packages/grafana-ui/src/types/panel.ts
  20. 2 2
      packages/grafana-ui/src/types/plugin.ts
  21. 2 2
      pkg/api/api.go
  22. 1 1
      pkg/api/common_test.go
  23. 1 1
      pkg/api/login.go
  24. 1 1
      pkg/middleware/middleware_test.go
  25. 16 3
      pkg/services/auth/auth_token.go
  26. 39 0
      pkg/services/auth/auth_token_test.go
  27. 1 4
      pkg/services/sqlstore/sqlstore.go
  28. 16 0
      pkg/setting/setting.go
  29. 2 0
      pkg/tsdb/cloudwatch/metric_find_query.go
  30. 1 4
      pkg/tsdb/mssql/mssql.go
  31. 0 25
      pkg/util/ip.go
  32. 31 6
      pkg/util/ip_address.go
  33. 83 1
      pkg/util/ip_address_test.go
  34. 0 43
      pkg/util/ip_test.go
  35. 1 2
      public/app/core/components/Select/MetricSelect.tsx
  36. 2 3
      public/app/core/logs_model.ts
  37. 1 102
      public/app/core/profiler.ts
  38. 83 0
      public/app/core/redux/actionCreatorFactory.test.ts
  39. 57 0
      public/app/core/redux/actionCreatorFactory.ts
  40. 4 0
      public/app/core/redux/index.ts
  41. 97 0
      public/app/core/redux/reducerFactory.test.ts
  42. 45 0
      public/app/core/redux/reducerFactory.ts
  43. 26 11
      public/app/core/services/backend_srv.ts
  44. 1 1
      public/app/core/services/keybindingSrv.ts
  45. 30 2
      public/app/core/utils/explore.test.ts
  46. 36 4
      public/app/core/utils/explore.ts
  47. 18 13
      public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
  48. 1 0
      public/app/features/dashboard/components/SaveModals/index.ts
  49. 123 0
      public/app/features/dashboard/containers/SoloPanelPage.tsx
  50. 0 3
      public/app/features/dashboard/dashgrid/DataPanel.tsx
  51. 11 4
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  52. 0 11
      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. 1 1
      public/app/features/dashboard/state/DashboardModel.ts
  56. 13 0
      public/app/features/dashboard/state/PanelModel.ts
  57. 2 2
      public/app/features/datasources/DataSourcesListItem.tsx
  58. 6 5
      public/app/features/datasources/DataSourcesListPage.test.tsx
  59. 1 0
      public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap
  60. 3 2
      public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
  61. 16 111
      public/app/features/datasources/state/actions.ts
  62. 137 0
      public/app/features/datasources/state/reducers.test.ts
  63. 70 39
      public/app/features/datasources/state/reducers.ts
  64. 7 5
      public/app/features/explore/Explore.tsx
  65. 4 1
      public/app/features/explore/Logs.tsx
  66. 13 4
      public/app/features/explore/QueryField.tsx
  67. 6 2
      public/app/features/explore/QueryRow.tsx
  68. 2 0
      public/app/features/explore/state/actionTypes.ts
  69. 72 35
      public/app/features/explore/state/actions.ts
  70. 2 1
      public/app/features/explore/state/reducers.ts
  71. 0 1
      public/app/features/panel/all.ts
  72. 0 11
      public/app/features/panel/metrics_panel_ctrl.ts
  73. 6 19
      public/app/features/panel/panel_ctrl.ts
  74. 8 6
      public/app/features/panel/panel_directive.ts
  75. 0 4
      public/app/features/panel/partials/soloPanel.html
  76. 0 58
      public/app/features/panel/solo_panel_ctrl.ts
  77. 80 0
      public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx
  78. 3 2
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  79. 41 2
      public/app/plugins/datasource/loki/datasource.test.ts
  80. 55 43
      public/app/plugins/datasource/loki/datasource.ts
  81. 2 0
      public/app/plugins/datasource/loki/module.ts
  82. 3 1
      public/app/plugins/datasource/loki/plugin.json
  83. 3 0
      public/app/plugins/datasource/loki/types.ts
  84. 3 3
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  85. 2 2
      public/app/plugins/datasource/testdata/QueryEditor.tsx
  86. 20 9
      public/app/plugins/panel/gauge/GaugePanel.tsx
  87. 1 1
      public/app/plugins/panel/graph/module.ts
  88. 1 1
      public/app/plugins/panel/graph/tab_legend.html
  89. 8 5
      public/app/plugins/panel/graph2/GraphPanel.tsx
  90. 0 1
      public/app/plugins/panel/table/module.ts
  91. 7 1
      public/app/routes/ReactContainer.tsx
  92. 11 8
      public/app/routes/routes.ts
  93. 7 0
      public/app/types/explore.ts
  94. 1 1
      public/sass/_variables.scss
  95. 4 0
      public/sass/components/_cards.scss
  96. 1 1
      public/sass/components/_code_editor.scss
  97. 4 0
      public/sass/components/_gf-form.scss
  98. 1 0
      public/sass/components/_slate_editor.scss
  99. 1 1
      public/sass/components/_submenu.scss
  100. 0 1
      public/sass/components/_toolbar.scss

+ 1 - 0
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 ### Minor
 * **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
+* **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson)
 
 # 6.0.0-beta1 (2019-01-30)
 

+ 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

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

+ 2 - 2
packages/grafana-ui/src/types/plugin.ts

@@ -44,8 +44,8 @@ export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
 export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
   datasource: DSType;
   query: TQuery;
-  onExecuteQuery?: () => void;
-  onQueryChange?: (value: TQuery) => void;
+  onRunQuery: () => void;
+  onChange: (value: TQuery) => void;
 }
 
 export interface PluginExports {

+ 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

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

@@ -95,6 +95,7 @@ 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"},
@@ -149,6 +150,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"},

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

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

@@ -1,106 +1,20 @@
-import $ from 'jquery';
-import angular from 'angular';
 
 export class Profiler {
   panelsRendered: number;
   enabled: boolean;
-  panelsInitCount: any;
-  timings: any;
-  digestCounter: any;
   $rootScope: any;
-  scopeCount: any;
   window: any;
 
   init(config, $rootScope) {
-    this.enabled = config.buildInfo.env === 'development';
-    this.timings = {};
-    this.timings.appStart = { loadStart: new Date().getTime() };
     this.$rootScope = $rootScope;
     this.window = window;
 
     if (!this.enabled) {
       return;
     }
-
-    $rootScope.$watch(
-      () => {
-        this.digestCounter++;
-        return false;
-      },
-      () => {}
-    );
-
-    $rootScope.onAppEvent('refresh', this.refresh.bind(this), $rootScope);
-    $rootScope.onAppEvent('dashboard-fetch-end', this.dashboardFetched.bind(this), $rootScope);
-    $rootScope.onAppEvent('dashboard-initialized', this.dashboardInitialized.bind(this), $rootScope);
-    $rootScope.onAppEvent('panel-initialized', this.panelInitialized.bind(this), $rootScope);
-  }
-
-  refresh() {
-    this.timings.query = 0;
-    this.timings.render = 0;
-
-    setTimeout(() => {
-      console.log('panel count: ' + this.panelsInitCount);
-      console.log('total query: ' + this.timings.query);
-      console.log('total render: ' + this.timings.render);
-      console.log('avg render: ' + this.timings.render / this.panelsInitCount);
-    }, 5000);
-  }
-
-  dashboardFetched() {
-    this.timings.dashboardLoadStart = new Date().getTime();
-    this.panelsInitCount = 0;
-    this.digestCounter = 0;
-    this.panelsInitCount = 0;
-    this.panelsRendered = 0;
-    this.timings.query = 0;
-    this.timings.render = 0;
   }
 
-  dashboardInitialized() {
-    setTimeout(() => {
-      console.log('Dashboard::Performance Total Digests: ' + this.digestCounter);
-      console.log('Dashboard::Performance Total Watchers: ' + this.getTotalWatcherCount());
-      console.log('Dashboard::Performance Total ScopeCount: ' + this.scopeCount);
-
-      const timeTaken = this.timings.lastPanelInitializedAt - this.timings.dashboardLoadStart;
-      console.log('Dashboard::Performance All panels initialized in ' + timeTaken + ' ms');
-
-      // measure digest performance
-      const rootDigestStart = window.performance.now();
-      for (let i = 0; i < 30; i++) {
-        this.$rootScope.$apply();
-      }
-
-      console.log('Dashboard::Performance Root Digest ' + (window.performance.now() - rootDigestStart) / 30);
-    }, 3000);
-  }
-
-  getTotalWatcherCount() {
-    let count = 0;
-    let scopes = 0;
-    const root = $(document.getElementsByTagName('body'));
-
-    const f = element => {
-      if (element.data().hasOwnProperty('$scope')) {
-        scopes++;
-        angular.forEach(element.data().$scope.$$watchers, () => {
-          count++;
-        });
-      }
-
-      angular.forEach(element.children(), childElement => {
-        f($(childElement));
-      });
-    };
-
-    f(root);
-    this.scopeCount = scopes;
-    return count;
-  }
-
-  renderingCompleted(panelId, panelTimings) {
+  renderingCompleted(panelId) {
     // add render counter to root scope
     // used by phantomjs render.js to know when panel has rendered
     this.panelsRendered = (this.panelsRendered || 0) + 1;
@@ -108,21 +22,6 @@ export class Profiler {
     // this window variable is used by backend rendering tools to know
     // all panels have completed rendering
     this.window.panelsRendered = this.panelsRendered;
-
-    if (this.enabled) {
-      panelTimings.renderEnd = new Date().getTime();
-      this.timings.query += panelTimings.queryEnd - panelTimings.queryStart;
-      this.timings.render += panelTimings.renderEnd - panelTimings.renderStart;
-    }
-  }
-
-  panelInitialized() {
-    if (!this.enabled) {
-      return;
-    }
-
-    this.panelsInitCount++;
-    this.timings.lastPanelInitializedAt = new Date().getTime();
   }
 }
 

+ 83 - 0
public/app/core/redux/actionCreatorFactory.test.ts

@@ -0,0 +1,83 @@
+import {
+  actionCreatorFactory,
+  resetAllActionCreatorTypes,
+  noPayloadActionCreatorFactory,
+} from './actionCreatorFactory';
+
+interface Dummy {
+  n: number;
+  s: string;
+  o: {
+    n: number;
+    s: string;
+    b: boolean;
+  };
+  b: boolean;
+}
+
+const setup = (payload?: Dummy) => {
+  resetAllActionCreatorTypes();
+  const actionCreator = actionCreatorFactory<Dummy>('dummy').create();
+  const noPayloadactionCreator = noPayloadActionCreatorFactory('NoPayload').create();
+  const result = actionCreator(payload);
+  const noPayloadResult = noPayloadactionCreator();
+
+  return { actionCreator, noPayloadactionCreator, result, noPayloadResult };
+};
+
+describe('actionCreatorFactory', () => {
+  describe('when calling create', () => {
+    it('then it should create correct type string', () => {
+      const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
+      const { actionCreator, result } = setup(payload);
+
+      expect(actionCreator.type).toEqual('dummy');
+      expect(result.type).toEqual('dummy');
+    });
+
+    it('then it should create correct payload', () => {
+      const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
+      const { result } = setup(payload);
+
+      expect(result.payload).toEqual(payload);
+    });
+  });
+
+  describe('when calling create with existing type', () => {
+    it('then it should throw error', () => {
+      const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
+      setup(payload);
+
+      expect(() => {
+        noPayloadActionCreatorFactory('DuMmY').create();
+      }).toThrow();
+    });
+  });
+});
+
+describe('noPayloadActionCreatorFactory', () => {
+  describe('when calling create', () => {
+    it('then it should create correct type string', () => {
+      const { noPayloadResult, noPayloadactionCreator } = setup();
+
+      expect(noPayloadactionCreator.type).toEqual('NoPayload');
+      expect(noPayloadResult.type).toEqual('NoPayload');
+    });
+
+    it('then it should create correct payload', () => {
+      const { noPayloadResult } = setup();
+
+      expect(noPayloadResult.payload).toBeUndefined();
+    });
+  });
+
+  describe('when calling create with existing type', () => {
+    it('then it should throw error', () => {
+      setup();
+
+      expect(() => {
+        actionCreatorFactory<Dummy>('nOpAyLoAd').create();
+      }).toThrow();
+    });
+  });
+});

+ 57 - 0
public/app/core/redux/actionCreatorFactory.ts

@@ -0,0 +1,57 @@
+import { Action } from 'redux';
+
+const allActionCreators: string[] = [];
+
+export interface ActionOf<Payload> extends Action {
+  readonly type: string;
+  readonly payload: Payload;
+}
+
+export interface ActionCreator<Payload> {
+  readonly type: string;
+  (payload: Payload): ActionOf<Payload>;
+}
+
+export interface NoPayloadActionCreator {
+  readonly type: string;
+  (): ActionOf<undefined>;
+}
+
+export interface ActionCreatorFactory<Payload> {
+  create: () => ActionCreator<Payload>;
+}
+
+export interface NoPayloadActionCreatorFactory {
+  create: () => NoPayloadActionCreator;
+}
+
+export const actionCreatorFactory = <Payload>(type: string): ActionCreatorFactory<Payload> => {
+  const create = (): ActionCreator<Payload> => {
+    return Object.assign((payload: Payload): ActionOf<Payload> => ({ type, payload }), { type });
+  };
+
+  if (allActionCreators.some(t => (t && type ? t.toLocaleUpperCase() === type.toLocaleUpperCase() : false))) {
+    throw new Error(`There is already an actionCreator defined with the type ${type}`);
+  }
+
+  allActionCreators.push(type);
+
+  return { create };
+};
+
+export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCreatorFactory => {
+  const create = (): NoPayloadActionCreator => {
+    return Object.assign((): ActionOf<undefined> => ({ type, payload: undefined }), { type });
+  };
+
+  if (allActionCreators.some(t => (t && type ? t.toLocaleUpperCase() === type.toLocaleUpperCase() : false))) {
+    throw new Error(`There is already an actionCreator defined with the type ${type}`);
+  }
+
+  allActionCreators.push(type);
+
+  return { create };
+};
+
+// Should only be used by tests
+export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);

+ 4 - 0
public/app/core/redux/index.ts

@@ -0,0 +1,4 @@
+import { actionCreatorFactory } from './actionCreatorFactory';
+import { reducerFactory } from './reducerFactory';
+
+export { actionCreatorFactory, reducerFactory };

+ 97 - 0
public/app/core/redux/reducerFactory.test.ts

@@ -0,0 +1,97 @@
+import { reducerFactory } from './reducerFactory';
+import { actionCreatorFactory, ActionOf } from './actionCreatorFactory';
+
+interface DummyReducerState {
+  n: number;
+  s: string;
+  b: boolean;
+  o: {
+    n: number;
+    s: string;
+    b: boolean;
+  };
+}
+
+const dummyReducerIntialState: DummyReducerState = {
+  n: 1,
+  s: 'One',
+  b: true,
+  o: {
+    n: 2,
+    s: 'two',
+    b: false,
+  },
+};
+
+const dummyActionCreator = actionCreatorFactory<DummyReducerState>('dummy').create();
+
+const dummyReducer = reducerFactory(dummyReducerIntialState)
+  .addMapper({
+    filter: dummyActionCreator,
+    mapper: (state, action) => ({ ...state, ...action.payload }),
+  })
+  .create();
+
+describe('reducerFactory', () => {
+  describe('given it is created with a defined handler', () => {
+    describe('when reducer is called with no state', () => {
+      describe('and with an action that the handler can not handle', () => {
+        it('then the resulting state should be intial state', () => {
+          const result = dummyReducer(undefined as DummyReducerState, {} as ActionOf<any>);
+
+          expect(result).toEqual(dummyReducerIntialState);
+        });
+      });
+
+      describe('and with an action that the handler can handle', () => {
+        it('then the resulting state should correct', () => {
+          const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
+          const result = dummyReducer(undefined as DummyReducerState, dummyActionCreator(payload));
+
+          expect(result).toEqual(payload);
+        });
+      });
+    });
+
+    describe('when reducer is called with a state', () => {
+      describe('and with an action that the handler can not handle', () => {
+        it('then the resulting state should be intial state', () => {
+          const result = dummyReducer(dummyReducerIntialState, {} as ActionOf<any>);
+
+          expect(result).toEqual(dummyReducerIntialState);
+        });
+      });
+
+      describe('and with an action that the handler can handle', () => {
+        it('then the resulting state should correct', () => {
+          const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
+          const result = dummyReducer(dummyReducerIntialState, dummyActionCreator(payload));
+
+          expect(result).toEqual(payload);
+        });
+      });
+    });
+  });
+
+  describe('given a handler is added', () => {
+    describe('when a handler with the same creator is added', () => {
+      it('then is should throw', () => {
+        const faultyReducer = reducerFactory(dummyReducerIntialState).addMapper({
+          filter: dummyActionCreator,
+          mapper: (state, action) => {
+            return { ...state, ...action.payload };
+          },
+        });
+
+        expect(() => {
+          faultyReducer.addMapper({
+            filter: dummyActionCreator,
+            mapper: state => {
+              return state;
+            },
+          });
+        }).toThrow();
+      });
+    });
+  });
+});

+ 45 - 0
public/app/core/redux/reducerFactory.ts

@@ -0,0 +1,45 @@
+import { ActionOf, ActionCreator } from './actionCreatorFactory';
+import { Reducer } from 'redux';
+
+export type Mapper<State, Payload> = (state: State, action: ActionOf<Payload>) => State;
+
+export interface MapperConfig<State, Payload> {
+  filter: ActionCreator<Payload>;
+  mapper: Mapper<State, Payload>;
+}
+
+export interface AddMapper<State> {
+  addMapper: <Payload>(config: MapperConfig<State, Payload>) => CreateReducer<State>;
+}
+
+export interface CreateReducer<State> extends AddMapper<State> {
+  create: () => Reducer<State, ActionOf<any>>;
+}
+
+export const reducerFactory = <State>(initialState: State): AddMapper<State> => {
+  const allMappers: { [key: string]: Mapper<State, any> } = {};
+
+  const addMapper = <Payload>(config: MapperConfig<State, Payload>): CreateReducer<State> => {
+    if (allMappers[config.filter.type]) {
+      throw new Error(`There is already a mapper defined with the type ${config.filter.type}`);
+    }
+
+    allMappers[config.filter.type] = config.mapper;
+
+    return instance;
+  };
+
+  const create = (): Reducer<State, ActionOf<any>> => (state: State = initialState, action: ActionOf<any>): State => {
+    const mapper = allMappers[action.type];
+
+    if (mapper) {
+      return mapper(state, action);
+    }
+
+    return state;
+  };
+
+  const instance: CreateReducer<State> = { addMapper, create };
+
+  return instance;
+};

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

+ 36 - 4
public/app/core/utils/explore.ts

@@ -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);
 }

+ 18 - 13
public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx

@@ -18,13 +18,18 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
       collapsed: this.props.panel.collapsed,
     };
 
-    this.toggle = this.toggle.bind(this);
-    this.openSettings = this.openSettings.bind(this);
-    this.delete = this.delete.bind(this);
-    this.update = this.update.bind(this);
+    appEvents.on('template-variable-value-updated', this.onVariableUpdated);
   }
 
-  toggle() {
+  componentWillUnmount() {
+    appEvents.off('template-variable-value-updated', this.onVariableUpdated);
+  }
+
+  onVariableUpdated = () => {
+    this.forceUpdate();
+  }
+
+  onToggle = () => {
     this.props.dashboard.toggleRow(this.props.panel);
 
     this.setState(prevState => {
@@ -32,23 +37,23 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
     });
   }
 
-  update() {
+  onUpdate = () => {
     this.props.dashboard.processRepeats();
     this.forceUpdate();
   }
 
-  openSettings() {
+  onOpenSettings = () => {
     appEvents.emit('show-modal', {
       templateHtml: `<row-options row="model.row" on-updated="model.onUpdated()" dismiss="dismiss()"></row-options>`,
       modalClass: 'modal--narrow',
       model: {
         row: this.props.panel,
-        onUpdated: this.update.bind(this),
+        onUpdated: this.onUpdate,
       },
     });
   }
 
-  delete() {
+  onDelete = () => {
     appEvents.emit('confirm-modal', {
       title: 'Delete Row',
       text: 'Are you sure you want to remove this row and all its panels?',
@@ -81,7 +86,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
 
     return (
       <div className={classes}>
-        <a className="dashboard-row__title pointer" onClick={this.toggle}>
+        <a className="dashboard-row__title pointer" onClick={this.onToggle}>
           <i className={chevronClass} />
           {title}
           <span className="dashboard-row__panel_count">
@@ -90,16 +95,16 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
         </a>
         {canEdit && (
           <div className="dashboard-row__actions">
-            <a className="pointer" onClick={this.openSettings}>
+            <a className="pointer" onClick={this.onOpenSettings}>
               <i className="fa fa-cog" />
             </a>
-            <a className="pointer" onClick={this.delete}>
+            <a className="pointer" onClick={this.onDelete}>
               <i className="fa fa-trash" />
             </a>
           </div>
         )}
         {this.state.collapsed === true && (
-          <div className="dashboard-row__toggle-target" onClick={this.toggle}>
+          <div className="dashboard-row__toggle-target" onClick={this.onToggle}>
             &nbsp;
           </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';

+ 123 - 0
public/app/features/dashboard/containers/SoloPanelPage.tsx

@@ -0,0 +1,123 @@
+// Libraries
+import React, { Component } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+// Utils & Services
+import appEvents from 'app/core/app_events';
+import locationUtil from 'app/core/utils/location_util';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+
+// Components
+import { DashboardPanel } from '../dashgrid/DashboardPanel';
+
+// Redux
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { StoreState } from 'app/types';
+import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
+
+interface Props {
+  panelId: string;
+  urlUid?: string;
+  urlSlug?: string;
+  urlType?: string;
+  $scope: any;
+  $injector: any;
+  updateLocation: typeof updateLocation;
+}
+
+interface State {
+  panel: PanelModel | null;
+  dashboard: DashboardModel | null;
+  notFound: boolean;
+}
+
+export class SoloPanelPage extends Component<Props, State> {
+
+  state: State = {
+    panel: null,
+    dashboard: null,
+    notFound: false,
+  };
+
+  componentDidMount() {
+    const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
+
+    // handle old urls with no uid
+    if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
+      this.redirectToNewUrl();
+      return;
+    }
+
+    const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
+
+    // subscribe to event to know when dashboard controller is done with inititalization
+    appEvents.on('dashboard-initialized', this.onDashoardInitialized);
+
+    dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
+      result.meta.soloMode = true;
+      $scope.initDashboard(result, $scope);
+    });
+  }
+
+  redirectToNewUrl() {
+    getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
+      if (res) {
+        const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
+        this.props.updateLocation(url);
+      }
+    });
+  }
+
+  onDashoardInitialized = () => {
+    const { $scope, panelId } = this.props;
+
+    const dashboard: DashboardModel = $scope.dashboard;
+    const panel = dashboard.getPanelById(parseInt(panelId, 10));
+
+    if (!panel) {
+      this.setState({ notFound: true });
+      return;
+    }
+
+    this.setState({ dashboard, panel });
+  };
+
+  render() {
+    const { panelId } = this.props;
+    const { notFound, panel, dashboard } = this.state;
+
+    if (notFound) {
+      return (
+        <div className="alert alert-error">
+          Panel with id { panelId } not found
+        </div>
+      );
+    }
+
+    if (!panel) {
+      return <div>Loading & initializing dashboard</div>;
+    }
+
+    return (
+      <div className="panel-solo">
+        <DashboardPanel dashboard={dashboard} panel={panel} isEditing={false} isFullscreen={false} />
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = (state: StoreState) => ({
+  urlUid: state.location.routeParams.uid,
+  urlSlug: state.location.routeParams.slug,
+  urlType: state.location.routeParams.type,
+  panelId: state.location.query.panelId
+});
+
+const mapDispatchToProps = {
+  updateLocation
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));

+ 0 - 3
public/app/features/dashboard/dashgrid/DataPanel.tsx

@@ -135,11 +135,8 @@ export class DataPanel extends Component<Props, State> {
         cacheTimeout: null,
       };
 
-      console.log('Issuing DataPanel query', queryOptions);
       const resp = await ds.query(queryOptions);
 
-      console.log('Issuing DataPanel query Resp', resp);
-
       if (this.isUnmounted) {
         return;
       }

+ 11 - 4
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -12,11 +12,12 @@ import { DataPanel } from './DataPanel';
 // Utils
 import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
 import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
+import { profiler } from 'app/core/profiler';
 
 // Types
 import { DashboardModel, PanelModel } from '../state';
 import { PanelPlugin } from 'app/types';
-import { TimeRange } from '@grafana/ui';
+import { TimeRange, LoadingState } from '@grafana/ui';
 
 import variables from 'sass/_variables.scss';
 import templateSrv from 'app/features/templating/template_srv';
@@ -93,16 +94,22 @@ 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;
 
+    // This is only done to increase a counter that is used by backend
+    // image rendering (phantomjs/headless chrome) to know when to capture image
+    if (loading === LoadingState.Done) {
+      profiler.renderingCompleted(panel.id);
+    }
+
     return (
       <div className="panel-content">
         <PanelComponent
           loading={loading}
-          timeSeries={timeSeries}
+          panelData={panelData}
           timeRange={timeRange}
           options={panel.getOptions(plugin.exports.PanelDefaults)}
           width={width - 2 * variables.panelHorizontalPadding}
@@ -151,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>
               )}

+ 0 - 11
public/app/features/dashboard/panel_editor/PanelEditor.tsx

@@ -101,17 +101,6 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
 
     return (
       <div className="panel-editor-container__editor">
-        {
-          // <div className="panel-editor__close">
-          //   <i className="fa fa-arrow-left" />
-          // </div>
-          // <div className="panel-editor-resizer">
-          //   <div className="panel-editor-resizer__handle">
-          //     <div className="panel-editor-resizer__handle-dots" />
-          //   </div>
-          // </div>
-        }
-
         <div className="panel-editor-tabs">
           {tabs.map(tab => {
             return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;

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

+ 1 - 1
public/app/features/dashboard/state/DashboardModel.ts

@@ -271,7 +271,7 @@ export class DashboardModel {
     }
   }
 
-  getPanelById(id) {
+  getPanelById(id): PanelModel {
     for (const panel of this.panels) {
       if (panel.id === id) {
         return panel;

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

+ 6 - 5
public/app/features/datasources/DataSourcesListPage.test.tsx

@@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
 import { DataSourceSettings } from '@grafana/ui/src/types';
 import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
 import { getMockDataSources } from './__mocks__/dataSourcesMocks';
+import { setDataSourcesSearchQuery, setDataSourcesLayoutMode } from './state/actions';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
@@ -13,16 +14,16 @@ const setup = (propOverrides?: object) => {
     loadDataSources: jest.fn(),
     navModel: {
       main: {
-        text: 'Configuration'
+        text: 'Configuration',
       },
       node: {
-        text: 'Data Sources'
-      }
+        text: 'Data Sources',
+      },
     } as NavModel,
     dataSourcesCount: 0,
     searchQuery: '',
-    setDataSourcesSearchQuery: jest.fn(),
-    setDataSourcesLayoutMode: jest.fn(),
+    setDataSourcesSearchQuery,
+    setDataSourcesLayoutMode,
     hasFetched: false,
   };
 

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

+ 3 - 2
public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx

@@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
 import { DataSourceSettings } from '@grafana/ui';
 import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
 import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
+import { setDataSourceName, setIsDefault } from '../state/actions';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
@@ -14,9 +15,9 @@ const setup = (propOverrides?: object) => {
     pageId: 1,
     deleteDataSource: jest.fn(),
     loadDataSource: jest.fn(),
-    setDataSourceName: jest.fn(),
+    setDataSourceName,
     updateDataSource: jest.fn(),
-    setIsDefault: jest.fn(),
+    setIsDefault,
   };
 
   Object.assign(props, propOverrides);

+ 16 - 111
public/app/features/datasources/state/actions.ts

@@ -8,131 +8,36 @@ import { UpdateLocationAction } from 'app/core/actions/location';
 import { buildNavModel } from './navModel';
 import { DataSourceSettings } from '@grafana/ui/src/types';
 import { Plugin, StoreState } from 'app/types';
+import { actionCreatorFactory } from 'app/core/redux';
+import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory';
 
-export enum ActionTypes {
-  LoadDataSources = 'LOAD_DATA_SOURCES',
-  LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
-  LoadedDataSourceTypes = 'LOADED_DATA_SOURCE_TYPES',
-  LoadDataSource = 'LOAD_DATA_SOURCE',
-  LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
-  SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
-  SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
-  SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
-  SetDataSourceName = 'SET_DATA_SOURCE_NAME',
-  SetIsDefault = 'SET_IS_DEFAULT',
-}
+export const dataSourceLoaded = actionCreatorFactory<DataSourceSettings>('LOAD_DATA_SOURCE').create();
 
-interface LoadDataSourcesAction {
-  type: ActionTypes.LoadDataSources;
-  payload: DataSourceSettings[];
-}
+export const dataSourcesLoaded = actionCreatorFactory<DataSourceSettings[]>('LOAD_DATA_SOURCES').create();
 
-interface SetDataSourcesSearchQueryAction {
-  type: ActionTypes.SetDataSourcesSearchQuery;
-  payload: string;
-}
+export const dataSourceMetaLoaded = actionCreatorFactory<Plugin>('LOAD_DATA_SOURCE_META').create();
 
-interface SetDataSourcesLayoutModeAction {
-  type: ActionTypes.SetDataSourcesLayoutMode;
-  payload: LayoutMode;
-}
+export const dataSourceTypesLoad = noPayloadActionCreatorFactory('LOAD_DATA_SOURCE_TYPES').create();
 
-interface LoadDataSourceTypesAction {
-  type: ActionTypes.LoadDataSourceTypes;
-}
+export const dataSourceTypesLoaded = actionCreatorFactory<Plugin[]>('LOADED_DATA_SOURCE_TYPES').create();
 
-interface LoadedDataSourceTypesAction {
-  type: ActionTypes.LoadedDataSourceTypes;
-  payload: Plugin[];
-}
+export const setDataSourcesSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCES_SEARCH_QUERY').create();
 
-interface SetDataSourceTypeSearchQueryAction {
-  type: ActionTypes.SetDataSourceTypeSearchQuery;
-  payload: string;
-}
+export const setDataSourcesLayoutMode = actionCreatorFactory<LayoutMode>('SET_DATA_SOURCES_LAYOUT_MODE').create();
 
-interface LoadDataSourceAction {
-  type: ActionTypes.LoadDataSource;
-  payload: DataSourceSettings;
-}
-
-interface LoadDataSourceMetaAction {
-  type: ActionTypes.LoadDataSourceMeta;
-  payload: Plugin;
-}
+export const setDataSourceTypeSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create();
 
-interface SetDataSourceNameAction {
-  type: ActionTypes.SetDataSourceName;
-  payload: string;
-}
-
-interface SetIsDefaultAction {
-  type: ActionTypes.SetIsDefault;
-  payload: boolean;
-}
+export const setDataSourceName = actionCreatorFactory<string>('SET_DATA_SOURCE_NAME').create();
 
-const dataSourcesLoaded = (dataSources: DataSourceSettings[]): LoadDataSourcesAction => ({
-  type: ActionTypes.LoadDataSources,
-  payload: dataSources,
-});
-
-const dataSourceLoaded = (dataSource: DataSourceSettings): LoadDataSourceAction => ({
-  type: ActionTypes.LoadDataSource,
-  payload: dataSource,
-});
-
-const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
-  type: ActionTypes.LoadDataSourceMeta,
-  payload: dataSourceMeta,
-});
-
-const dataSourceTypesLoad = (): LoadDataSourceTypesAction => ({
-  type: ActionTypes.LoadDataSourceTypes,
-});
-
-const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadedDataSourceTypesAction => ({
-  type: ActionTypes.LoadedDataSourceTypes,
-  payload: dataSourceTypes,
-});
-
-export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({
-  type: ActionTypes.SetDataSourcesSearchQuery,
-  payload: searchQuery,
-});
-
-export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({
-  type: ActionTypes.SetDataSourcesLayoutMode,
-  payload: layoutMode,
-});
-
-export const setDataSourceTypeSearchQuery = (query: string): SetDataSourceTypeSearchQueryAction => ({
-  type: ActionTypes.SetDataSourceTypeSearchQuery,
-  payload: query,
-});
-
-export const setDataSourceName = (name: string) => ({
-  type: ActionTypes.SetDataSourceName,
-  payload: name,
-});
-
-export const setIsDefault = (state: boolean) => ({
-  type: ActionTypes.SetIsDefault,
-  payload: state,
-});
+export const setIsDefault = actionCreatorFactory<boolean>('SET_IS_DEFAULT').create();
 
 export type Action =
-  | LoadDataSourcesAction
-  | SetDataSourcesSearchQueryAction
-  | SetDataSourcesLayoutModeAction
   | UpdateLocationAction
-  | LoadDataSourceTypesAction
-  | LoadedDataSourceTypesAction
-  | SetDataSourceTypeSearchQueryAction
-  | LoadDataSourceAction
   | UpdateNavIndexAction
-  | LoadDataSourceMetaAction
-  | SetDataSourceNameAction
-  | SetIsDefaultAction;
+  | ActionOf<DataSourceSettings>
+  | ActionOf<DataSourceSettings[]>
+  | ActionOf<Plugin>
+  | ActionOf<Plugin[]>;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 

+ 137 - 0
public/app/features/datasources/state/reducers.test.ts

@@ -0,0 +1,137 @@
+import { reducerTester } from 'test/core/redux/reducerTester';
+import { dataSourcesReducer, initialState } from './reducers';
+import {
+  dataSourcesLoaded,
+  dataSourceLoaded,
+  setDataSourcesSearchQuery,
+  setDataSourcesLayoutMode,
+  dataSourceTypesLoad,
+  dataSourceTypesLoaded,
+  setDataSourceTypeSearchQuery,
+  dataSourceMetaLoaded,
+  setDataSourceName,
+  setIsDefault,
+} from './actions';
+import { getMockDataSources, getMockDataSource } from '../__mocks__/dataSourcesMocks';
+import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
+import { DataSourcesState } from 'app/types';
+import { PluginMetaInfo } from '@grafana/ui';
+
+const mockPlugin = () => ({
+  defaultNavUrl: 'defaultNavUrl',
+  enabled: true,
+  hasUpdate: true,
+  id: 'id',
+  info: {} as PluginMetaInfo,
+  latestVersion: 'latestVersion',
+  name: 'name',
+  pinned: true,
+  state: 'state',
+  type: 'type',
+  module: {},
+});
+
+describe('dataSourcesReducer', () => {
+  describe('when dataSourcesLoaded is dispatched', () => {
+    it('then state should be correct', () => {
+      const dataSources = getMockDataSources(0);
+
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(dataSourcesLoaded(dataSources))
+        .thenStateShouldEqual({ ...initialState, hasFetched: true, dataSources, dataSourcesCount: 1 });
+    });
+  });
+
+  describe('when dataSourceLoaded is dispatched', () => {
+    it('then state should be correct', () => {
+      const dataSource = getMockDataSource();
+
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(dataSourceLoaded(dataSource))
+        .thenStateShouldEqual({ ...initialState, dataSource });
+    });
+  });
+
+  describe('when setDataSourcesSearchQuery is dispatched', () => {
+    it('then state should be correct', () => {
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(setDataSourcesSearchQuery('some query'))
+        .thenStateShouldEqual({ ...initialState, searchQuery: 'some query' });
+    });
+  });
+
+  describe('when setDataSourcesLayoutMode is dispatched', () => {
+    it('then state should be correct', () => {
+      const layoutMode: LayoutModes = LayoutModes.Grid;
+
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(setDataSourcesLayoutMode(layoutMode))
+        .thenStateShouldEqual({ ...initialState, layoutMode: LayoutModes.Grid });
+    });
+  });
+
+  describe('when dataSourceTypesLoad is dispatched', () => {
+    it('then state should be correct', () => {
+      const state: DataSourcesState = { ...initialState, dataSourceTypes: [mockPlugin()] };
+
+      reducerTester()
+        .givenReducer(dataSourcesReducer, state)
+        .whenActionIsDispatched(dataSourceTypesLoad())
+        .thenStateShouldEqual({ ...initialState, dataSourceTypes: [], isLoadingDataSources: true });
+    });
+  });
+
+  describe('when dataSourceTypesLoaded is dispatched', () => {
+    it('then state should be correct', () => {
+      const dataSourceTypes = [mockPlugin()];
+      const state: DataSourcesState = { ...initialState, isLoadingDataSources: true };
+
+      reducerTester()
+        .givenReducer(dataSourcesReducer, state)
+        .whenActionIsDispatched(dataSourceTypesLoaded(dataSourceTypes))
+        .thenStateShouldEqual({ ...initialState, dataSourceTypes, isLoadingDataSources: false });
+    });
+  });
+
+  describe('when setDataSourceTypeSearchQuery is dispatched', () => {
+    it('then state should be correct', () => {
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(setDataSourceTypeSearchQuery('type search query'))
+        .thenStateShouldEqual({ ...initialState, dataSourceTypeSearchQuery: 'type search query' });
+    });
+  });
+
+  describe('when dataSourceMetaLoaded is dispatched', () => {
+    it('then state should be correct', () => {
+      const dataSourceMeta = mockPlugin();
+
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(dataSourceMetaLoaded(dataSourceMeta))
+        .thenStateShouldEqual({ ...initialState, dataSourceMeta });
+    });
+  });
+
+  describe('when setDataSourceName is dispatched', () => {
+    it('then state should be correct', () => {
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(setDataSourceName('some name'))
+        .thenStateShouldEqual({ ...initialState, dataSource: { name: 'some name' } });
+    });
+  });
+
+  describe('when setIsDefault is dispatched', () => {
+    it('then state should be correct', () => {
+      reducerTester()
+        .givenReducer(dataSourcesReducer, initialState)
+        .whenActionIsDispatched(setIsDefault(true))
+        .thenStateShouldEqual({ ...initialState, dataSource: { isDefault: true } });
+    });
+  });
+});

+ 70 - 39
public/app/features/datasources/state/reducers.ts

@@ -1,56 +1,87 @@
 import { DataSourcesState, Plugin } from 'app/types';
 import { DataSourceSettings } from '@grafana/ui/src/types';
-import { Action, ActionTypes } from './actions';
+import {
+  dataSourceLoaded,
+  dataSourcesLoaded,
+  setDataSourcesSearchQuery,
+  setDataSourcesLayoutMode,
+  dataSourceTypesLoad,
+  dataSourceTypesLoaded,
+  setDataSourceTypeSearchQuery,
+  dataSourceMetaLoaded,
+  setDataSourceName,
+  setIsDefault,
+} from './actions';
 import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
+import { reducerFactory } from 'app/core/redux';
 
-const initialState: DataSourcesState = {
-  dataSources: [] as DataSourceSettings[],
+export const initialState: DataSourcesState = {
+  dataSources: [],
   dataSource: {} as DataSourceSettings,
   layoutMode: LayoutModes.List,
   searchQuery: '',
   dataSourcesCount: 0,
-  dataSourceTypes: [] as Plugin[],
+  dataSourceTypes: [],
   dataSourceTypeSearchQuery: '',
   hasFetched: false,
   isLoadingDataSources: false,
   dataSourceMeta: {} as Plugin,
 };
 
-export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
-  switch (action.type) {
-    case ActionTypes.LoadDataSources:
-      return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
-
-    case ActionTypes.LoadDataSource:
-      return { ...state, dataSource: action.payload };
-
-    case ActionTypes.SetDataSourcesSearchQuery:
-      return { ...state, searchQuery: action.payload };
-
-    case ActionTypes.SetDataSourcesLayoutMode:
-      return { ...state, layoutMode: action.payload };
-
-    case ActionTypes.LoadDataSourceTypes:
-      return { ...state, dataSourceTypes: [], isLoadingDataSources: true };
-
-    case ActionTypes.LoadedDataSourceTypes:
-      return { ...state, dataSourceTypes: action.payload, isLoadingDataSources: false };
-
-    case ActionTypes.SetDataSourceTypeSearchQuery:
-      return { ...state, dataSourceTypeSearchQuery: action.payload };
-
-    case ActionTypes.LoadDataSourceMeta:
-      return { ...state, dataSourceMeta: action.payload };
-
-    case ActionTypes.SetDataSourceName:
-      return { ...state, dataSource: { ...state.dataSource, name: action.payload } };
-
-    case ActionTypes.SetIsDefault:
-      return { ...state, dataSource: { ...state.dataSource, isDefault: action.payload } };
-  }
-
-  return state;
-};
+export const dataSourcesReducer = reducerFactory(initialState)
+  .addMapper({
+    filter: dataSourcesLoaded,
+    mapper: (state, action) => ({
+      ...state,
+      hasFetched: true,
+      dataSources: action.payload,
+      dataSourcesCount: action.payload.length,
+    }),
+  })
+  .addMapper({
+    filter: dataSourceLoaded,
+    mapper: (state, action) => ({ ...state, dataSource: action.payload }),
+  })
+  .addMapper({
+    filter: setDataSourcesSearchQuery,
+    mapper: (state, action) => ({ ...state, searchQuery: action.payload }),
+  })
+  .addMapper({
+    filter: setDataSourcesLayoutMode,
+    mapper: (state, action) => ({ ...state, layoutMode: action.payload }),
+  })
+  .addMapper({
+    filter: dataSourceTypesLoad,
+    mapper: state => ({ ...state, dataSourceTypes: [], isLoadingDataSources: true }),
+  })
+  .addMapper({
+    filter: dataSourceTypesLoaded,
+    mapper: (state, action) => ({
+      ...state,
+      dataSourceTypes: action.payload,
+      isLoadingDataSources: false,
+    }),
+  })
+  .addMapper({
+    filter: setDataSourceTypeSearchQuery,
+    mapper: (state, action) => ({ ...state, dataSourceTypeSearchQuery: action.payload }),
+  })
+  .addMapper({
+    filter: dataSourceMetaLoaded,
+    mapper: (state, action) => ({ ...state, dataSourceMeta: action.payload }),
+  })
+  .addMapper({
+    filter: setDataSourceName,
+    mapper: (state, action) => ({ ...state, dataSource: { ...state.dataSource, name: action.payload } }),
+  })
+  .addMapper({
+    filter: setIsDefault,
+    mapper: (state, action) => ({
+      ...state,
+      dataSource: { ...state.dataSource, isDefault: action.payload },
+    }),
+  })
+  .create();
 
 export default {
   dataSources: dataSourcesReducer,

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

@@ -32,7 +32,7 @@ import {
 import { RawTimeRange, TimeRange, DataQuery } 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';
 
@@ -61,7 +61,7 @@ interface ExploreProps {
   supportsGraph: boolean | null;
   supportsLogs: boolean | null;
   supportsTable: boolean | null;
-  urlState: ExploreUrlState;
+  urlState?: ExploreUrlState;
 }
 
 /**
@@ -107,18 +107,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
       );
     }
   }
@@ -216,7 +218,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

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

+ 13 - 4
public/app/features/explore/QueryField.tsx

@@ -104,11 +104,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();
     }
   }

+ 6 - 2
public/app/features/explore/QueryRow.tsx

@@ -65,6 +65,10 @@ export class QueryRow extends PureComponent<QueryRowProps> {
     }
   };
 
+  componentWillUnmount() {
+    console.log('QueryRow will unmount');
+  }
+
   onClickAddButton = () => {
     const { exploreId, index } = this.props;
     this.props.addQueryRow(exploreId, index);
@@ -107,7 +111,7 @@ 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}
@@ -131,7 +135,7 @@ export class QueryRow extends PureComponent<QueryRowProps> {
             />
           )}
         </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" />

+ 2 - 0
public/app/features/explore/state/actionTypes.ts

@@ -8,6 +8,7 @@ import {
   RangeScanner,
   ResultType,
   QueryTransaction,
+  ExploreUIState,
 } from 'app/types/explore';
 
 export enum ActionTypes {
@@ -106,6 +107,7 @@ export interface InitializeExploreAction {
     exploreDatasources: DataSourceSelectItem[];
     queries: DataQuery[];
     range: RawTimeRange;
+    ui: ExploreUIState;
   };
 }
 

+ 72 - 35
public/app/features/explore/state/actions.ts

@@ -38,6 +38,7 @@ import {
   ResultType,
   QueryOptions,
   QueryTransaction,
+  ExploreUIState,
 } from 'app/types/explore';
 
 import {
@@ -78,7 +79,15 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun
     await dispatch(importQueries(exploreId, modifiedQueries, currentDataSourceInstance, newDataSourceInstance));
 
     dispatch(updateDatasourceInstance(exploreId, newDataSourceInstance));
-    dispatch(loadDatasource(exploreId, newDataSourceInstance));
+
+    try {
+      await dispatch(loadDatasource(exploreId, newDataSourceInstance));
+    } catch (error) {
+      console.error(error);
+      return;
+    }
+
+    dispatch(runQueries(exploreId));
   };
 }
 
@@ -154,7 +163,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()
@@ -175,6 +185,7 @@ export function initializeExplore(
         exploreDatasources,
         queries,
         range,
+        ui,
       },
     });
 
@@ -194,7 +205,14 @@ export function initializeExplore(
       }
 
       dispatch(updateDatasourceInstance(exploreId, instance));
-      dispatch(loadDatasource(exploreId, instance));
+
+      try {
+        await dispatch(loadDatasource(exploreId, instance));
+      } catch (error) {
+        console.error(error);
+        return;
+      }
+      dispatch(runQueries(exploreId, true));
     } else {
       dispatch(loadDatasourceMissing(exploreId));
     }
@@ -258,10 +276,7 @@ export const queriesImported = (exploreId: ExploreId, queries: DataQuery[]): Que
  * 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): LoadDatasourceSuccessAction => {
   // Capabilities
   const supportsGraph = instance.meta.metrics;
   const supportsLogs = instance.meta.logs;
@@ -343,8 +358,8 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
 
     // Keep ID to track selection
     dispatch(loadDatasourcePending(exploreId, datasourceName));
-
     let datasourceError = null;
+
     try {
       const testResult = await instance.testDatasource();
       datasourceError = testResult.status === 'success' ? null : testResult.message;
@@ -354,7 +369,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
 
     if (datasourceError) {
       dispatch(loadDatasourceFailure(exploreId, datasourceError));
-      return;
+      return Promise.reject(`${datasourceName} loading failed`);
     }
 
     if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
@@ -372,7 +387,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
     }
 
     dispatch(loadDatasourceSuccess(exploreId, instance));
-    dispatch(runQueries(exploreId));
+    return Promise.resolve();
   };
 }
 
@@ -572,7 +587,7 @@ export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult
 /**
  * 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,
@@ -596,7 +611,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 +626,7 @@ export function runQueries(exploreId: ExploreId) {
         )
       );
     }
-    if (showingGraph && supportsGraph) {
+    if ((ignoreUIState || showingGraph) && supportsGraph) {
       dispatch(
         runQueriesForType(
           exploreId,
@@ -625,9 +640,10 @@ export function runQueries(exploreId: ExploreId) {
         )
       );
     }
-    if (showingLogs && supportsLogs) {
+    if ((ignoreUIState || showingLogs) && supportsLogs) {
       dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
     }
+
     dispatch(stateSave());
   };
 }
@@ -766,6 +782,11 @@ export function stateSave() {
       datasource: left.datasourceInstance.name,
       queries: left.modifiedQueries.map(clearQueryKeys),
       range: left.range,
+      ui: {
+        showingGraph: left.showingGraph,
+        showingLogs: left.showingLogs,
+        showingTable: left.showingTable,
+      },
     };
     urlStates.left = serializeStateToUrlParam(leftUrlState, true);
     if (split) {
@@ -773,48 +794,64 @@ export function stateSave() {
         datasource: right.datasourceInstance.name,
         queries: right.modifiedQueries.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 = (type: ActionTypes.ToggleGraph | ActionTypes.ToggleTable | ActionTypes.ToggleLogs) => (
+  exploreId: ExploreId
+) => {
   return (dispatch, getState) => {
-    dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
-    if (getState().explore[exploreId].showingGraph) {
+    let shouldRunQueries;
+    dispatch({ type, payload: { exploreId } });
+    dispatch(stateSave());
+
+    switch (type) {
+      case ActionTypes.ToggleGraph:
+        shouldRunQueries = getState().explore[exploreId].showingGraph;
+        break;
+      case ActionTypes.ToggleLogs:
+        shouldRunQueries = getState().explore[exploreId].showingLogs;
+        break;
+      case ActionTypes.ToggleTable:
+        shouldRunQueries = getState().explore[exploreId].showingTable;
+        break;
+    }
+
+    if (shouldRunQueries) {
       dispatch(runQueries(exploreId));
     }
   };
-}
+};
+
+/**
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ */
+export const toggleGraph = togglePanelActionCreator(ActionTypes.ToggleGraph);
 
 /**
  * Expand/collapse the logs result viewer. When collapsed, log 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 toggleLogs = togglePanelActionCreator(ActionTypes.ToggleLogs);
 
 /**
  * Expand/collapse the table result viewer. When collapsed, table 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 toggleTable = togglePanelActionCreator(ActionTypes.ToggleTable);
 
 /**
  * Resets state for explore.

+ 2 - 1
public/app/features/explore/state/reducers.ts

@@ -163,7 +163,7 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
     }
 
     case ActionTypes.InitializeExplore: {
-      const { containerWidth, eventBridge, exploreDatasources, queries, range } = action.payload;
+      const { containerWidth, eventBridge, exploreDatasources, queries, range, ui } = action.payload;
       return {
         ...state,
         containerWidth,
@@ -173,6 +173,7 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
         initialQueries: queries,
         initialized: true,
         modifiedQueries: queries.slice(),
+        ...ui,
       };
     }
 

+ 0 - 1
public/app/features/panel/all.ts

@@ -1,6 +1,5 @@
 import './panel_header';
 import './panel_directive';
-import './solo_panel_ctrl';
 import './query_ctrl';
 import './panel_editor_tab';
 import './query_editor_row';

+ 0 - 11
public/app/features/panel/metrics_panel_ctrl.ts

@@ -16,7 +16,6 @@ class MetricsPanelCtrl extends PanelCtrl {
   datasourceSrv: any;
   timeSrv: any;
   templateSrv: any;
-  timing: any;
   range: any;
   interval: any;
   intervalMs: any;
@@ -81,7 +80,6 @@ class MetricsPanelCtrl extends PanelCtrl {
     this.loading = true;
 
     // load datasource service
-    this.setTimeQueryStart();
     this.datasourceSrv
       .get(this.panel.datasource)
       .then(this.updateTimeRange.bind(this))
@@ -112,14 +110,6 @@ class MetricsPanelCtrl extends PanelCtrl {
       });
   }
 
-  setTimeQueryStart() {
-    this.timing.queryStart = new Date().getTime();
-  }
-
-  setTimeQueryEnd() {
-    this.timing.queryEnd = new Date().getTime();
-  }
-
   updateTimeRange(datasource?) {
     this.datasource = datasource || this.datasource;
     this.range = this.timeSrv.timeRange();
@@ -181,7 +171,6 @@ class MetricsPanelCtrl extends PanelCtrl {
   }
 
   handleQueryResult(result) {
-    this.setTimeQueryEnd();
     this.loading = false;
 
     // check for if data source returns subject

+ 6 - 19
public/app/features/panel/panel_ctrl.ts

@@ -1,5 +1,4 @@
 import _ from 'lodash';
-import $ from 'jquery';
 import Remarkable from 'remarkable';
 
 import config from 'app/core/config';
@@ -13,7 +12,7 @@ import {
   sharePanel as sharePanelUtil,
 } from 'app/features/dashboard/utils/panel';
 
-import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
+import { GRID_COLUMN_COUNT, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
 
 export class PanelCtrl {
   panel: any;
@@ -31,8 +30,8 @@ export class PanelCtrl {
   height: any;
   containerHeight: any;
   events: Emitter;
-  timing: any;
   loading: boolean;
+  timing: any;
   maxPanelsPerRowOptions: number[];
 
   constructor($scope, $injector) {
@@ -42,7 +41,7 @@ export class PanelCtrl {
     this.$timeout = $injector.get('$timeout');
     this.editorTabs = [];
     this.events = this.panel.events;
-    this.timing = {};
+    this.timing = {}; // not used but here to not break plugins
 
     const plugin = config.panels[this.panel.type];
     if (plugin) {
@@ -59,7 +58,7 @@ export class PanelCtrl {
   }
 
   renderingCompleted() {
-    profiler.renderingCompleted(this.panel.id, this.timing);
+    profiler.renderingCompleted(this.panel.id);
   }
 
   refresh() {
@@ -200,24 +199,12 @@ export class PanelCtrl {
     return this.dashboard.meta.fullscreen && !this.panel.fullscreen;
   }
 
-  calculatePanelHeight() {
-    if (this.panel.isEditing) {
-      this.containerHeight = $('.panel-wrapper--edit').height();
-    } else if (this.panel.fullscreen)  {
-      this.containerHeight = $('.panel-wrapper--view').height();
-    } else {
-      this.containerHeight = this.panel.gridPos.h * GRID_CELL_HEIGHT + (this.panel.gridPos.h - 1) * GRID_CELL_VMARGIN;
-    }
-
-    if (this.panel.soloMode) {
-      this.containerHeight = $(window).height();
-    }
-
+  calculatePanelHeight(containerHeight) {
+    this.containerHeight = containerHeight;
     this.height = this.containerHeight - (PANEL_BORDER + PANEL_HEADER_HEIGHT);
   }
 
   render(payload?) {
-    this.timing.renderStart = new Date().getTime();
     this.events.emit('render', payload);
   }
 

+ 8 - 6
public/app/features/panel/panel_directive.ts

@@ -101,7 +101,7 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
       });
 
       ctrl.events.on('panel-size-changed', () => {
-        ctrl.calculatePanelHeight();
+        ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
         $timeout(() => {
           resizeScrollableContent();
           ctrl.render();
@@ -112,19 +112,21 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
         // first wait one pass for dashboard fullscreen view mode to take effect (classses being applied)
         setTimeout(() => {
           // then recalc style
-          ctrl.calculatePanelHeight();
+          ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
           // then wait another cycle (this might not be needed)
           $timeout(() => {
             ctrl.render();
             resizeScrollableContent();
           });
-        });
+        }, 10);
       });
 
-      // set initial height
-      ctrl.calculatePanelHeight();
-
       ctrl.events.on('render', () => {
+        // set initial height
+        if (!ctrl.height) {
+          ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
+        }
+
         if (transparentLastState !== ctrl.panel.transparent) {
           panelContainer.toggleClass('panel-transparent', ctrl.panel.transparent === true);
           transparentLastState = ctrl.panel.transparent;

+ 0 - 4
public/app/features/panel/partials/soloPanel.html

@@ -1,4 +0,0 @@
-<div class="panel-solo" ng-if="panel">
-	<plugin-component type="panel">
-	</plugin-component>
-</div>

+ 0 - 58
public/app/features/panel/solo_panel_ctrl.ts

@@ -1,58 +0,0 @@
-import angular from 'angular';
-import locationUtil from 'app/core/utils/location_util';
-import appEvents from 'app/core/app_events';
-
-export class SoloPanelCtrl {
-  /** @ngInject */
-  constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
-    let panelId;
-
-    $scope.init = () => {
-      contextSrv.sidemenu = false;
-      appEvents.emit('toggle-sidemenu-hidden');
-
-      const params = $location.search();
-      panelId = parseInt(params.panelId, 10);
-
-      appEvents.on('dashboard-initialized', $scope.initPanelScope);
-
-      // if no uid, redirect to new route based on slug
-      if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
-        backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
-          if (res) {
-            const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
-            $location.path(url).replace();
-          }
-        });
-        return;
-      }
-
-      dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(result => {
-        result.meta.soloMode = true;
-        $scope.initDashboard(result, $scope);
-      });
-    };
-
-    $scope.initPanelScope = () => {
-      const panelInfo = $scope.dashboard.getPanelInfoById(panelId);
-
-      // fake row ctrl scope
-      $scope.ctrl = {
-        dashboard: $scope.dashboard,
-      };
-
-      $scope.panel = panelInfo.panel;
-      $scope.panel.soloMode = true;
-      $scope.$index = 0;
-
-      if (!$scope.panel) {
-        $scope.appEvent('alert-error', ['Panel not found', '']);
-        return;
-      }
-    };
-
-    $scope.init();
-  }
-}
-
-angular.module('grafana.routes').controller('SoloPanelCtrl', SoloPanelCtrl);

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

@@ -0,0 +1,80 @@
+// 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}
+          initialQuery={query}
+          onQueryChange={this.onFieldChange}
+          onPressEnter={this.onRunQuery}
+        />
+        <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;

+ 3 - 2
public/app/plugins/datasource/loki/components/LokiQueryField.tsx

@@ -12,6 +12,7 @@ import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explor
 import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
 import BracesPlugin from 'app/features/explore/slate-plugins/braces';
 import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
+import LokiDatasource from '../datasource';
 
 // Types
 import { LokiQuery } from '../types';
@@ -65,7 +66,7 @@ interface CascaderOption {
 }
 
 interface LokiQueryFieldProps {
-  datasource: any;
+  datasource: LokiDatasource;
   error?: string | JSX.Element;
   hint?: any;
   history?: any[];
@@ -80,7 +81,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;

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

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

@@ -249,15 +249,15 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
 
     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}

+ 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

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

@@ -80,7 +80,6 @@ class TablePanelCtrl extends MetricsPanelCtrl {
     this.pageIndex = 0;
 
     if (this.panel.transform === 'annotations') {
-      this.setTimeQueryStart();
       return this.annotationsSrv
         .getAnnotations({
           dashboard: this.dashboard,

+ 7 - 1
public/app/routes/ReactContainer.tsx

@@ -18,6 +18,8 @@ function WrapInProvider(store, Component, props) {
 export function reactContainer(
   $route,
   $location,
+  $injector,
+  $rootScope,
   contextSrv: ContextSrv
 ) {
   return {
@@ -38,7 +40,11 @@ export function reactContainer(
         component = component.default;
       }
 
-      const props = { };
+      const props = {
+        $injector: $injector,
+        $rootScope: $rootScope,
+        $scope: scope,
+      };
 
       ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
 

+ 11 - 8
public/app/routes/routes.ts

@@ -19,6 +19,7 @@ import UsersListPage from 'app/features/users/UsersListPage';
 import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards';
 import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage';
 import OrgDetailsPage from '../features/org/OrgDetailsPage';
+import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
 import config from 'app/core/config';
 
 /** @ngInject */
@@ -51,16 +52,18 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       pageClass: 'page-dashboard',
     })
     .when('/d-solo/:uid/:slug', {
-      templateUrl: 'public/app/features/panel/partials/soloPanel.html',
-      controller: 'SoloPanelCtrl',
-      reloadOnSearch: false,
-      pageClass: 'page-dashboard',
+      template: '<react-container />',
+      pageClass: 'dashboard-solo',
+      resolve: {
+        component: () => SoloPanelPage,
+      },
     })
     .when('/dashboard-solo/:type/:slug', {
-      templateUrl: 'public/app/features/panel/partials/soloPanel.html',
-      controller: 'SoloPanelCtrl',
-      reloadOnSearch: false,
-      pageClass: 'page-dashboard',
+      template: '<react-container />',
+      pageClass: 'dashboard-solo',
+      resolve: {
+        component: () => SoloPanelPage,
+      },
     })
     .when('/dashboard/new', {
       templateUrl: 'public/app/partials/dashboard.html',

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

@@ -231,10 +231,17 @@ export interface ExploreItemState {
   tableResult?: TableModel;
 }
 
+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/sass/_variables.scss

@@ -135,7 +135,7 @@ $input-padding-y-sm: 4px !default;
 $input-padding-x-lg: 20px !default;
 $input-padding-y-lg: 10px !default;
 
-$input-height: (($font-size-base * $line-height-base) + ($input-padding-y * 2)) !default;
+$input-height: 35px !default;
 
 $gf-form-margin: 0.2rem;
 $gf-form-input-height: 35px;

+ 4 - 0
public/sass/components/_cards.scss

@@ -109,6 +109,10 @@
   width: 100%;
 }
 
+.card-item-label {
+    margin-left: 8px;
+}
+
 .card-item-sub-name {
   color: $text-color-weak;
   overflow: hidden;

+ 1 - 1
public/sass/components/_code_editor.scss

@@ -7,7 +7,7 @@
   &.ace_editor {
     @include font-family-monospace();
     font-size: 1rem;
-    min-height: 2.6rem;
+    min-height: 3.6rem; // Include space for horizontal scrollbar
 
     @include border-radius($input-border-radius-sm);
     border: $input-btn-border-width solid $input-border-color;

+ 4 - 0
public/sass/components/_gf-form.scss

@@ -84,6 +84,10 @@ $input-border: 1px solid $input-border-color;
   .gf-form + .gf-form {
     margin-left: $gf-form-margin;
   }
+
+  &--nowrap {
+    flex-wrap: nowrap;
+  }
 }
 
 .gf-form-button-row {

+ 1 - 0
public/sass/components/_slate_editor.scss

@@ -19,6 +19,7 @@
   border: $panel-border;
   border-radius: $border-radius;
   transition: all 0.3s;
+  line-height: $input-line-height;
 }
 
 .slate-query-field__wrapper--disabled {

+ 1 - 1
public/sass/components/_submenu.scss

@@ -40,9 +40,9 @@
   background-color: $input-bg;
   border: 1px solid $input-border-color;
   border-radius: $input-border-radius;
-  box-sizing: content-box;
   display: inline-block;
   color: $text-color;
+  height: $gf-form-input-height;
 
   .label-tag {
     margin: 0 5px;

+ 0 - 1
public/sass/components/_toolbar.scss

@@ -4,7 +4,6 @@
   align-items: center;
   padding: 3px 20px 3px 20px;
   position: relative;
-  z-index: 1;
   flex: 0 0 auto;
   background: $toolbar-bg;
   border-radius: 3px;

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است