浏览代码

Explore WIP

David Kaltschmidt 7 年之前
父节点
当前提交
f1220fd2a4
共有 31 个文件被更改,包括 2216 次插入259 次删除
  1. 6 0
      package.json
  2. 11 0
      pkg/api/index.go
  3. 46 0
      public/app/containers/Explore/ElapsedTime.tsx
  4. 246 0
      public/app/containers/Explore/Explore.tsx
  5. 123 0
      public/app/containers/Explore/Graph.tsx
  6. 22 0
      public/app/containers/Explore/Legend.tsx
  7. 562 0
      public/app/containers/Explore/QueryField.tsx
  8. 24 0
      public/app/containers/Explore/Table.tsx
  9. 66 0
      public/app/containers/Explore/Typeahead.tsx
  10. 47 0
      public/app/containers/Explore/slate-plugins/braces.test.ts
  11. 51 0
      public/app/containers/Explore/slate-plugins/braces.ts
  12. 38 0
      public/app/containers/Explore/slate-plugins/clear.test.ts
  13. 22 0
      public/app/containers/Explore/slate-plugins/clear.ts
  14. 35 0
      public/app/containers/Explore/slate-plugins/newline.ts
  15. 122 0
      public/app/containers/Explore/slate-plugins/prism/index.tsx
  16. 123 0
      public/app/containers/Explore/slate-plugins/prism/promql.ts
  17. 14 0
      public/app/containers/Explore/slate-plugins/runner.ts
  18. 14 0
      public/app/containers/Explore/utils/debounce.ts
  19. 40 0
      public/app/containers/Explore/utils/dom.ts
  20. 20 0
      public/app/containers/Explore/utils/prometheus.ts
  21. 14 2
      public/app/core/components/grafana_app.ts
  22. 1 1
      public/app/features/plugins/datasource_srv.ts
  23. 7 3
      public/app/routes/ReactContainer.tsx
  24. 8 0
      public/app/routes/routes.ts
  25. 2 2
      public/app/stores/store.ts
  26. 1 0
      public/sass/_grafana.scss
  27. 7 0
      public/sass/layout/_page.scss
  28. 304 0
      public/sass/pages/_explore.scss
  29. 1 0
      scripts/webpack/webpack.dev.js
  30. 6 1
      scripts/webpack/webpack.prod.js
  31. 233 250
      yarn.lock

+ 6 - 0
package.json

@@ -22,6 +22,7 @@
     "axios": "^0.17.1",
     "babel-core": "^6.26.0",
     "babel-loader": "^7.1.2",
+    "babel-plugin-syntax-dynamic-import": "^6.18.0",
     "babel-preset-es2015": "^6.24.1",
     "clean-webpack-plugin": "^0.1.19",
     "css-loader": "^0.28.7",
@@ -150,6 +151,7 @@
     "d3-scale-chromatic": "^1.1.1",
     "eventemitter3": "^2.0.3",
     "file-saver": "^1.3.3",
+    "immutable": "^3.8.2",
     "jquery": "^3.2.1",
     "lodash": "^4.17.4",
     "mobx": "^3.4.1",
@@ -158,6 +160,7 @@
     "moment": "^2.18.1",
     "mousetrap": "^1.6.0",
     "mousetrap-global-bind": "^1.1.0",
+    "prismjs": "^1.6.0",
     "prop-types": "^15.6.0",
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
@@ -170,6 +173,9 @@
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",
+    "slate": "^0.33.4",
+    "slate-plain-serializer": "^0.5.10",
+    "slate-react": "^0.12.4",
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "tinycolor2": "^1.4.1"

+ 11 - 0
pkg/api/index.go

@@ -117,6 +117,17 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		Children: dashboardChildNavs,
 	})
 
+	// data.NavTree = append(data.NavTree, &dtos.NavLink{
+	// 	Text:     "Explore",
+	// 	Id:       "explore",
+	// 	SubTitle: "Explore your data",
+	// 	Icon:     "fa fa-rocket",
+	// 	Url:      setting.AppSubUrl + "/explore",
+	// 	Children: []*dtos.NavLink{
+	// 		{Text: "New tab", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/explore/new"},
+	// 	},
+	// })
+
 	if c.IsSignedIn {
 		// Only set login if it's different from the name
 		var login string

+ 46 - 0
public/app/containers/Explore/ElapsedTime.tsx

@@ -0,0 +1,46 @@
+import React, { PureComponent } from 'react';
+
+const INTERVAL = 150;
+
+export default class ElapsedTime extends PureComponent<any, any> {
+  offset: number;
+  timer: NodeJS.Timer;
+
+  state = {
+    elapsed: 0,
+  };
+
+  start() {
+    this.offset = Date.now();
+    this.timer = setInterval(this.tick, INTERVAL);
+  }
+
+  tick = () => {
+    const jetzt = Date.now();
+    const elapsed = jetzt - this.offset;
+    this.setState({ elapsed });
+  };
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.time) {
+      clearInterval(this.timer);
+    } else if (this.props.time) {
+      this.start();
+    }
+  }
+
+  componentDidMount() {
+    this.start();
+  }
+
+  componentWillUnmount() {
+    clearInterval(this.timer);
+  }
+
+  render() {
+    const { elapsed } = this.state;
+    const { className, time } = this.props;
+    const value = (time || elapsed) / 1000;
+    return <span className={className}>{value.toFixed(1)}s</span>;
+  }
+}

+ 246 - 0
public/app/containers/Explore/Explore.tsx

