Browse Source

Merge pull request #11942 from grafana/davkal/design-integration

Explore: Time selector, split view, design integration
David 7 years ago
parent
commit
7a3c1e162c

+ 1 - 1
public/app/containers/Explore/ElapsedTime.tsx

@@ -41,6 +41,6 @@ export default class ElapsedTime extends PureComponent<any, any> {
     const { elapsed } = this.state;
     const { className, time } = this.props;
     const value = (time || elapsed) / 1000;
-    return <span className={className}>{value.toFixed(1)}s</span>;
+    return <span className={`elapsed-time ${className}`}>{value.toFixed(1)}s</span>;
   }
 }

+ 135 - 76
public/app/containers/Explore/Explore.tsx

@@ -4,10 +4,10 @@ import colors from 'app/core/utils/colors';
 import TimeSeries from 'app/core/time_series2';
 
 import ElapsedTime from './ElapsedTime';
-import Legend from './Legend';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
 import Table from './Table';
+import TimePicker, { DEFAULT_RANGE } from './TimePicker';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 import { decodePathComponent } from 'app/core/utils/location_util';
@@ -16,39 +16,30 @@ function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
     const datapoints = seriesData.datapoints || [];
     const alias = seriesData.target;
-
     const colorIndex = index % colors.length;
     const color = colors[colorIndex];
 
     const series = new TimeSeries({
-      datapoints: datapoints,
-      alias: alias,
-      color: color,
+      datapoints,
+      alias,
+      color,
       unit: seriesData.unit,
     });
 
-    if (datapoints && datapoints.length > 0) {
-      const last = datapoints[datapoints.length - 1][1];
-      const from = options.range.from;
-      if (last - from < -10000) {
-        series.isOutsideRange = true;
-      }
-    }
-
     return series;
   });
 }
 
-function parseInitialQueries(initial) {
-  if (!initial) {
-    return [];
-  }
+function parseInitialState(initial) {
   try {
     const parsed = JSON.parse(decodePathComponent(initial));
-    return parsed.queries.map(q => q.query);
+    return {
+      queries: parsed.queries.map(q => q.query),
+      range: parsed.range,
+    };
   } catch (e) {
     console.error(e);
-    return [];
+    return { queries: [], range: DEFAULT_RANGE };
   }
 }
 
@@ -60,6 +51,8 @@ interface IExploreState {
   latency: number;
   loading: any;
   queries: any;
+  queryError: any;
+  range: any;
   requestOptions: any;
   showingGraph: boolean;
   showingTable: boolean;
@@ -72,7 +65,7 @@ export class Explore extends React.Component<any, IExploreState> {
 
   constructor(props) {
     super(props);
-    const initialQueries = parseInitialQueries(props.routeParams.initial);
+    const { range, queries } = parseInitialState(props.routeParams.initial);
     this.state = {
       datasource: null,
       datasourceError: null,
@@ -80,11 +73,14 @@ export class Explore extends React.Component<any, IExploreState> {
       graphResult: null,
       latency: 0,
       loading: false,
-      queries: ensureQueries(initialQueries),
+      queries: ensureQueries(queries),
+      queryError: null,
+      range: range || { ...DEFAULT_RANGE },
       requestOptions: null,
       showingGraph: true,
       showingTable: true,
       tableResult: null,
+      ...props.initialState,
     };
   }
 
@@ -98,6 +94,10 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   }
 
+  componentDidCatch(error) {
+    console.error(error);
+  }
+
   handleAddQueryRow = index => {
     const { queries } = this.state;
     const nextQueries = [
@@ -119,10 +119,32 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState({ queries: nextQueries });
   };
 
+  handleChangeTime = nextRange => {
+    const range = {
+      from: nextRange.from,
+      to: nextRange.to,
+    };
+    this.setState({ range }, () => this.handleSubmit());
+  };
+
+  handleClickCloseSplit = () => {
+    const { onChangeSplit } = this.props;
+    if (onChangeSplit) {
+      onChangeSplit(false);
+    }
+  };
+
   handleClickGraphButton = () => {
     this.setState(state => ({ showingGraph: !state.showingGraph }));
   };
 
+  handleClickSplit = () => {
+    const { onChangeSplit } = this.props;
+    if (onChangeSplit) {
+      onChangeSplit(true, this.state);
+    }
+  };
+
   handleClickTableButton = () => {
     this.setState(state => ({ showingTable: !state.showingTable }));
   };
@@ -147,17 +169,17 @@ export class Explore extends React.Component<any, IExploreState> {
   };
 
   async runGraphQuery() {
-    const { datasource, queries } = this.state;
+    const { datasource, queries, range } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, graphResult: null });
+    this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
     const now = Date.now();
     const options = buildQueryOptions({
       format: 'time_series',
       interval: datasource.interval,
       instant: false,
-      now,
+      range,
       queries: queries.map(q => q.query),
     });
     try {
@@ -165,24 +187,25 @@ export class Explore extends React.Component<any, IExploreState> {
       const result = makeTimeSeriesList(res.data, options);
       const latency = Date.now() - now;
       this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
-    } catch (error) {
-      console.error(error);
-      this.setState({ loading: false, graphResult: error });
+    } catch (response) {
+      console.error(response);
+      const queryError = response.data ? response.data.error : response;
+      this.setState({ loading: false, queryError });
     }
   }
 
   async runTableQuery() {
-    const { datasource, queries } = this.state;
+    const { datasource, queries, range } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, tableResult: null });
+    this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
     const now = Date.now();
     const options = buildQueryOptions({
       format: 'table',
       interval: datasource.interval,
       instant: true,
-      now,
+      range,
       queries: queries.map(q => q.query),
     });
     try {
@@ -190,9 +213,10 @@ export class Explore extends React.Component<any, IExploreState> {
       const tableModel = res.data[0];
       const latency = Date.now() - now;
       this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
-    } catch (error) {
-      console.error(error);
-      this.setState({ loading: false, tableResult: null });
+    } catch (response) {
+      console.error(response);
+      const queryError = response.data ? response.data.error : response;
+      this.setState({ loading: false, queryError });
     }
   }
 
@@ -202,6 +226,7 @@ export class Explore extends React.Component<any, IExploreState> {
   };
 
   render() {
+    const { position, split } = this.props;
     const {
       datasource,
       datasourceError,
@@ -210,59 +235,93 @@ export class Explore extends React.Component<any, IExploreState> {
       latency,
       loading,
       queries,
+      queryError,
+      range,
       requestOptions,
       showingGraph,
       showingTable,
       tableResult,
     } = this.state;
     const showingBoth = showingGraph && showingTable;
-    const graphHeight = showingBoth ? '200px' : null;
-    const graphButtonClassName = showingBoth || showingGraph ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
-    const tableButtonClassName = showingBoth || showingTable ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
+    const graphHeight = showingBoth ? '200px' : '400px';
+    const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
+    const tableButtonActive = showingBoth || showingTable ? 'active' : '';
+    const exploreClass = split ? 'explore explore-split' : 'explore';
     return (
-      <div className="explore">
-        <div className="page-body page-full">
-          <h2 className="page-sub-heading">Explore</h2>
-          {datasourceLoading ? <div>Loading datasource...</div> : null}
-
-          {datasourceError ? <div title={datasourceError}>Error connecting to datasource.</div> : null}
-
-          {datasource ? (
-            <div className="m-r-3">
-              <div className="nav m-b-1">
-                <div className="pull-right">
-                  {loading || latency ? <ElapsedTime time={latency} className="" /> : null}
-                  <button type="submit" className="m-l-1 btn btn-primary" onClick={this.handleSubmit}>
-                    <i className="fa fa-return" /> Run Query
-                  </button>
-                </div>
-                <div>
-                  <button className={graphButtonClassName} onClick={this.handleClickGraphButton}>
-                    Graph
-                  </button>
-                  <button className={tableButtonClassName} onClick={this.handleClickTableButton}>
-                    Table
-                  </button>
-                </div>
-              </div>
-              <QueryRows
-                queries={queries}
-                request={this.request}
-                onAddQueryRow={this.handleAddQueryRow}
-                onChangeQuery={this.handleChangeQuery}
-                onExecuteQuery={this.handleSubmit}
-                onRemoveQueryRow={this.handleRemoveQueryRow}
-              />
-              <main className="m-t-2">
-                {showingGraph ? (
-                  <Graph data={graphResult} id="explore-1" options={requestOptions} height={graphHeight} />
-                ) : null}
-                {showingGraph ? <Legend data={graphResult} /> : null}
-                {showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
-              </main>
+      <div className={exploreClass}>
+        <div className="navbar">
+          {position === 'left' ? (
+            <div>
+              <a className="navbar-page-btn">
+                <i className="fa fa-rocket" />
+                Explore
+              </a>
+            </div>
+          ) : (
+            <div className="navbar-buttons explore-first-button">
+              <button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
+                Close Split
+              </button>
+            </div>
+          )}
+          <div className="navbar__spacer" />
+          {position === 'left' && !split ? (
+            <div className="navbar-buttons">
+              <button className="btn navbar-button" onClick={this.handleClickSplit}>
+                Split
+              </button>
             </div>
           ) : null}
+          <div className="navbar-buttons">
+            <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
+              Graph
+            </button>
+            <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
+              Table
+            </button>
+          </div>
+          <TimePicker range={range} onChangeTime={this.handleChangeTime} />
+          <div className="navbar-buttons relative">
+            <button className="btn navbar-button--primary" onClick={this.handleSubmit}>
+              Run Query <i className="fa fa-level-down run-icon" />
+            </button>
+            {loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
+          </div>
         </div>
+
+        {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
+
+        {datasourceError ? (
+          <div className="explore-container" title={datasourceError}>
+            Error connecting to datasource.
+          </div>
+        ) : null}
+
+        {datasource ? (
+          <div className="explore-container">
+            <QueryRows
+              queries={queries}
+              request={this.request}
+              onAddQueryRow={this.handleAddQueryRow}
+              onChangeQuery={this.handleChangeQuery}
+              onExecuteQuery={this.handleSubmit}
+              onRemoveQueryRow={this.handleRemoveQueryRow}
+            />
+            {queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
+            <main className="m-t-2">
+              {showingGraph ? (
+                <Graph
+                  data={graphResult}
+                  id={`explore-graph-${position}`}
+                  options={requestOptions}
+                  height={graphHeight}
+                  split={split}
+                />
+              ) : null}
+              {showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+            </main>
+          </div>
+        ) : null}
       </div>
     );
   }

+ 23 - 10
public/app/containers/Explore/Graph.tsx

@@ -1,10 +1,13 @@
 import $ from 'jquery';
 import React, { Component } from 'react';
-
-import TimeSeries from 'app/core/time_series2';
+import moment from 'moment';
 
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot.time';
+import * as dateMath from 'app/core/utils/datemath';
+import TimeSeries from 'app/core/time_series2';
+
+import Legend from './Legend';
 
 // Copied from graph.ts
 function time_format(ticks, min, max) {
@@ -72,6 +75,7 @@ class Graph extends Component<any, any> {
     if (
       prevProps.data !== this.props.data ||
       prevProps.options !== this.props.options ||
+      prevProps.split !== this.props.split ||
       prevProps.height !== this.props.height
     ) {
       this.draw();
@@ -84,14 +88,22 @@ class Graph extends Component<any, any> {
       return;
     }
     const series = data.map((ts: TimeSeries) => ({
+      color: ts.color,
       label: ts.label,
       data: ts.getFlotPairs('null'),
     }));
 
     const $el = $(`#${this.props.id}`);
     const ticks = $el.width() / 100;
-    const min = userOptions.range.from.valueOf();
-    const max = userOptions.range.to.valueOf();
+    let { from, to } = userOptions.range;
+    if (!moment.isMoment(from)) {
+      from = dateMath.parse(from, false);
+    }
+    if (!moment.isMoment(to)) {
+      to = dateMath.parse(to, true);
+    }
+    const min = from.valueOf();
+    const max = to.valueOf();
     const dynamicOptions = {
       xaxis: {
         mode: 'time',
@@ -111,12 +123,13 @@ class Graph extends Component<any, any> {
   }
 
   render() {
-    const style = {
-      height: this.props.height || '400px',
-      width: this.props.width || '100%',
-    };
-
-    return <div id={this.props.id} style={style} />;
+    const { data, height } = this.props;
+    return (
+      <div className="panel-container">
+        <div id={this.props.id} className="explore-graph" style={{ height }} />
+        <Legend data={data} />
+      </div>
+    );
   }
 }
 

+ 1 - 1
public/app/containers/Explore/QueryField.tsx

@@ -50,7 +50,7 @@ class Portal extends React.Component {
   constructor(props) {
     super(props);
     this.node = document.createElement('div');
-    this.node.classList.add(`query-field-portal-${props.index}`);
+    this.node.classList.add('explore-typeahead', `explore-typeahead-${props.index}`);
     document.body.appendChild(this.node);
   }
 

+ 3 - 2
public/app/containers/Explore/QueryRows.tsx

@@ -48,10 +48,10 @@ class QueryRow extends PureComponent<any, any> {
     return (
       <div className="query-row">
         <div className="query-row-tools">
-          <button className="btn btn-small btn-inverse" onClick={this.handleClickAddButton}>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.handleClickAddButton}>
             <i className="fa fa-plus" />
           </button>
-          <button className="btn btn-small btn-inverse" onClick={this.handleClickRemoveButton}>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.handleClickRemoveButton}>
             <i className="fa fa-minus" />
           </button>
         </div>
@@ -60,6 +60,7 @@ class QueryRow extends PureComponent<any, any> {
             initialQuery={edited ? null : query}
             onPressEnter={this.handlePressEnter}
             onQueryChange={this.handleChangeQuery}
+            placeholder="Enter a PromQL query"
             request={request}
           />
         </div>

+ 74 - 0
public/app/containers/Explore/TimePicker.jest.tsx

@@ -0,0 +1,74 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import sinon from 'sinon';
+
+import * as rangeUtil from 'app/core/utils/rangeutil';
+import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
+
+describe('<TimePicker />', () => {
+  it('renders closed with default values', () => {
+    const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
+    const wrapper = shallow(<TimePicker />);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
+  });
+
+  it('renders with relative range', () => {
+    const range = {
+      from: 'now-7h',
+      to: 'now',
+    };
+    const rangeString = rangeUtil.describeTimeRange(range);
+    const wrapper = shallow(<TimePicker range={range} isOpen />);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.state('fromRaw')).toBe(range.from);
+    expect(wrapper.state('toRaw')).toBe(range.to);
+    expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
+    expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
+  });
+
+  it('renders with epoch (millies) range converted to ISO-ish', () => {
+    const range = {
+      from: '1',
+      to: '1000',
+    };
+    const rangeString = rangeUtil.describeTimeRange({
+      from: parseTime(range.from),
+      to: parseTime(range.to),
+    });
+    const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
+    expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
+  });
+
+  it('moves ranges forward and backward by half the range on arrow click', () => {
+    const range = {
+      from: '2000',
+      to: '4000',
+    };
+    const rangeString = rangeUtil.describeTimeRange({
+      from: parseTime(range.from),
+      to: parseTime(range.to),
+    });
+
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
+    expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
+
+    wrapper.find('.timepicker-left').simulate('click');
+    expect(onChangeTime.calledOnce).toBe(true);
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
+
+    wrapper.find('.timepicker-right').simulate('click');
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
+  });
+});

+ 245 - 0
public/app/containers/Explore/TimePicker.tsx

@@ -0,0 +1,245 @@
+import React, { PureComponent } from 'react';
+import moment from 'moment';
+
+import * as dateMath from 'app/core/utils/datemath';
+import * as rangeUtil from 'app/core/utils/rangeutil';
+
+const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+
+export const DEFAULT_RANGE = {
+  from: 'now-6h',
+  to: 'now',
+};
+
+export function parseTime(value, isUtc = false, asString = false) {
+  if (value.indexOf('now') !== -1) {
+    return value;
+  }
+  if (!isNaN(value)) {
+    const epoch = parseInt(value);
+    const m = isUtc ? moment.utc(epoch) : moment(epoch);
+    return asString ? m.format(DATE_FORMAT) : m;
+  }
+  return undefined;
+}
+
+export default class TimePicker extends PureComponent<any, any> {
+  dropdownEl: any;
+  constructor(props) {
+    super(props);
+
+    const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from;
+    const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to;
+    const range = {
+      from: parseTime(fromRaw),
+      to: parseTime(toRaw),
+    };
+    this.state = {
+      fromRaw: parseTime(fromRaw, props.isUtc, true),
+      isOpen: props.isOpen,
+      isUtc: props.isUtc,
+      rangeString: rangeUtil.describeTimeRange(range),
+      refreshInterval: '',
+      toRaw: parseTime(toRaw, props.isUtc, true),
+    };
+  }
+
+  move(direction) {
+    const { onChangeTime } = this.props;
+    const { fromRaw, toRaw } = this.state;
+    const range = {
+      from: dateMath.parse(fromRaw, false),
+      to: dateMath.parse(toRaw, true),
+    };
+
+    const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
+    let to, from;
+    if (direction === -1) {
+      to = range.to.valueOf() - timespan;
+      from = range.from.valueOf() - timespan;
+    } else if (direction === 1) {
+      to = range.to.valueOf() + timespan;
+      from = range.from.valueOf() + timespan;
+      if (to > Date.now() && range.to < Date.now()) {
+        to = Date.now();
+        from = range.from.valueOf();
+      }
+    } else {
+      to = range.to.valueOf();
+      from = range.from.valueOf();
+    }
+
+    const rangeString = rangeUtil.describeTimeRange(range);
+    // No need to convert to UTC again
+    to = moment(to);
+    from = moment(from);
+
+    this.setState(
+      {
+        rangeString,
+        fromRaw: from.format(DATE_FORMAT),
+        toRaw: to.format(DATE_FORMAT),
+      },
+      () => {
+        onChangeTime({ to, from });
+      }
+    );
+  }
+
+  handleChangeFrom = e => {
+    this.setState({
+      fromRaw: e.target.value,
+    });
+  };
+
+  handleChangeTo = e => {
+    this.setState({
+      toRaw: e.target.value,
+    });
+  };
+
+  handleClickApply = () => {
+    const { onChangeTime } = this.props;
+    const { toRaw, fromRaw } = this.state;
+    const range = {
+      from: dateMath.parse(fromRaw, false),
+      to: dateMath.parse(toRaw, true),
+    };
+    const rangeString = rangeUtil.describeTimeRange(range);
+    this.setState(
+      {
+        isOpen: false,
+        rangeString,
+      },
+      () => {
+        if (onChangeTime) {
+          onChangeTime(range);
+        }
+      }
+    );
+  };
+
+  handleClickLeft = () => this.move(-1);
+  handleClickPicker = () => {
+    this.setState(state => ({
+      isOpen: !state.isOpen,
+    }));
+  };
+  handleClickRight = () => this.move(1);
+  handleClickRefresh = () => {};
+  handleClickRelativeOption = range => {
+    const { onChangeTime } = this.props;
+    const rangeString = rangeUtil.describeTimeRange(range);
+    this.setState(
+      {
+        toRaw: range.to,
+        fromRaw: range.from,
+        isOpen: false,
+        rangeString,
+      },
+      () => {
+        if (onChangeTime) {
+          onChangeTime(range);
+        }
+      }
+    );
+  };
+
+  getTimeOptions() {
+    return rangeUtil.getRelativeTimesList({}, this.state.rangeString);
+  }
+
+  dropdownRef = el => {
+    this.dropdownEl = el;
+  };
+
+  renderDropdown() {
+    const { fromRaw, isOpen, toRaw } = this.state;
+    if (!isOpen) {
+      return null;
+    }
+    const timeOptions = this.getTimeOptions();
+    return (
+      <div ref={this.dropdownRef} className="gf-timepicker-dropdown">
+        <div className="gf-timepicker-absolute-section">
+          <h3 className="section-heading">Custom range</h3>
+
+          <label className="small">From:</label>
+          <div className="gf-form-inline">
+            <div className="gf-form max-width-28">
+              <input
+                type="text"
+                className="gf-form-input input-large timepicker-from"
+                value={fromRaw}
+                onChange={this.handleChangeFrom}
+              />
+            </div>
+          </div>
+
+          <label className="small">To:</label>
+          <div className="gf-form-inline">
+            <div className="gf-form max-width-28">
+              <input
+                type="text"
+                className="gf-form-input input-large timepicker-to"
+                value={toRaw}
+                onChange={this.handleChangeTo}
+              />
+            </div>
+          </div>
+
+          {/* <label className="small">Refreshing every:</label>
+          <div className="gf-form-inline">
+            <div className="gf-form max-width-28">
+              <select className="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
+            </div>
+          </div> */}
+          <div className="gf-form">
+            <button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
+              Apply
+            </button>
+          </div>
+        </div>
+
+        <div className="gf-timepicker-relative-section">
+          <h3 className="section-heading">Quick ranges</h3>
+          {Object.keys(timeOptions).map(section => {
+            const group = timeOptions[section];
+            return (
+              <ul key={section}>
+                {group.map(option => (
+                  <li className={option.active ? 'active' : ''} key={option.display}>
+                    <a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
+                  </li>
+                ))}
+              </ul>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { isUtc, rangeString, refreshInterval } = this.state;
+    return (
+      <div className="timepicker">
+        <div className="navbar-buttons">
+          <button className="btn navbar-button navbar-button--tight timepicker-left" onClick={this.handleClickLeft}>
+            <i className="fa fa-chevron-left" />
+          </button>
+          <button className="btn navbar-button gf-timepicker-nav-btn" onClick={this.handleClickPicker}>
+            <i className="fa fa-clock-o" />
+            <span className="timepicker-rangestring">{rangeString}</span>
+            {isUtc ? <span className="gf-timepicker-utc">UTC</span> : null}
+            {refreshInterval ? <span className="text-warning">&nbsp; Refresh every {refreshInterval}</span> : null}
+          </button>
+          <button className="btn navbar-button navbar-button--tight timepicker-right" onClick={this.handleClickRight}>
+            <i className="fa fa-chevron-right" />
+          </button>
+        </div>
+        {this.renderDropdown()}
+      </div>
+    );
+  }
+}

+ 33 - 0
public/app/containers/Explore/Wrapper.tsx

@@ -0,0 +1,33 @@
+import React, { PureComponent } from 'react';
+
+import Explore from './Explore';
+
+export default class Wrapper extends PureComponent<any, any> {
+  state = {
+    initialState: null,
+    split: false,
+  };
+
+  handleChangeSplit = (split, initialState) => {
+    this.setState({ split, initialState });
+  };
+
+  render() {
+    // State overrides for props from first Explore
+    const { initialState, split } = this.state;
+    return (
+      <div className="explore-wrapper">
+        <Explore {...this.props} position="left" onChangeSplit={this.handleChangeSplit} split={split} />
+        {split ? (
+          <Explore
+            {...this.props}
+            initialState={initialState}
+            onChangeSplit={this.handleChangeSplit}
+            position="right"
+            split={split}
+          />
+        ) : null}
+      </div>
+    );
+  }
+}

+ 2 - 7
public/app/containers/Explore/utils/query.ts

@@ -1,12 +1,7 @@
-export function buildQueryOptions({ format, interval, instant, now, queries }) {
-  const to = now;
-  const from = to - 1000 * 60 * 60 * 3;
+export function buildQueryOptions({ format, interval, instant, range, queries }) {
   return {
     interval,
-    range: {
-      from,
-      to,
-    },
+    range,
     targets: queries.map(expr => ({
       expr,
       format,

+ 7 - 2
public/app/core/services/keybindingSrv.ts

@@ -14,7 +14,7 @@ export class KeybindingSrv {
   timepickerOpen = false;
 
   /** @ngInject */
-  constructor(private $rootScope, private $location, private datasourceSrv) {
+  constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv) {
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
@@ -182,7 +182,12 @@ export class KeybindingSrv {
         const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
         const datasource = await this.datasourceSrv.get(panel.datasource);
         if (datasource && datasource.supportsExplore) {
-          const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
+          const range = this.timeSrv.timeRangeForUrl();
+          const state = {
+            ...datasource.getExploreState(panel),
+            range,
+          };
+          const exploreState = encodePathComponent(JSON.stringify(state));
           this.$location.url(`/explore/${exploreState}`);
         }
       }

+ 6 - 1
public/app/features/panel/metrics_panel_ctrl.ts

@@ -324,7 +324,12 @@ class MetricsPanelCtrl extends PanelCtrl {
   }
 
   explore() {
-    const exploreState = encodePathComponent(JSON.stringify(this.datasource.getExploreState(this.panel)));
+    const range = this.timeSrv.timeRangeForUrl();
+    const state = {
+      ...this.datasource.getExploreState(this.panel),
+      range,
+    };
+    const exploreState = encodePathComponent(JSON.stringify(state));
     this.$location.url(`/explore/${exploreState}`);
   }
 

+ 1 - 0
public/app/plugins/datasource/prometheus/datasource.ts

@@ -164,6 +164,7 @@ export class PrometheusDatasource {
           legendFormat: activeTargets[index].legendFormat,
           start: start,
           end: end,
+          query: queries[index].expr,
           responseListLength: responseList.length,
           responseIndex: index,
           refId: activeTargets[index].refId,

+ 8 - 3
public/app/plugins/datasource/prometheus/result_transformer.ts

@@ -123,11 +123,16 @@ export class ResultTransformer {
   }
 
   createMetricLabel(labelData, options) {
+    let label = '';
     if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
-      return this.getOriginalMetricName(labelData);
+      label = this.getOriginalMetricName(labelData);
+    } else {
+      label = this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData);
     }
-
-    return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
+    if (!label || label === '{}') {
+      label = options.query;
+    }
+    return label;
   }
 
   renderTemplate(aliasPattern, aliasData) {

+ 1 - 2
public/app/routes/routes.ts

@@ -3,7 +3,6 @@ import './ReactContainer';
 
 import ServerStats from 'app/containers/ServerStats/ServerStats';
 import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
-// import Explore from 'app/containers/Explore/Explore';
 import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
 import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
 
@@ -114,7 +113,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     .when('/explore/:initial?', {
       template: '<react-container />',
       resolve: {
-        component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
+        component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Wrapper'),
       },
     })
     .when('/org', {

+ 12 - 0
public/sass/_variables.dark.scss

@@ -45,6 +45,10 @@ $brand-warning: $brand-primary;
 $brand-danger: $red;
 
 $query-blue: $blue;
+$query-red: $red;
+$query-green: $green;
+$query-purple: $purple;
+$query-orange: $orange;
 
 // Status colors
 // -------------------------
@@ -176,6 +180,9 @@ $btn-inverse-bg-hl: lighten($dark-3, 4%);
 $btn-inverse-text-color: $link-color;
 $btn-inverse-text-shadow: 0px 1px 0 rgba(0, 0, 0, 0.1);
 
+$btn-active-bg: $gray-4;
+$btn-active-text-color: $blue-dark;
+
 $btn-link-color: $gray-3;
 
 $iconContainerBackground: $black;
@@ -204,6 +211,11 @@ $input-invalid-border-color: lighten($red, 5%);
 $search-shadow: 0 0 30px 0 $black;
 $search-filter-box-bg: $gray-blue;
 
+// Typeahead
+$typeahead-shadow: 0 5px 10px 0 $black;
+$typeahead-selected-bg: $dark-4;
+$typeahead-selected-color: $blue;
+
 // Dropdowns
 // -------------------------
 $dropdownBackground: $dark-3;

+ 12 - 0
public/sass/_variables.light.scss

@@ -46,6 +46,10 @@ $brand-warning: $orange;
 $brand-danger: $red;
 
 $query-blue: $blue-dark;
+$query-red: $red;
+$query-green: $green;
+$query-purple: $purple;
+$query-orange: $orange;
 
 // Status colors
 // -------------------------
@@ -173,6 +177,9 @@ $btn-inverse-bg-hl: darken($gray-6, 5%);
 $btn-inverse-text-color: $gray-1;
 $btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
 
+$btn-active-bg: $white;
+$btn-active-text-color: $blue-dark;
+
 $btn-link-color: $gray-1;
 
 $btn-divider-left: $gray-4;
@@ -226,6 +233,11 @@ $tab-border-color: $gray-5;
 $search-shadow: 0 5px 30px 0 $gray-4;
 $search-filter-box-bg: $gray-7;
 
+// Typeahead
+$typeahead-shadow: 0 5px 10px 0 $gray-5;
+$typeahead-selected-bg: lighten($blue, 25%);
+$typeahead-selected-color: $blue-dark;
+
 // Dropdowns
 // -------------------------
 $dropdownBackground: $white;

+ 163 - 244
public/sass/pages/_explore.scss

@@ -1,11 +1,89 @@
 .explore {
+  width: 100%;
+
+  &-container {
+    padding: 2rem;
+  }
+
+  &-wrapper {
+    display: flex;
+
+    > .explore-split {
+      width: 50%;
+    }
+  }
+
+  // Push split button a bit
+  .explore-first-button {
+    margin-left: 15px;
+  }
+
+  // Graph panel needs a bit extra padding at top
+  .panel-container {
+    padding: $panel-padding;
+    padding-top: 10px;
+  }
+
+  // Make sure wrap buttons around on small screens
+  .navbar {
+    flex-wrap: wrap;
+    height: auto;
+  }
+
+  .navbar-page-btn {
+    margin-right: 1rem;
+
+    // Explore icon in header
+    .fa {
+      font-size: 100%;
+      opacity: 0.75;
+      margin-right: 0.5em;
+    }
+  }
+
+  // Toggle mode
+  .navbar-button.active {
+    color: $btn-active-text-color;
+    background-color: $btn-active-bg;
+  }
+
+  .elapsed-time {
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 3.5rem;
+    text-align: center;
+    font-size: 0.8rem;
+  }
+
   .graph-legend {
     flex-wrap: wrap;
   }
+
+  .timepicker {
+    display: flex;
+
+    &-rangestring {
+      margin-left: 0.5em;
+    }
+  }
+
+  .run-icon {
+    margin-left: 0.5em;
+    transform: rotate(90deg);
+  }
+
+  .relative {
+    position: relative;
+  }
+}
+
+.explore + .explore {
+  border-left: 1px dotted $table-border;
 }
 
 .query-row {
-  position: relative;
+  display: flex;
 
   & + & {
     margin-top: 0.5rem;
@@ -13,17 +91,12 @@
 }
 
 .query-row-tools {
-  position: absolute;
-  left: -4rem;
-  top: 0.33rem;
-  > * {
-    margin-right: 0.25rem;
-  }
+  width: 4rem;
 }
 
 .query-field {
-  font-size: 14px;
-  font-family: Consolas, Menlo, Courier, monospace;
+  font-size: $font-size-root;
+  font-family: $font-family-monospace;
   height: auto;
 }
 
@@ -33,54 +106,52 @@
   padding: 6px 7px 4px;
   width: 100%;
   cursor: text;
-  line-height: 1.5;
-  color: rgba(0, 0, 0, 0.65);
-  background-color: #fff;
+  line-height: $line-height-base;
+  color: $text-color-weak;
+  background-color: $panel-bg;
   background-image: none;
-  border: 1px solid lightgray;
-  border-radius: 3px;
+  border: $panel-border;
+  border-radius: $border-radius;
   transition: all 0.3s;
 }
 
-.explore {
+.explore-typeahead {
   .typeahead {
     position: absolute;
     z-index: auto;
     top: -10000px;
     left: -10000px;
     opacity: 0;
-    border-radius: 4px;
+    border-radius: $border-radius;
     transition: opacity 0.75s;
-    border: 1px solid #e4e4e4;
+    border: $panel-border;
     max-height: calc(66vh);
     overflow-y: scroll;
     max-width: calc(66%);
     overflow-x: hidden;
     outline: none;
     list-style: none;
-    background: #fff;
-    color: rgba(0, 0, 0, 0.65);
+    background: $panel-bg;
+    color: $text-color;
     transition: opacity 0.4s ease-out;
+    box-shadow: $typeahead-shadow;
   }
 
   .typeahead-group__title {
-    color: rgba(0, 0, 0, 0.43);
-    font-size: 12px;
-    line-height: 1.5;
-    padding: 8px 16px;
+    color: $text-color-weak;
+    font-size: $font-size-sm;
+    line-height: $line-height-base;
+    padding: $input-padding-y $input-padding-x;
   }
 
   .typeahead-item {
-    line-height: 200%;
     height: auto;
-    font-family: Consolas, Menlo, Courier, monospace;
-    padding: 0 16px 0 28px;
-    font-size: 12px;
+    font-family: $font-family-monospace;
+    padding: $input-padding-y $input-padding-x;
+    padding-left: $input-padding-x-lg;
+    font-size: $font-size-sm;
     text-overflow: ellipsis;
     overflow: hidden;
-    margin-left: -1px;
-    left: 1px;
-    position: relative;
     z-index: 1;
     display: block;
     white-space: nowrap;
@@ -90,234 +161,82 @@
   }
 
   .typeahead-item__selected {
-    background-color: #ecf6fd;
-    color: #108ee9;
+    background-color: $typeahead-selected-bg;
+    color: $typeahead-selected-color;
   }
 }
 
 /* SYNTAX */
 
-/**
- * prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML
- * Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics);
- * @author Tim  Shedor
- */
-
-code[class*='language-'],
-pre[class*='language-'] {
-  color: black;
-  background: none;
-  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
-  text-align: left;
-  white-space: pre;
-  word-spacing: normal;
-  word-break: normal;
-  word-wrap: normal;
-  line-height: 1.5;
-
-  -moz-tab-size: 4;
-  -o-tab-size: 4;
-  tab-size: 4;
-
-  -webkit-hyphens: none;
-  -moz-hyphens: none;
-  -ms-hyphens: none;
-  hyphens: none;
-}
-
-/* Code blocks */
-pre[class*='language-'] {
-  position: relative;
-  margin: 0.5em 0;
-  overflow: visible;
-  padding: 0;
-}
-pre[class*='language-'] > code {
-  position: relative;
-  border-left: 10px solid #358ccb;
-  box-shadow: -1px 0px 0px 0px #358ccb, 0px 0px 0px 1px #dfdfdf;
-  background-color: #fdfdfd;
-  background-image: linear-gradient(transparent 50%, rgba(69, 142, 209, 0.04) 50%);
-  background-size: 3em 3em;
-  background-origin: content-box;
-  background-attachment: local;
-}
-
-code[class*='language'] {
-  max-height: inherit;
-  height: inherit;
-  padding: 0 1em;
-  display: block;
-  overflow: auto;
-}
-
-/* Margin bottom to accomodate shadow */
-:not(pre) > code[class*='language-'],
-pre[class*='language-'] {
-  background-color: #fdfdfd;
-  -webkit-box-sizing: border-box;
-  -moz-box-sizing: border-box;
-  box-sizing: border-box;
-  margin-bottom: 1em;
-}
-
-/* Inline code */
-:not(pre) > code[class*='language-'] {
-  position: relative;
-  padding: 0.2em;
-  border-radius: 0.3em;
-  color: #c92c2c;
-  border: 1px solid rgba(0, 0, 0, 0.1);
-  display: inline;
-  white-space: normal;
-}
-
-pre[class*='language-']:before,
-pre[class*='language-']:after {
-  content: '';
-  z-index: -2;
-  display: block;
-  position: absolute;
-  bottom: 0.75em;
-  left: 0.18em;
-  width: 40%;
-  height: 20%;
-  max-height: 13em;
-  box-shadow: 0px 13px 8px #979797;
-  -webkit-transform: rotate(-2deg);
-  -moz-transform: rotate(-2deg);
-  -ms-transform: rotate(-2deg);
-  -o-transform: rotate(-2deg);
-  transform: rotate(-2deg);
-}
-
-:not(pre) > code[class*='language-']:after,
-pre[class*='language-']:after {
-  right: 0.75em;
-  left: auto;
-  -webkit-transform: rotate(2deg);
-  -moz-transform: rotate(2deg);
-  -ms-transform: rotate(2deg);
-  -o-transform: rotate(2deg);
-  transform: rotate(2deg);
-}
-
-.token.comment,
-.token.block-comment,
-.token.prolog,
-.token.doctype,
-.token.cdata {
-  color: #7d8b99;
-}
-
-.token.punctuation {
-  color: #5f6364;
-}
-
-.token.property,
-.token.tag,
-.token.boolean,
-.token.number,
-.token.function-name,
-.token.constant,
-.token.symbol,
-.token.deleted {
-  color: #c92c2c;
-}
-
-.token.selector,
-.token.attr-name,
-.token.string,
-.token.char,
-.token.function,
-.token.builtin,
-.token.inserted {
-  color: #2f9c0a;
-}
-
-.token.operator,
-.token.entity,
-.token.url,
-.token.variable {
-  color: #a67f59;
-  background: rgba(255, 255, 255, 0.5);
-}
-
-.token.atrule,
-.token.attr-value,
-.token.keyword,
-.token.class-name {
-  color: #1990b8;
-}
-
-.token.regex,
-.token.important {
-  color: #e90;
-}
-
-.language-css .token.string,
-.style .token.string {
-  color: #a67f59;
-  background: rgba(255, 255, 255, 0.5);
-}
+.explore {
+  .token.comment,
+  .token.block-comment,
+  .token.prolog,
+  .token.doctype,
+  .token.cdata {
+    color: $text-color-weak;
+  }
 
-.token.important {
-  font-weight: normal;
-}
+  .token.punctuation {
+    color: $text-color-weak;
+  }
 
-.token.bold {
-  font-weight: bold;
-}
-.token.italic {
-  font-style: italic;
-}
+  .token.property,
+  .token.tag,
+  .token.boolean,
+  .token.number,
+  .token.function-name,
+  .token.constant,
+  .token.symbol,
+  .token.deleted {
+    color: $query-red;
+  }
 
-.token.entity {
-  cursor: help;
-}
+  .token.selector,
+  .token.attr-name,
+  .token.string,
+  .token.char,
+  .token.function,
+  .token.builtin,
+  .token.inserted {
+    color: $query-green;
+  }
 
-.namespace {
-  opacity: 0.7;
-}
+  .token.operator,
+  .token.entity,
+  .token.url,
+  .token.variable {
+    color: $query-purple;
+  }
 
-@media screen and (max-width: 767px) {
-  pre[class*='language-']:before,
-  pre[class*='language-']:after {
-    bottom: 14px;
-    box-shadow: none;
+  .token.atrule,
+  .token.attr-value,
+  .token.keyword,
+  .token.class-name {
+    color: $query-blue;
   }
-}
 
-/* Plugin styles */
-.token.tab:not(:empty):before,
-.token.cr:before,
-.token.lf:before {
-  color: #e0d7d1;
-}
+  .token.regex,
+  .token.important {
+    color: $query-orange;
+  }
 
-/* Plugin styles: Line Numbers */
-pre[class*='language-'].line-numbers {
-  padding-left: 0;
-}
+  .token.important {
+    font-weight: normal;
+  }
 
-pre[class*='language-'].line-numbers code {
-  padding-left: 3.8em;
-}
+  .token.bold {
+    font-weight: bold;
+  }
+  .token.italic {
+    font-style: italic;
+  }
 
-pre[class*='language-'].line-numbers .line-numbers-rows {
-  left: 0;
-}
+  .token.entity {
+    cursor: help;
+  }
 
-/* Plugin styles: Line Highlight */
-pre[class*='language-'][data-line] {
-  padding-top: 0;
-  padding-bottom: 0;
-  padding-left: 0;
-}
-pre[data-line] code {
-  position: relative;
-  padding-left: 4em;
-}
-pre .line-highlight {
-  margin-top: 0;
+  .namespace {
+    opacity: 0.7;
+  }
 }