Explorar o código

Merge branch 'master' into delete_session_on_logout

Marcus Efraimsson %!s(int64=7) %!d(string=hai) anos
pai
achega
fb3c510178
Modificáronse 100 ficheiros con 1571 adicións e 792 borrados
  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 12
      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. 1 1
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
  15. 3 11
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
  16. 29 47
      packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
  17. 17 0
      packages/grafana-ui/src/types/data.ts
  18. 2 2
      packages/grafana-ui/src/types/datasource.ts
  19. 1 1
      packages/grafana-ui/src/types/index.ts
  20. 7 2
      packages/grafana-ui/src/types/panel.ts
  21. 2 2
      packages/grafana-ui/src/types/plugin.ts
  22. 2 2
      pkg/api/api.go
  23. 2 1
      pkg/services/auth/auth_token.go
  24. 2 2
      pkg/services/provisioning/notifiers/alert_notifications.go
  25. 1 4
      pkg/services/sqlstore/sqlstore.go
  26. 16 0
      pkg/setting/setting.go
  27. 2 0
      pkg/tsdb/cloudwatch/metric_find_query.go
  28. 1 4
      pkg/tsdb/mssql/mssql.go
  29. 0 25
      pkg/util/ip.go
  30. 31 6
      pkg/util/ip_address.go
  31. 83 1
      pkg/util/ip_address_test.go
  32. 0 43
      pkg/util/ip_test.go
  33. 1 2
      public/app/core/components/Select/MetricSelect.tsx
  34. 2 3
      public/app/core/logs_model.ts
  35. 1 102
      public/app/core/profiler.ts
  36. 83 0
      public/app/core/redux/actionCreatorFactory.test.ts
  37. 57 0
      public/app/core/redux/actionCreatorFactory.ts
  38. 4 0
      public/app/core/redux/index.ts
  39. 97 0
      public/app/core/redux/reducerFactory.test.ts
  40. 45 0
      public/app/core/redux/reducerFactory.ts
  41. 26 11
      public/app/core/services/backend_srv.ts
  42. 1 1
      public/app/core/services/keybindingSrv.ts
  43. 30 2
      public/app/core/utils/explore.test.ts
  44. 36 4
      public/app/core/utils/explore.ts
  45. 18 13
      public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
  46. 1 0
      public/app/features/dashboard/components/SaveModals/index.ts
  47. 123 0
      public/app/features/dashboard/containers/SoloPanelPage.tsx
  48. 29 7
      public/app/features/dashboard/dashgrid/DataPanel.tsx
  49. 13 8
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  50. 0 11
      public/app/features/dashboard/panel_editor/PanelEditor.tsx
  51. 7 1
      public/app/features/dashboard/panel_editor/QueriesTab.tsx
  52. 6 10
      public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
  53. 1 1
      public/app/features/dashboard/state/DashboardModel.ts
  54. 15 1
      public/app/features/dashboard/state/PanelModel.ts
  55. 2 2
      public/app/features/datasources/DataSourcesListItem.tsx
  56. 6 5
      public/app/features/datasources/DataSourcesListPage.test.tsx
  57. 1 0
      public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap
  58. 3 2
      public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
  59. 16 111
      public/app/features/datasources/state/actions.ts
  60. 137 0
      public/app/features/datasources/state/reducers.test.ts
  61. 70 39
      public/app/features/datasources/state/reducers.ts
  62. 7 5
      public/app/features/explore/Explore.tsx
  63. 4 1
      public/app/features/explore/Logs.tsx
  64. 13 4
      public/app/features/explore/QueryField.tsx
  65. 6 2
      public/app/features/explore/QueryRow.tsx
  66. 2 0
      public/app/features/explore/state/actionTypes.ts
  67. 72 35
      public/app/features/explore/state/actions.ts
  68. 2 1
      public/app/features/explore/state/reducers.ts
  69. 0 1
      public/app/features/panel/all.ts
  70. 0 11
      public/app/features/panel/metrics_panel_ctrl.ts
  71. 6 19
      public/app/features/panel/panel_ctrl.ts
  72. 8 6
      public/app/features/panel/panel_directive.ts
  73. 0 4
      public/app/features/panel/partials/soloPanel.html
  74. 0 58
      public/app/features/panel/solo_panel_ctrl.ts
  75. 1 0
      public/app/plugins/datasource/elasticsearch/pipeline_variables.ts
  76. 80 0
      public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx
  77. 3 2
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  78. 41 2
      public/app/plugins/datasource/loki/datasource.test.ts
  79. 55 43
      public/app/plugins/datasource/loki/datasource.ts
  80. 2 0
      public/app/plugins/datasource/loki/module.ts
  81. 3 1
      public/app/plugins/datasource/loki/plugin.json
  82. 3 0
      public/app/plugins/datasource/loki/types.ts
  83. 3 3
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  84. 2 2
      public/app/plugins/datasource/testdata/QueryEditor.tsx
  85. 20 9
      public/app/plugins/panel/gauge/GaugePanel.tsx
  86. 1 1
      public/app/plugins/panel/graph/module.ts
  87. 1 1
      public/app/plugins/panel/graph/tab_legend.html
  88. 8 5
      public/app/plugins/panel/graph2/GraphPanel.tsx
  89. 0 1
      public/app/plugins/panel/table/module.ts
  90. 7 1
      public/app/routes/ReactContainer.tsx
  91. 11 8
      public/app/routes/routes.ts
  92. 7 0
      public/app/types/explore.ts
  93. 1 1
      public/sass/_variables.scss
  94. 4 0
      public/sass/components/_cards.scss
  95. 1 1
      public/sass/components/_code_editor.scss
  96. 4 0
      public/sass/components/_gf-form.scss
  97. 1 0
      public/sass/components/_slate_editor.scss
  98. 1 1
      public/sass/components/_submenu.scss
  99. 0 1
      public/sass/components/_toolbar.scss
  100. 7 0
      public/sass/pages/_dashboard.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 - 12