@@ -0,0 +1,246 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import colors from 'app/core/utils/colors';
+import TimeSeries from 'app/core/time_series2';
+
+import ElapsedTime from './ElapsedTime';
+import Legend from './Legend';
+import QueryField from './QueryField';
+import Graph from './Graph';
+import Table from './Table';
+import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
+
+function buildQueryOptions({ format, interval, instant, now, query }) {
+  const to = now;
+  const from = to - 1000 * 60 * 60 * 3;
+  return {
+    interval,
+    range: {
+      from,
+      to,
+    },
+    targets: [
+      {
+        expr: query,
+        format,
+        instant,
+      },
+    ],
+  };
+}
+
+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,
+      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;
+  });
+}
+
+interface IExploreState {
+  datasource: any;
+  datasourceError: any;
+  datasourceLoading: any;
+  graphResult: any;
+  latency: number;
+  loading: any;
+  requestOptions: any;
+  showingGraph: boolean;
+  showingTable: boolean;
+  tableResult: any;
+}
+
+// @observer
+export class Explore extends React.Component<any, IExploreState> {
+  datasourceSrv: DatasourceSrv;
+  query: string;
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      datasource: null,
+      datasourceError: null,
+      datasourceLoading: true,
+      graphResult: null,
+      latency: 0,
+      loading: false,
+      requestOptions: null,
+      showingGraph: true,
+      showingTable: true,
+      tableResult: null,
+    };
+  }
+
+  async componentDidMount() {
+    const datasource = await this.props.datasourceSrv.get();
+    const testResult = await datasource.testDatasource();
+    if (testResult.status === 'success') {
+      this.setState({ datasource, datasourceError: null, datasourceLoading: false });
+    } else {
+      this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
+    }
+  }
+
+  handleClickGraphButton = () => {
+    this.setState(state => ({ showingGraph: !state.showingGraph }));
+  };
+
+  handleClickTableButton = () => {
+    this.setState(state => ({ showingTable: !state.showingTable }));
+  };
+
+  handleRequestError({ error }) {
+    console.error(error);
+  }
+
+  handleQueryChange = query => {
+    this.query = query;
+  };
+
+  handleSubmit = () => {
+    const { showingGraph, showingTable } = this.state;
+    if (showingTable) {
+      this.runTableQuery();
+    }
+    if (showingGraph) {
+      this.runGraphQuery();
+    }
+  };
+
+  async runGraphQuery() {
+    const { query } = this;
+    const { datasource } = this.state;
+    if (!query) {
+      return;
+    }
+    this.setState({ latency: 0, loading: true, graphResult: null });
+    const now = Date.now();
+    const options = buildQueryOptions({
+      format: 'time_series',
+      interval: datasource.interval,
+      instant: false,
+      now,
+      query,
+    });
+    try {
+      const res = await datasource.query(options);
+      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 });
+    }
+  }
+
+  async runTableQuery() {
+    const { query } = this;
+    const { datasource } = this.state;
+    if (!query) {
+      return;
+    }
+    this.setState({ latency: 0, loading: true, tableResult: null });
+    const now = Date.now();
+    const options = buildQueryOptions({ format: 'table', interval: datasource.interval, instant: true, now, query });
+    try {
+      const res = await datasource.query(options);
+      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 });
+    }
+  }
+
+  request = url => {
+    const { datasource } = this.state;
+    return datasource.metadataRequest(url);
+  };
+
+  render() {
+    const {
+      datasource,
+      datasourceError,
+      datasourceLoading,
+      latency,
+      loading,
+      requestOptions,
+      graphResult,
+      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';
+    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" style={{ paddingRight: '6rem' }}>
+                  <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>
+              <div className="query-field-wrapper">
+                <QueryField
+                  request={this.request}
+                  onPressEnter={this.handleSubmit}
+                  onQueryChange={this.handleQueryChange}
+                  onRequestError={this.handleRequestError}
+                />
+              </div>
+              {loading || latency ? <ElapsedTime time={latency} className="m-l-1" /> : null}
+              <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>
+          ) : null}
+        </div>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(Explore);

+ 123 - 0
public/app/containers/Explore/Graph.tsx

@@ -0,0 +1,123 @@
+import $ from 'jquery';
+import React, { Component } from 'react';
+
+import TimeSeries from 'app/core/time_series2';
+
+import 'vendor/flot/jquery.flot';
+import 'vendor/flot/jquery.flot.time';
+
+// Copied from graph.ts
+function time_format(ticks, min, max) {
+  if (min && max && ticks) {
+    var range = max - min;
+    var secPerTick = range / ticks / 1000;
+    var oneDay = 86400000;
+    var oneYear = 31536000000;
+
+    if (secPerTick <= 45) {
+      return '%H:%M:%S';
+    }
+    if (secPerTick <= 7200 || range <= oneDay) {
+      return '%H:%M';
+    }
+    if (secPerTick <= 80000) {
+      return '%m/%d %H:%M';
+    }
+    if (secPerTick <= 2419200 || range <= oneYear) {
+      return '%m/%d';
+    }
+    return '%Y-%m';
+  }
+
+  return '%H:%M';
+}
+
+const FLOT_OPTIONS = {
+  legend: {
+    show: false,
+  },
+  series: {
+    lines: {
+      linewidth: 1,
+      zero: false,
+    },
+    shadowSize: 0,
+  },
+  grid: {
+    minBorderMargin: 0,
+    markings: [],
+    backgroundColor: null,
+    borderWidth: 0,
+    // hoverable: true,
+    clickable: true,
+    color: '#a1a1a1',
+    margin: { left: 0, right: 0 },
+    labelMarginX: 0,
+  },
+  // selection: {
+  //   mode: 'x',
+  //   color: '#666',
+  // },
+  // crosshair: {
+  //   mode: 'x',
+  // },
+};
+
+class Graph extends Component<any, any> {
+  componentDidMount() {
+    this.draw();
+  }
+
+  componentDidUpdate(prevProps) {
+    if (
+      prevProps.data !== this.props.data ||
+      prevProps.options !== this.props.options ||
+      prevProps.height !== this.props.height
+    ) {
+      this.draw();
+    }
+  }
+
+  draw() {
+    const { data, options: userOptions } = this.props;
+    if (!data) {
+      return;
+    }
+    const series = data.map((ts: TimeSeries) => ({
+      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();
+    const dynamicOptions = {
+      xaxis: {
+        mode: 'time',
+        min: min,
+        max: max,
+        label: 'Datetime',
+        ticks: ticks,
+        timeformat: time_format(ticks, min, max),
+      },
+    };
+    const options = {
+      ...FLOT_OPTIONS,
+      ...dynamicOptions,
+      ...userOptions,
+    };
+    $.plot($el, series, options);
+  }
+
+  render() {
+    const style = {
+      height: this.props.height || '400px',
+      width: this.props.width || '100%',
+    };
+
+    return <div id={this.props.id} style={style} />;
+  }
+}
+
+export default Graph;

+ 22 - 0
public/app/containers/Explore/Legend.tsx

@@ -0,0 +1,22 @@
+import React, { PureComponent } from 'react';
+
+const LegendItem = ({ series }) => (
+  <div className="graph-legend-series">
+    <div className="graph-legend-icon">
+      <i className="fa fa-minus pointer" style={{ color: series.color }} />
+    </div>
+    <a className="graph-legend-alias pointer">{series.alias}</a>
+  </div>
+);
+
+export default class Legend extends PureComponent<any, any> {
+  render() {
+    const { className = '', data } = this.props;
+    const items = data || [];
+    return (
+      <div className={`${className} graph-legend ps`}>
+        {items.map(series => <LegendItem key={series.id} series={series} />)}
+      </div>
+    );
+  }
+}

+ 562 - 0
public/app/containers/Explore/QueryField.tsx

@@ -0,0 +1,562 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Value } from 'slate';
+import { Editor } from 'slate-react';
+import Plain from 'slate-plain-serializer';
+
+// dom also includes Element polyfills
+import { getNextCharacter, getPreviousCousin } from './utils/dom';
+import BracesPlugin from './slate-plugins/braces';
+import ClearPlugin from './slate-plugins/clear';
+import NewlinePlugin from './slate-plugins/newline';
+import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index';
+import RunnerPlugin from './slate-plugins/runner';
+import debounce from './utils/debounce';
+import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
+
+import Typeahead from './Typeahead';
+
+const EMPTY_METRIC = '';
+const TYPEAHEAD_DEBOUNCE = 300;
+
+function flattenSuggestions(s) {
+  return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
+}
+
+const getInitialValue = query =>
+  Value.fromJSON({
+    document: {
+      nodes: [
+        {
+          object: 'block',
+          type: 'paragraph',
+          nodes: [
+            {
+              object: 'text',
+              leaves: [
+                {
+                  text: query,
+                },
+              ],
+            },
+          ],
+        },
+      ],
+    },
+  });
+
+class Portal extends React.Component {
+  node: any;
+  constructor(props) {
+    super(props);
+    this.node = document.createElement('div');
+    this.node.classList.add(`query-field-portal-${props.index}`);
+    document.body.appendChild(this.node);
+  }
+
+  componentWillUnmount() {
+    document.body.removeChild(this.node);
+  }
+
+  render() {
+    return ReactDOM.createPortal(this.props.children, this.node);
+  }
+}
+
+class QueryField extends React.Component<any, any> {
+  menuEl: any;
+  plugins: any;
+  resetTimer: any;
+
+  constructor(props, context) {
+    super(props, context);
+
+    this.plugins = [
+      BracesPlugin(),
+      ClearPlugin(),
+      RunnerPlugin({ handler: props.onPressEnter }),
+      NewlinePlugin(),
+      PluginPrism(),
+    ];
+
+    this.state = {
+      labelKeys: {},
+      labelValues: {},
+      metrics: props.metrics || [],
+      suggestions: [],
+      typeaheadIndex: 0,
+      typeaheadPrefix: '',
+      value: getInitialValue(props.initialQuery || ''),
+    };
+  }
+
+  componentDidMount() {
+    this.updateMenu();
+
+    if (this.props.metrics === undefined) {
+      this.fetchMetricNames();
+    }
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.resetTimer);
+  }
+
+  componentDidUpdate() {
+    this.updateMenu();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
+      this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
+    }
+    // initialQuery is null in case the user typed
+    if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
+      this.setState({ value: getInitialValue(nextProps.initialQuery) });
+    }
+  }
+
+  onChange = ({ value }) => {
+    const changed = value.document !== this.state.value.document;
+    this.setState({ value }, () => {
+      if (changed) {
+        this.handleChangeQuery();
+      }
+    });
+
+    window.requestAnimationFrame(this.handleTypeahead);
+  };
+
+  onMetricsReceived = () => {
+    if (!this.state.metrics) {
+      return;
+    }
+    configurePrismMetricsTokens(this.state.metrics);
+    // Trigger re-render
+    window.requestAnimationFrame(() => {
+      // Bogus edit to trigger highlighting
+      const change = this.state.value
+        .change()
+        .insertText(' ')
+        .deleteBackward(1);
+      this.onChange(change);
+    });
+  };
+
+  request = url => {
+    if (this.props.request) {
+      return this.props.request(url);
+    }
+    return fetch(url);
+  };
+
+  handleChangeQuery = () => {
+    // Send text change to parent
+    const { onQueryChange } = this.props;
+    if (onQueryChange) {
+      onQueryChange(Plain.serialize(this.state.value));
+    }
+  };
+
+  handleTypeahead = debounce(() => {
+    const selection = window.getSelection();
+    if (selection.anchorNode) {
+      const wrapperNode = selection.anchorNode.parentElement;
+      const editorNode = wrapperNode.closest('.query-field');
+      if (!editorNode || this.state.value.isBlurred) {
+        // Not inside this editor
+        return;
+      }
+
+      const range = selection.getRangeAt(0);
+      const text = selection.anchorNode.textContent;
+      const offset = range.startOffset;
+      const prefix = cleanText(text.substr(0, offset));
+
+      // Determine candidates by context
+      const suggestionGroups = [];
+      const wrapperClasses = wrapperNode.classList;
+      let typeaheadContext = null;
+
+      // Take first metric as lucky guess
+      const metricNode = editorNode.querySelector('.metric');
+
+      if (wrapperClasses.contains('context-range')) {
+        // Rate ranges
+        typeaheadContext = 'context-range';
+        suggestionGroups.push({
+          label: 'Range vector',
+          items: [...RATE_RANGES],
+        });
+      } else if (wrapperClasses.contains('context-labels') && metricNode) {
+        const metric = metricNode.textContent;
+        const labelKeys = this.state.labelKeys[metric];
+        if (labelKeys) {
+          if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
+            // Label values
+            const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
+            if (labelKeyNode) {
+              const labelKey = labelKeyNode.textContent;
+              const labelValues = this.state.labelValues[metric][labelKey];
+              typeaheadContext = 'context-label-values';
+              suggestionGroups.push({
+                label: 'Label values',
+                items: labelValues,
+              });
+            }
+          } else {
+            // Label keys
+            typeaheadContext = 'context-labels';
+            suggestionGroups.push({ label: 'Labels', items: labelKeys });
+          }
+        } else {
+          this.fetchMetricLabels(metric);
+        }
+      } else if (wrapperClasses.contains('context-labels') && !metricNode) {
+        // Empty name queries
+        const defaultKeys = ['job', 'instance'];
+        // Munge all keys that we have seen together
+        const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
+          return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
+        }, defaultKeys);
+        if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
+          // Label values
+          const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
+          if (labelKeyNode) {
+            const labelKey = labelKeyNode.textContent;
+            if (this.state.labelValues[EMPTY_METRIC]) {
+              const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
+              typeaheadContext = 'context-label-values';
+              suggestionGroups.push({
+                label: 'Label values',
+                items: labelValues,
+              });
+            } else {
+              // Can only query label values for now (API to query keys is under development)
+              this.fetchLabelValues(labelKey);
+            }
+          }
+        } else {
+          // Label keys
+          typeaheadContext = 'context-labels';
+          suggestionGroups.push({ label: 'Labels', items: labelKeys });
+        }
+      } else if (metricNode && wrapperClasses.contains('context-aggregation')) {
+        typeaheadContext = 'context-aggregation';
+        const metric = metricNode.textContent;
+        const labelKeys = this.state.labelKeys[metric];
+        if (labelKeys) {
+          suggestionGroups.push({ label: 'Labels', items: labelKeys });
+        } else {
+          this.fetchMetricLabels(metric);
+        }
+      } else if (
+        (this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
+        wrapperClasses.contains('context-function')
+      ) {
+        // Need prefix for metrics
+        typeaheadContext = 'context-metrics';
+        suggestionGroups.push({
+          label: 'Metrics',
+          items: this.state.metrics,
+        });
+      }
+
+      let results = 0;
+      const filteredSuggestions = suggestionGroups.map(group => {
+        if (group.items) {
+          group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
+          results += group.items.length;
+        }
+        return group;
+      });
+
+      console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
+
+      this.setState({
+        typeaheadPrefix: prefix,
+        typeaheadContext,
+        typeaheadText: text,
+        suggestions: results > 0 ? filteredSuggestions : [],
+      });
+    }
+  }, TYPEAHEAD_DEBOUNCE);
+
+  applyTypeahead(change, suggestion) {
+    const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
+
+    // Modify suggestion based on context
+    switch (typeaheadContext) {
+      case 'context-labels': {
+        const nextChar = getNextCharacter();
+        if (!nextChar || nextChar === '}' || nextChar === ',') {
+          suggestion += '=';
+        }
+        break;
+      }
+
+      case 'context-label-values': {
+        // Always add quotes and remove existing ones instead
+        if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
+          suggestion = `"${suggestion}`;
+        }
+        if (getNextCharacter() !== '"') {
+          suggestion = `${suggestion}"`;
+        }
+        break;
+      }
+
+      default:
+    }
+
+    this.resetTypeahead();
+
+    // Remove the current, incomplete text and replace it with the selected suggestion
+    let backward = typeaheadPrefix.length;
+    const text = cleanText(typeaheadText);
+    const suffixLength = text.length - typeaheadPrefix.length;
+    const offset = typeaheadText.indexOf(typeaheadPrefix);
+    const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
+    const forward = midWord ? suffixLength + offset : 0;
+
+    return (
+      change
+        // TODO this line breaks if cursor was moved left and length is longer than whole prefix
+        .deleteBackward(backward)
+        .deleteForward(forward)
+        .insertText(suggestion)
+        .focus()
+    );
+  }
+
+  onKeyDown = (event, change) => {
+    if (this.menuEl) {
+      const { typeaheadIndex, suggestions } = this.state;
+
+      switch (event.key) {
+        case 'Escape': {
+          if (this.menuEl) {
+            event.preventDefault();
+            this.resetTypeahead();
+            return true;
+          }
+          break;
+        }
+
+        case 'Tab': {
+          // Dont blur input
+          event.preventDefault();
+          if (!suggestions || suggestions.length === 0) {
+            return undefined;
+          }
+
+          // Get the currently selected suggestion
+          const flattenedSuggestions = flattenSuggestions(suggestions);
+          const selected = Math.abs(typeaheadIndex);
+          const selectedIndex = selected % flattenedSuggestions.length || 0;
+          const suggestion = flattenedSuggestions[selectedIndex];
+
+          this.applyTypeahead(change, suggestion);
+          return true;
+        }
+
+        case 'ArrowDown': {
+          // Select next suggestion
+          event.preventDefault();
+          this.setState({ typeaheadIndex: typeaheadIndex + 1 });
+          break;
+        }
+
+        case 'ArrowUp': {
+          // Select previous suggestion
+          event.preventDefault();
+          this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
+          break;
+        }
+
+        default: {
+          // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
+          break;
+        }
+      }
+    }
+    return undefined;
+  };
+
+  resetTypeahead = () => {
+    this.setState({
+      suggestions: [],
+      typeaheadIndex: 0,
+      typeaheadPrefix: '',
+      typeaheadContext: null,
+    });
+  };
+
+  async fetchLabelValues(key) {
+    const url = `/api/v1/label/${key}/values`;
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const pairs = this.state.labelValues[EMPTY_METRIC];
+      const values = {
+        ...pairs,
+        [key]: body.data,
+      };
+      // const labelKeys = {
+      //   ...this.state.labelKeys,
+      //   [EMPTY_METRIC]: keys,
+      // };
+      const labelValues = {
+        ...this.state.labelValues,
+        [EMPTY_METRIC]: values,
+      };
+      this.setState({ labelValues }, this.handleTypeahead);
+    } catch (e) {
+      if (this.props.onRequestError) {
+        this.props.onRequestError(e);
+      } else {
+        console.error(e);
+      }
+    }
+  }
+
+  async fetchMetricLabels(name) {
+    const url = `/api/v1/series?match[]=${name}`;
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const { keys, values } = processLabels(body.data);
+      const labelKeys = {
+        ...this.state.labelKeys,
+        [name]: keys,
+      };
+      const labelValues = {
+        ...this.state.labelValues,
+        [name]: values,
+      };
+      this.setState({ labelKeys, labelValues }, this.handleTypeahead);
+    } catch (e) {
+      if (this.props.onRequestError) {
+        this.props.onRequestError(e);
+      } else {
+        console.error(e);
+      }
+    }
+  }
+
+  async fetchMetricNames() {
+    const url = '/api/v1/label/__name__/values';
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      this.setState({ metrics: body.data }, this.onMetricsReceived);
+    } catch (error) {
+      if (this.props.onRequestError) {
+        this.props.onRequestError(error);
+      } else {
+        console.error(error);
+      }
+    }
+  }
+
+  handleBlur = () => {
+    const { onBlur } = this.props;
+    // If we dont wait here, menu clicks wont work because the menu
+    // will be gone.
+    this.resetTimer = setTimeout(this.resetTypeahead, 100);
+    if (onBlur) {
+      onBlur();
+    }
+  };
+
+  handleFocus = () => {
+    const { onFocus } = this.props;
+    if (onFocus) {
+      onFocus();
+    }
+  };
+
+  handleClickMenu = item => {
+    // Manually triggering change
+    const change = this.applyTypeahead(this.state.value.change(), item);
+    this.onChange(change);
+  };
+
+  updateMenu = () => {
+    const { suggestions } = this.state;
+    const menu = this.menuEl;
+    const selection = window.getSelection();
+    const node = selection.anchorNode;
+
+    // No menu, nothing to do
+    if (!menu) {
+      return;
+    }
+
+    // No suggestions or blur, remove menu
+    const hasSuggesstions = suggestions && suggestions.length > 0;
+    if (!hasSuggesstions) {
+      menu.removeAttribute('style');
+      return;
+    }
+
+    // Align menu overlay to editor node
+    if (node) {
+      const rect = node.parentElement.getBoundingClientRect();
+      menu.style.opacity = 1;
+      menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`;
+      menu.style.left = `${rect.left + window.scrollX - 2}px`;
+    }
+  };
+
+  menuRef = el => {
+    this.menuEl = el;
+  };
+
+  renderMenu = () => {
+    const { suggestions } = this.state;
+    const hasSuggesstions = suggestions && suggestions.length > 0;
+    if (!hasSuggesstions) {
+      return null;
+    }
+
+    // Guard selectedIndex to be within the length of the suggestions
+    let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
+    const flattenedSuggestions = flattenSuggestions(suggestions);
+    selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
+    const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : [];
+
+    // Create typeahead in DOM root so we can later position it absolutely
+    return (
+      <Portal>
+        <Typeahead
+          menuRef={this.menuRef}
+          selectedItems={selectedKeys}
+          onClickItem={this.handleClickMenu}
+          groupedItems={suggestions}
+        />
+      </Portal>
+    );
+  };
+
+  render() {
+    return (
+      <div className="query-field">
+        {this.renderMenu()}
+        <Editor
+          autoCorrect={false}
+          onBlur={this.handleBlur}
+          onKeyDown={this.onKeyDown}
+          onChange={this.onChange}
+          onFocus={this.handleFocus}
+          placeholder={this.props.placeholder}
+          plugins={this.plugins}
+          spellCheck={false}
+          value={this.state.value}
+        />
+      </div>
+    );
+  }
+}
+
+export default QueryField;

+ 24 - 0
public/app/containers/Explore/Table.tsx

@@ -0,0 +1,24 @@
+import React, { PureComponent } from 'react';
+// import TableModel from 'app/core/table_model';
+
+const EMPTY_TABLE = {
+  columns: [],
+  rows: [],
+};
+
+export default class Table extends PureComponent<any, any> {
+  render() {
+    const { className = '', data } = this.props;
+    const tableModel = data || EMPTY_TABLE;
+    return (
+      <table className={`${className} filter-table`}>
+        <thead>
+          <tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr>
+        </thead>
+        <tbody>
+          {tableModel.rows.map((row, i) => <tr key={i}>{row.map((content, j) => <td key={j}>{content}</td>)}</tr>)}
+        </tbody>
+      </table>
+    );
+  }
+}

+ 66 - 0
public/app/containers/Explore/Typeahead.tsx

@@ -0,0 +1,66 @@
+import React from 'react';
+
+function scrollIntoView(el) {
+  if (!el || !el.offsetParent) {
+    return;
+  }
+  const container = el.offsetParent;
+  if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
+    container.scrollTop = el.offsetTop - container.offsetTop;
+  }
+}
+
+class TypeaheadItem extends React.PureComponent<any, any> {
+  el: any;
+  componentDidUpdate(prevProps) {
+    if (this.props.isSelected && !prevProps.isSelected) {
+      scrollIntoView(this.el);
+    }
+  }
+
+  getRef = el => {
+    this.el = el;
+  };
+
+  render() {
+    const { isSelected, label, onClickItem } = this.props;
+    const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
+    const onClick = () => onClickItem(label);
+    return (
+      <li ref={this.getRef} className={className} onClick={onClick}>
+        {label}
+      </li>
+    );
+  }
+}
+
+class TypeaheadGroup extends React.PureComponent<any, any> {
+  render() {
+    const { items, label, selected, onClickItem } = this.props;
+    return (
+      <li className="typeahead-group">
+        <div className="typeahead-group__title">{label}</div>
+        <ul className="typeahead-group__list">
+          {items.map(item => (
+            <TypeaheadItem key={item} onClickItem={onClickItem} isSelected={selected.indexOf(item) > -1} label={item} />
+          ))}
+        </ul>
+      </li>
+    );
+  }
+}
+
+class Typeahead extends React.PureComponent<any, any> {
+  render() {
+    const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
+    return (
+      <ul className="typeahead" ref={menuRef}>
+        {groupedItems.map(g => (
+          <TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
+        ))}
+      </ul>
+    );
+  }
+}
+
+export default Typeahead;

+ 47 - 0
public/app/containers/Explore/slate-plugins/braces.test.ts

@@ -0,0 +1,47 @@
+import Plain from 'slate-plain-serializer';
+
+import BracesPlugin from './braces';
+
+declare global {
+  interface Window {
+    KeyboardEvent: any;
+  }
+}
+
+describe('braces', () => {
+  const handler = BracesPlugin().onKeyDown;
+
+  it('adds closing braces around empty value', () => {
+    const change = Plain.deserialize('').change();
+    const event = new window.KeyboardEvent('keydown', { key: '(' });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('()');
+  });
+
+  it('adds closing braces around a value', () => {
+    const change = Plain.deserialize('foo').change();
+    const event = new window.KeyboardEvent('keydown', { key: '(' });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('(foo)');
+  });
+
+  it('adds closing braces around the following value only', () => {
+    const change = Plain.deserialize('foo bar ugh').change();
+    let event;
+    event = new window.KeyboardEvent('keydown', { key: '(' });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('(foo) bar ugh');
+
+    // Wrap bar
+    change.move(5);
+    event = new window.KeyboardEvent('keydown', { key: '(' });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('(foo) (bar) ugh');
+
+    // Create empty parens after (bar)
+    change.move(4);
+    event = new window.KeyboardEvent('keydown', { key: '(' });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('(foo) (bar)() ugh');
+  });
+});

+ 51 - 0
public/app/containers/Explore/slate-plugins/braces.ts

@@ -0,0 +1,51 @@
+const BRACES = {
+  '[': ']',
+  '{': '}',
+  '(': ')',
+};
+
+export default function BracesPlugin() {
+  return {
+    onKeyDown(event, change) {
+      const { value } = change;
+      if (!value.isCollapsed) {
+        return undefined;
+      }
+
+      switch (event.key) {
+        case '{':
+        case '[': {
+          event.preventDefault();
+          // Insert matching braces
+          change
+            .insertText(`${event.key}${BRACES[event.key]}`)
+            .move(-1)
+            .focus();
+          return true;
+        }
+
+        case '(': {
+          event.preventDefault();
+          const text = value.anchorText.text;
+          const offset = value.anchorOffset;
+          const space = text.indexOf(' ', offset);
+          const length = space > 0 ? space : text.length;
+          const forward = length - offset;
+          // Insert matching braces
+          change
+            .insertText(event.key)
+            .move(forward)
+            .insertText(BRACES[event.key])
+            .move(-1 - forward)
+            .focus();
+          return true;
+        }
+
+        default: {
+          break;
+        }
+      }
+      return undefined;
+    },
+  };
+}

+ 38 - 0
public/app/containers/Explore/slate-plugins/clear.test.ts

@@ -0,0 +1,38 @@
+import Plain from 'slate-plain-serializer';
+
+import ClearPlugin from './clear';
+
+describe('clear', () => {
+  const handler = ClearPlugin().onKeyDown;
+
+  it('does not change the empty value', () => {
+    const change = Plain.deserialize('').change();
+    const event = new window.KeyboardEvent('keydown', {
+      key: 'k',
+      ctrlKey: true,
+    });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('');
+  });
+
+  it('clears to the end of the line', () => {
+    const change = Plain.deserialize('foo').change();
+    const event = new window.KeyboardEvent('keydown', {
+      key: 'k',
+      ctrlKey: true,
+    });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('');
+  });
+
+  it('clears from the middle to the end of the line', () => {
+    const change = Plain.deserialize('foo bar').change();
+    change.move(4);
+    const event = new window.KeyboardEvent('keydown', {
+      key: 'k',
+      ctrlKey: true,
+    });
+    handler(event, change);
+    expect(Plain.serialize(change.value)).toEqual('foo ');
+  });
+});

+ 22 - 0
public/app/containers/Explore/slate-plugins/clear.ts

@@ -0,0 +1,22 @@
+// Clears the rest of the line after the caret
+export default function ClearPlugin() {
+  return {
+    onKeyDown(event, change) {
+      const { value } = change;
+      if (!value.isCollapsed) {
+        return undefined;
+      }
+
+      if (event.key === 'k' && event.ctrlKey) {
+        event.preventDefault();
+        const text = value.anchorText.text;
+        const offset = value.anchorOffset;
+        const length = text.length;
+        const forward = length - offset;
+        change.deleteForward(forward);
+        return true;
+      }
+      return undefined;
+    },
+  };
+}

+ 35 - 0
public/app/containers/Explore/slate-plugins/newline.ts

@@ -0,0 +1,35 @@
+function getIndent(text) {
+  let offset = text.length - text.trimLeft().length;
+  if (offset) {
+    let indent = text[0];
+    while (--offset) {
+      indent += text[0];
+    }
+    return indent;
+  }
+  return '';
+}
+
+export default function NewlinePlugin() {
+  return {
+    onKeyDown(event, change) {
+      const { value } = change;
+      if (!value.isCollapsed) {
+        return undefined;
+      }
+
+      if (event.key === 'Enter' && event.shiftKey) {
+        event.preventDefault();
+
+        const { startBlock } = value;
+        const currentLineText = startBlock.text;
+        const indent = getIndent(currentLineText);
+
+        return change
+          .splitBlock()
+          .insertText(indent)
+          .focus();
+      }
+    },
+  };
+}

+ 122 - 0
public/app/containers/Explore/slate-plugins/prism/index.tsx

@@ -0,0 +1,122 @@
+import React from 'react';
+import Prism from 'prismjs';
+
+import Promql from './promql';
+
+Prism.languages.promql = Promql;
+
+const TOKEN_MARK = 'prism-token';
+
+export function configurePrismMetricsTokens(metrics) {
+  Prism.languages.promql.metric = {
+    alias: 'variable',
+    pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
+  };
+}
+
+/**
+ * Code-highlighting plugin based on Prism and
+ * https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js
+ *
+ * (Adapted to handle nested grammar definitions.)
+ */
+
+export default function PrismPlugin() {
+  return {
+    /**
+     * Render a Slate mark with appropiate CSS class names
+     *
+     * @param {Object} props
+     * @return {Element}
+     */
+
+    renderMark(props) {
+      const { children, mark } = props;
+      // Only apply spans to marks identified by this plugin
+      if (mark.type !== TOKEN_MARK) {
+        return undefined;
+      }
+      const className = `token ${mark.data.get('types')}`;
+      return <span className={className}>{children}</span>;
+    },
+
+    /**
+     * Decorate code blocks with Prism.js highlighting.
+     *
+     * @param {Node} node
+     * @return {Array}
+     */
+
+    decorateNode(node) {
+      if (node.type !== 'paragraph') {
+        return [];
+      }
+
+      const texts = node.getTexts().toArray();
+      const tstring = texts.map(t => t.text).join('\n');
+      const grammar = Prism.languages.promql;
+      const tokens = Prism.tokenize(tstring, grammar);
+      const decorations = [];
+      let startText = texts.shift();
+      let endText = startText;
+      let startOffset = 0;
+      let endOffset = 0;
+      let start = 0;
+
+      function processToken(token, acc?) {
+        // Accumulate token types down the tree
+        const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`;
+
+        // Add mark for token node
+        if (typeof token === 'string' || typeof token.content === 'string') {
+          startText = endText;
+          startOffset = endOffset;
+
+          const content = typeof token === 'string' ? token : token.content;
+          const newlines = content.split('\n').length - 1;
+          const length = content.length - newlines;
+          const end = start + length;
+
+          let available = startText.text.length - startOffset;
+          let remaining = length;
+
+          endOffset = startOffset + remaining;
+
+          while (available < remaining) {
+            endText = texts.shift();
+            remaining = length - available;
+            available = endText.text.length;
+            endOffset = remaining;
+          }
+
+          // Inject marks from up the tree (acc) as well
+          if (typeof token !== 'string' || acc) {
+            const range = {
+              anchorKey: startText.key,
+              anchorOffset: startOffset,
+              focusKey: endText.key,
+              focusOffset: endOffset,
+              marks: [{ type: TOKEN_MARK, data: { types } }],
+            };
+
+            decorations.push(range);
+          }
+
+          start = end;
+        } else if (token.content && token.content.length) {
+          // Tokens can be nested
+          for (const subToken of token.content) {
+            processToken(subToken, types);
+          }
+        }
+      }
+
+      // Process top-level tokens
+      for (const token of tokens) {
+        processToken(token);
+      }
+
+      return decorations;
+    },
+  };
+}

+ 123 - 0
public/app/containers/Explore/slate-plugins/prism/promql.ts

@@ -0,0 +1,123 @@
+export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
+
+const AGGREGATION_OPERATORS = [
+  'sum',
+  'min',
+  'max',
+  'avg',
+  'stddev',
+  'stdvar',
+  'count',
+  'count_values',
+  'bottomk',
+  'topk',
+  'quantile',
+];
+
+export const FUNCTIONS = [
+  ...AGGREGATION_OPERATORS,
+  'abs',
+  'absent',
+  'ceil',
+  'changes',
+  'clamp_max',
+  'clamp_min',
+  'count_scalar',
+  'day_of_month',
+  'day_of_week',
+  'days_in_month',
+  'delta',
+  'deriv',
+  'drop_common_labels',
+  'exp',
+  'floor',
+  'histogram_quantile',
+  'holt_winters',
+  'hour',
+  'idelta',
+  'increase',
+  'irate',
+  'label_replace',
+  'ln',
+  'log2',
+  'log10',
+  'minute',
+  'month',
+  'predict_linear',
+  'rate',
+  'resets',
+  'round',
+  'scalar',
+  'sort',
+  'sort_desc',
+  'sqrt',
+  'time',
+  'vector',
+  'year',
+  'avg_over_time',
+  'min_over_time',
+  'max_over_time',
+  'sum_over_time',
+  'count_over_time',
+  'quantile_over_time',
+  'stddev_over_time',
+  'stdvar_over_time',
+];
+
+const tokenizer = {
+  comment: {
+    pattern: /(^|[^\n])#.*/,
+    lookbehind: true,
+  },
+  'context-aggregation': {
+    pattern: /((by|without)\s*)\([^)]*\)/, // by ()
+    lookbehind: true,
+    inside: {
+      'label-key': {
+        pattern: /[^,\s][^,]*[^,\s]*/,
+        alias: 'attr-name',
+      },
+    },
+  },
+  'context-labels': {
+    pattern: /\{[^}]*(?=})/,
+    inside: {
+      'label-key': {
+        pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/,
+        alias: 'attr-name',
+      },
+      'label-value': {
+        pattern: /"(?:\\.|[^\\"])*"/,
+        greedy: true,
+        alias: 'attr-value',
+      },
+    },
+  },
+  function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'),
+  'context-range': [
+    {
+      pattern: /\[[^\]]*(?=])/, // [1m]
+      inside: {
+        'range-duration': {
+          pattern: /\b\d+[smhdwy]\b/i,
+          alias: 'number',
+        },
+      },
+    },
+    {
+      pattern: /(offset\s+)\w+/, // offset 1m
+      lookbehind: true,
+      inside: {
+        'range-duration': {
+          pattern: /\b\d+[smhdwy]\b/i,
+          alias: 'number',
+        },
+      },
+    },
+  ],
+  number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
+  operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'),
+  punctuation: /[{};()`,.]/,
+};
+
+export default tokenizer;

+ 14 - 0
public/app/containers/Explore/slate-plugins/runner.ts

@@ -0,0 +1,14 @@
+export default function RunnerPlugin({ handler }) {
+  return {
+    onKeyDown(event) {
+      // Handle enter
+      if (handler && event.key === 'Enter' && !event.shiftKey) {
+        // Submit on Enter
+        event.preventDefault();
+        handler(event);
+        return true;
+      }
+      return undefined;
+    },
+  };
+}

+ 14 - 0
public/app/containers/Explore/utils/debounce.ts

@@ -0,0 +1,14 @@
+// Based on underscore.js debounce()
+export default function debounce(func, wait) {
+  let timeout;
+  return function() {
+    const context = this;
+    const args = arguments;
+    const later = function() {
+      timeout = null;
+      func.apply(context, args);
+    };
+    clearTimeout(timeout);
+    timeout = setTimeout(later, wait);
+  };
+}

+ 40 - 0
public/app/containers/Explore/utils/dom.ts

@@ -0,0 +1,40 @@
+// Node.closest() polyfill
+if ('Element' in window && !Element.prototype.closest) {
+  Element.prototype.closest = function(s) {
+    const matches = (this.document || this.ownerDocument).querySelectorAll(s);
+    let el = this;
+    let i;
+    // eslint-disable-next-line
+    do {
+      i = matches.length;
+      // eslint-disable-next-line
+      while (--i >= 0 && matches.item(i) !== el) {}
+    } while (i < 0 && (el = el.parentElement));
+    return el;
+  };
+}
+
+export function getPreviousCousin(node, selector) {
+  let sibling = node.parentElement.previousSibling;
+  let el;
+  while (sibling) {
+    el = sibling.querySelector(selector);
+    if (el) {
+      return el;
+    }
+    sibling = sibling.previousSibling;
+  }
+  return undefined;
+}
+
+export function getNextCharacter(global = window) {
+  const selection = global.getSelection();
+  if (!selection.anchorNode) {
+    return null;
+  }
+
+  const range = selection.getRangeAt(0);
+  const text = selection.anchorNode.textContent;
+  const offset = range.startOffset;
+  return text.substr(offset, 1);
+}

+ 20 - 0
public/app/containers/Explore/utils/prometheus.ts

@@ -0,0 +1,20 @@
+export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h'];
+
+export function processLabels(labels) {
+  const values = {};
+  labels.forEach(l => {
+    const { __name__, ...rest } = l;
+    Object.keys(rest).forEach(key => {
+      if (!values[key]) {
+        values[key] = [];
+      }
+      if (values[key].indexOf(rest[key]) === -1) {
+        values[key].push(rest[key]);
+      }
+    });
+  });
+  return { values, keys: Object.keys(values) };
+}
+
+// Strip syntax chars
+export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();

+ 14 - 2
public/app/core/components/grafana_app.ts

@@ -8,11 +8,23 @@ import appEvents from 'app/core/app_events';
 import Drop from 'tether-drop';
 import { createStore } from 'app/stores/store';
 import colors from 'app/core/utils/colors';
+import { BackendSrv } from 'app/core/services/backend_srv';
+import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 
 export class GrafanaCtrl {
   /** @ngInject */
-  constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, bridgeSrv, backendSrv) {
-    createStore(backendSrv);
+  constructor(
+    $scope,
+    alertSrv,
+    utilSrv,
+    $rootScope,
+    $controller,
+    contextSrv,
+    bridgeSrv,
+    backendSrv: BackendSrv,
+    datasourceSrv: DatasourceSrv
+  ) {
+    createStore({ backendSrv, datasourceSrv });
 
     $scope.init = function() {
       $scope.contextSrv = contextSrv;

+ 1 - 1
public/app/features/plugins/datasource_srv.ts

@@ -15,7 +15,7 @@ export class DatasourceSrv {
     this.datasources = {};
   }
 
-  get(name) {
+  get(name?) {
     if (!name) {
       return this.get(config.defaultDatasource);
     }

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

@@ -1,8 +1,11 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { Provider } from 'mobx-react';
+
 import coreModule from 'app/core/core_module';
 import { store } from 'app/stores/store';
-import { Provider } from 'mobx-react';
+import { BackendSrv } from 'app/core/services/backend_srv';
+import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 
 function WrapInProvider(store, Component, props) {
   return (
@@ -13,14 +16,15 @@ function WrapInProvider(store, Component, props) {
 }
 
 /** @ngInject */
-export function reactContainer($route, $location, backendSrv) {
+export function reactContainer($route, $location, backendSrv: BackendSrv, datasourceSrv: DatasourceSrv) {
   return {
     restrict: 'E',
     template: '',
     link(scope, elem) {
-      let component = $route.current.locals.component;
+      let component = $route.current.locals.component.default;
       let props = {
         backendSrv: backendSrv,
+        datasourceSrv: datasourceSrv,
       };
 
       ReactDOM.render(WrapInProvider(store, component, props), elem[0]);

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

@@ -1,7 +1,9 @@
 import './dashboard_loaders';
 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';
 
@@ -109,6 +111,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controller: 'FolderDashboardsCtrl',
       controllerAs: 'ctrl',
     })
+    .when('/explore', {
+      template: '<react-container />',
+      resolve: {
+        component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
+      },
+    })
     .when('/org', {
       templateUrl: 'public/app/features/org/partials/orgDetails.html',
       controller: 'OrgDetailsCtrl',

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

@@ -3,11 +3,11 @@ import config from 'app/core/config';
 
 export let store: IRootStore;
 
-export function createStore(backendSrv) {
+export function createStore(services) {
   store = RootStore.create(
     {},
     {
-      backendSrv: backendSrv,
+      ...services,
       navTree: config.bootData.navTree,
     }
   );

+ 1 - 0
public/sass/_grafana.scss

@@ -104,5 +104,6 @@
 @import 'pages/signup';
 @import 'pages/styleguide';
 @import 'pages/errorpage';
+@import 'pages/explore';
 @import 'old_responsive';
 @import 'components/view_states.scss';

+ 7 - 0
public/sass/layout/_page.scss

@@ -23,6 +23,13 @@
   @include clearfix();
 }
 
+.page-full {
+  margin-left: $page-sidebar-margin;
+  padding-left: $spacer;
+  padding-right: $spacer;
+  @include clearfix();
+}
+
 .scroll-canvas {
   position: absolute;
   width: 100%;

+ 304 - 0
public/sass/pages/_explore.scss

@@ -0,0 +1,304 @@
+.explore {
+  .graph-legend {
+    flex-wrap: wrap;
+  }
+}
+
+.query-field {
+  font-size: 14px;
+  font-family: Consolas, Menlo, Courier, monospace;
+  height: auto;
+}
+
+.query-field-wrapper {
+  position: relative;
+  display: inline-block;
+  padding: 6px 7px 4px;
+  width: calc(100% - 6rem);
+  cursor: text;
+  line-height: 1.5;
+  color: rgba(0, 0, 0, 0.65);
+  background-color: #fff;
+  background-image: none;
+  border: 1px solid lightgray;
+  border-radius: 4px;
+  transition: all 0.3s;
+}
+
+.typeahead {
+  position: absolute;
+  z-index: auto;
+  top: -10000px;
+  left: -10000px;
+  opacity: 0;
+  border-radius: 4px;
+  transition: opacity 0.75s;
+  border: 1px solid #e4e4e4;
+  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);
+  transition: opacity 0.4s ease-out;
+}
+
+.typeahead-group__title {
+  color: rgba(0, 0, 0, 0.43);
+  font-size: 12px;
+  line-height: 1.5;
+  padding: 8px 16px;
+}
+
+.typeahead-item {
+  line-height: 200%;
+  height: auto;
+  font-family: Consolas, Menlo, Courier, monospace;
+  padding: 0 16px 0 28px;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  margin-left: -1px;
+  left: 1px;
+  position: relative;
+  z-index: 1;
+  display: block;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
+    background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+.typeahead-item__selected {
+  background-color: #ecf6fd;
+  color: #108ee9;
+}
+
+/* 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);
+}
+
+.token.important {
+  font-weight: normal;
+}
+
+.token.bold {
+  font-weight: bold;
+}
+.token.italic {
+  font-style: italic;
+}
+
+.token.entity {
+  cursor: help;
+}
+
+.namespace {
+  opacity: 0.7;
+}
+
+@media screen and (max-width: 767px) {
+  pre[class*='language-']:before,
+  pre[class*='language-']:after {
+    bottom: 14px;
+    box-shadow: none;
+  }
+}
+
+/* Plugin styles */
+.token.tab:not(:empty):before,
+.token.cr:before,
+.token.lf:before {
+  color: #e0d7d1;
+}
+
+/* Plugin styles: Line Numbers */
+pre[class*='language-'].line-numbers {
+  padding-left: 0;
+}
+
+pre[class*='language-'].line-numbers code {
+  padding-left: 3.8em;
+}
+
+pre[class*='language-'].line-numbers .line-numbers-rows {
+  left: 0;
+}
+
+/* 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;
+}

+ 1 - 0
scripts/webpack/webpack.dev.js

@@ -71,6 +71,7 @@ module.exports = merge(common, {
             loader: 'babel-loader',
             options: {
               plugins: [
+                'syntax-dynamic-import',
                 'react-hot-loader/babel',
               ],
             },

+ 6 - 1
scripts/webpack/webpack.prod.js

@@ -36,7 +36,12 @@ module.exports = merge(common, {
         test: /\.tsx?$/,
         exclude: /node_modules/,
         use: [
-          { loader: "awesome-typescript-loader" }
+          {
+            loader: 'awesome-typescript-loader',
+            options: {
+              errorsAsWarnings: false,
+            },
+          },
         ]
       },
       require('./sass.rule.js')({

文件差异内容过多而无法显示
+ 233 - 250
yarn.lock


部分文件因为文件数量过多而无法显示