devenv/docker/blocks/loki/docker-compose.yaml

@@ -1,24 +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
-    depends_on:
-      - 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/series';
 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 {

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

+ 17 - 0
packages/grafana-ui/src/types/series.ts → packages/grafana-ui/src/types/data.ts

@@ -52,3 +52,20 @@ export interface TimeSeriesVMs {
   [index: number]: TimeSeriesVM;
   length: number;
 }
+
+interface Column {
+  text: string;
+  title?: string;
+  type?: string;
+  sort?: boolean;
+  desc?: boolean;
+  filterable?: boolean;
+  unit?: string;
+}
+
+export interface TableData {
+  columns: Column[];
+  rows: any[];
+  type: string;
+  columnMap: any;
+}

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

@@ -1,9 +1,9 @@
 import { TimeRange, RawTimeRange } from './time';
-import { TimeSeries } from './series';
 import { PluginMeta } from './plugin';
+import { TableData, TimeSeries } from './data';
 
 export interface DataQueryResponse {
-  data: TimeSeries[];
+  data: TimeSeries[] | [TableData] | any;
 }
 
 export interface DataQuery {

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

@@ -1,4 +1,4 @@
-export * from './series';
+export * from './data';
 export * from './time';
 export * from './panel';
 export * from './plugin';

+ 7 - 2
packages/grafana-ui/src/types/panel.ts

@@ -1,10 +1,10 @@
-import { TimeSeries, LoadingState } from './series';
+import { TimeSeries, LoadingState, TableData } from './data';
 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;
@@ -14,6 +14,11 @@ export interface PanelProps<T = any> {
   onInterpolate: InterpolateFunction;
 }
 
+export interface PanelData {
+  timeSeries?: TimeSeries[];
+  tableData?: TableData;
+}
+
 export interface PanelOptionsProps<T = any> {
   options: T;
   onChange: (options: T) => void;

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

+ 2 - 1
pkg/services/auth/auth_token.go

@@ -97,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)
@@ -163,7 +164,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
 func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
 	hashedToken := hashToken(unhashedToken)
 	if setting.Env == setting.DEV {
-		s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
+		s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
 	}
 
 	expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()

+ 2 - 2
pkg/services/provisioning/notifiers/alert_notifications.go

@@ -92,7 +92,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not
 		}
 
 		if cmd.Result == nil {
-			dc.log.Info("Inserting alert notification from configuration ", "name", notification.Name, "uid", notification.Uid)
+			dc.log.Debug("inserting alert notification from configuration", "name", notification.Name, "uid", notification.Uid)
 			insertCmd := &models.CreateAlertNotificationCommand{
 				Uid:                   notification.Uid,
 				Name:                  notification.Name,
@@ -109,7 +109,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not
 				return err
 			}
 		} else {
-			dc.log.Info("Updating alert notification from configuration", "name", notification.Name)
+			dc.log.Debug("updating alert notification from configuration", "name", notification.Name)
 			updateCmd := &models.UpdateAlertNotificationWithUidCommand{
 				Uid:                   notification.Uid,
 				Name:                  notification.Name,

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

+ 29 - 7
public/app/features/dashboard/dashgrid/DataPanel.tsx

@@ -8,13 +8,21 @@ import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource
 // Utils
 import kbn from 'app/core/utils/kbn';
 // Types
-import { DataQueryOptions, DataQueryResponse, LoadingState, TimeRange, TimeSeries } from '@grafana/ui/src/types';
+import {
+  DataQueryOptions,
+  DataQueryResponse,
+  LoadingState,
+  PanelData,
+  TableData,
+  TimeRange,
+  TimeSeries,
+} from '@grafana/ui';
 
 const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
 
 interface RenderProps {
   loading: LoadingState;
-  timeSeries: TimeSeries[];
+  panelData: PanelData;
 }
 
 export interface Props {
@@ -127,9 +135,7 @@ 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;
@@ -160,11 +166,27 @@ export class DataPanel extends Component<Props, State> {
     }
   };
 
+  getPanelData = () => {
+    const { response } = this.state;
+
+    if (response.data.length > 0 && (response.data[0] as TableData).type === 'table') {
+      return {
+        tableData: response.data[0] as TableData,
+        timeSeries: null,
+      };
+    }
+
+    return {
+      timeSeries: response.data as TimeSeries[],
+      tableData: null,
+    };
+  };
+
   render() {
     const { queries } = this.props;
-    const { response, loading, isFirstLoad } = this.state;
+    const { loading, isFirstLoad } = this.state;
 
-    const timeSeries = response.data;
+    const panelData = this.getPanelData();
 
     if (isFirstLoad && loading === LoadingState.Loading) {
       return this.renderLoadingStates();
@@ -190,8 +212,8 @@ export class DataPanel extends Component<Props, State> {
             return (
               <>
                 {this.props.children({
-                  timeSeries,
                   loading,
+                  panelData,
                 })}
               </>
             );

+ 13 - 8
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -12,12 +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 { PanelModel } from '../state/PanelModel';
-import { DashboardModel } from '../state/DashboardModel';
+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';
@@ -94,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}
@@ -139,7 +145,6 @@ export class PanelChrome extends PureComponent<Props, State> {
                 scopedVars={panel.scopedVars}
                 links={panel.links}
               />
-
               {panel.snapshotData ? (
                 this.renderPanel(false, panel.snapshotData, width, height)
               ) : (
@@ -152,8 +157,8 @@ export class PanelChrome extends PureComponent<Props, State> {
                   refreshCounter={refreshCounter}
                   onDataResponse={this.onDataResponse}
                 >
-                  {({ loading, timeSeries }) => {
-                    return this.renderPanel(loading, timeSeries, width, height);
+                  {({ loading, panelData }) => {
+                    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;

+ 15 - 1
public/app/features/dashboard/state/PanelModel.ts

@@ -5,6 +5,7 @@ import _ from 'lodash';
 import { Emitter } from 'app/core/utils/emitter';
 import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
 import { DataQuery, TimeSeries } from '@grafana/ui';
+import { TableData } from '@grafana/ui/src';
 
 export interface GridPos {
   x: number;
@@ -87,7 +88,7 @@ export class PanelModel {
   datasource: string;
   thresholds?: any;
 
-  snapshotData?: TimeSeries[];
+  snapshotData?: TimeSeries[] | [TableData];
   timeFrom?: any;
   timeShift?: any;
   hideTimeOverride?: any;
@@ -268,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);

+ 1 - 0
public/app/plugins/datasource/elasticsearch/pipeline_variables.ts

@@ -22,6 +22,7 @@ const newVariable = index => {
 };
 
 export class ElasticPipelineVariablesCtrl {
+  /** @ngInject */
   constructor($scope) {
     $scope.variables = $scope.variables || [newVariable(1)];
 

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

+ 7 - 0
public/sass/pages/_dashboard.scss

@@ -17,6 +17,13 @@ div.flot-text {
   height: 100%;
 }
 
+.dashboard-solo {
+  .footer,
+  .sidemenu {
+    display: none;
+  }
+}
+
 .panel-solo {
   position: fixed;
   bottom: 0;

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio