Pārlūkot izejas kodu

Merge pull request #12821 from grafana/davkal/explore-query-ux

Explore: prometheus query helpers
David 7 gadi atpakaļ
vecāks
revīzija
91d04b87ad

+ 2 - 1
package.json

@@ -166,6 +166,7 @@
     "mousetrap-global-bind": "^1.1.0",
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.0",
+    "rc-cascader": "^0.14.0",
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
     "react-grid-layout": "0.16.6",
@@ -187,4 +188,4 @@
   "resolutions": {
     "caniuse-db": "1.0.30000772"
   }
-}
+}

+ 68 - 52
public/app/containers/Explore/Explore.tsx

@@ -166,7 +166,7 @@ export class Explore extends React.Component<any, IExploreState> {
         supportsTable,
         datasourceLoading: false,
       },
-      () => datasourceError === null && this.handleSubmit()
+      () => datasourceError === null && this.onSubmit()
     );
   }
 
@@ -174,7 +174,7 @@ export class Explore extends React.Component<any, IExploreState> {
     this.el = el;
   };
 
-  handleAddQueryRow = index => {
+  onAddQueryRow = index => {
     const { queries } = this.state;
     const nextQueries = [
       ...queries.slice(0, index + 1),
@@ -184,7 +184,7 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState({ queries: nextQueries });
   };
 
-  handleChangeDatasource = async option => {
+  onChangeDatasource = async option => {
     this.setState({
       datasource: null,
       datasourceError: null,
@@ -197,10 +197,10 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setDatasource(datasource);
   };
 
-  handleChangeQuery = (value, index) => {
+  onChangeQuery = (value: string, index: number, override?: boolean) => {
     const { queries } = this.state;
     const prevQuery = queries[index];
-    const edited = prevQuery.query !== value;
+    const edited = override ? false : prevQuery.query !== value;
     const nextQuery = {
       ...queries[index],
       edited,
@@ -208,53 +208,74 @@ export class Explore extends React.Component<any, IExploreState> {
     };
     const nextQueries = [...queries];
     nextQueries[index] = nextQuery;
-    this.setState({ queries: nextQueries });
+    this.setState({ queries: nextQueries }, override ? () => this.onSubmit() : undefined);
   };
 
-  handleChangeTime = nextRange => {
+  onChangeTime = nextRange => {
     const range = {
       from: nextRange.from,
       to: nextRange.to,
     };
-    this.setState({ range }, () => this.handleSubmit());
+    this.setState({ range }, () => this.onSubmit());
+  };
+
+  onClickClear = () => {
+    this.setState({
+      graphResult: null,
+      logsResult: null,
+      queries: ensureQueries(),
+      tableResult: null,
+    });
   };
 
-  handleClickCloseSplit = () => {
+  onClickCloseSplit = () => {
     const { onChangeSplit } = this.props;
     if (onChangeSplit) {
       onChangeSplit(false);
     }
   };
 
-  handleClickGraphButton = () => {
+  onClickGraphButton = () => {
     this.setState(state => ({ showingGraph: !state.showingGraph }));
   };
 
-  handleClickLogsButton = () => {
+  onClickLogsButton = () => {
     this.setState(state => ({ showingLogs: !state.showingLogs }));
   };
 
-  handleClickSplit = () => {
+  onClickSplit = () => {
     const { onChangeSplit } = this.props;
     if (onChangeSplit) {
       onChangeSplit(true, this.state);
     }
   };
 
-  handleClickTableButton = () => {
+  onClickTableButton = () => {
     this.setState(state => ({ showingTable: !state.showingTable }));
   };
 
-  handleRemoveQueryRow = index => {
+  onClickTableCell = (columnKey: string, rowValue: string) => {
+    const { datasource, queries } = this.state;
+    if (datasource && datasource.modifyQuery) {
+      const nextQueries = queries.map(q => ({
+        ...q,
+        edited: false,
+        query: datasource.modifyQuery(q.query, { addFilter: { key: columnKey, value: rowValue } }),
+      }));
+      this.setState({ queries: nextQueries }, () => this.onSubmit());
+    }
+  };
+
+  onRemoveQueryRow = index => {
     const { queries } = this.state;
     if (queries.length <= 1) {
       return;
     }
     const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
-    this.setState({ queries: nextQueries }, () => this.handleSubmit());
+    this.setState({ queries: nextQueries }, () => this.onSubmit());
   };
 
-  handleSubmit = () => {
+  onSubmit = () => {
     const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
     if (showingTable && supportsTable) {
       this.runTableQuery();
@@ -267,18 +288,6 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   };
 
-  onClickTableCell = (columnKey: string, rowValue: string) => {
-    const { datasource, queries } = this.state;
-    if (datasource && datasource.modifyQuery) {
-      const nextQueries = queries.map(q => ({
-        ...q,
-        edited: false,
-        query: datasource.modifyQuery(q.query, { addFilter: { key: columnKey, value: rowValue } }),
-      }));
-      this.setState({ queries: nextQueries }, () => this.handleSubmit());
-    }
-  };
-
   onQuerySuccess(datasourceId: string, queries: any[]): void {
     // save queries to history
     let { datasource, history } = this.state;
@@ -441,7 +450,7 @@ export class Explore extends React.Component<any, IExploreState> {
             </div>
           ) : (
               <div className="navbar-buttons explore-first-button">
-                <button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
+                <button className="btn navbar-button" onClick={this.onClickCloseSplit}>
                   Close Split
               </button>
               </div>
@@ -451,7 +460,7 @@ export class Explore extends React.Component<any, IExploreState> {
               <Select
                 className="datasource-picker"
                 clearable={false}
-                onChange={this.handleChangeDatasource}
+                onChange={this.onChangeDatasource}
                 options={datasources}
                 placeholder="Loading datasources..."
                 value={selectedDatasource}
@@ -461,31 +470,19 @@ export class Explore extends React.Component<any, IExploreState> {
           <div className="navbar__spacer" />
           {position === 'left' && !split ? (
             <div className="navbar-buttons">
-              <button className="btn navbar-button" onClick={this.handleClickSplit}>
+              <button className="btn navbar-button" onClick={this.onClickSplit}>
                 Split
               </button>
             </div>
           ) : null}
+          <TimePicker range={range} onChangeTime={this.onChangeTime} />
           <div className="navbar-buttons">
-            {supportsGraph ? (
-              <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
-                Graph
-              </button>
-            ) : null}
-            {supportsTable ? (
-              <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
-                Table
-              </button>
-            ) : null}
-            {supportsLogs ? (
-              <button className={`btn navbar-button ${logsButtonActive}`} onClick={this.handleClickLogsButton}>
-                Logs
-              </button>
-            ) : null}
+            <button className="btn navbar-button navbar-button--no-icon" onClick={this.onClickClear}>
+              Clear All
+            </button>
           </div>
-          <TimePicker range={range} onChangeTime={this.handleChangeTime} />
           <div className="navbar-buttons relative">
-            <button className="btn navbar-button--primary" onClick={this.handleSubmit}>
+            <button className="btn navbar-button--primary" onClick={this.onSubmit}>
               Run Query <i className="fa fa-level-down run-icon" />
             </button>
             {loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
@@ -508,12 +505,31 @@ export class Explore extends React.Component<any, IExploreState> {
               history={history}
               queries={queries}
               request={this.request}
-              onAddQueryRow={this.handleAddQueryRow}
-              onChangeQuery={this.handleChangeQuery}
-              onExecuteQuery={this.handleSubmit}
-              onRemoveQueryRow={this.handleRemoveQueryRow}
+              onAddQueryRow={this.onAddQueryRow}
+              onChangeQuery={this.onChangeQuery}
+              onExecuteQuery={this.onSubmit}
+              onRemoveQueryRow={this.onRemoveQueryRow}
             />
             {queryError && !loading ? <div className="text-warning m-a-2">{queryError}</div> : null}
+
+            <div className="result-options">
+              {supportsGraph ? (
+                <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.onClickGraphButton}>
+                  Graph
+                </button>
+              ) : null}
+              {supportsTable ? (
+                <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.onClickTableButton}>
+                  Table
+                </button>
+              ) : null}
+              {supportsLogs ? (
+                <button className={`btn navbar-button ${logsButtonActive}`} onClick={this.onClickLogsButton}>
+                  Logs
+                </button>
+              ) : null}
+            </div>
+
             <main className="m-t-2">
               {supportsGraph && showingGraph ? (
                 <Graph

+ 2 - 1
public/app/containers/Explore/Graph.tsx

@@ -84,7 +84,9 @@ class Graph extends Component<any, any> {
 
   draw() {
     const { data, options: userOptions } = this.props;
+    const $el = $(`#${this.props.id}`);
     if (!data) {
+      $el.empty();
       return;
     }
     const series = data.map((ts: TimeSeries) => ({
@@ -93,7 +95,6 @@ class Graph extends Component<any, any> {
       data: ts.getFlotPairs('null'),
     }));
 
-    const $el = $(`#${this.props.id}`);
     const ticks = $el.width() / 100;
     let { from, to } = userOptions.range;
     if (!moment.isMoment(from)) {

+ 100 - 18
public/app/containers/Explore/PromQueryField.tsx

@@ -2,6 +2,7 @@ import _ from 'lodash';
 import moment from 'moment';
 import React from 'react';
 import { Value } from 'slate';
+import Cascader from 'rc-cascader';
 
 // dom also includes Element polyfills
 import { getNextCharacter, getPreviousCousin } from './utils/dom';
@@ -21,12 +22,14 @@ import TypeaheadField, {
 
 const DEFAULT_KEYS = ['job', 'instance'];
 const EMPTY_SELECTOR = '{}';
+const HISTOGRAM_GROUP = '__histograms__';
+const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
 const HISTORY_ITEM_COUNT = 5;
 const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
 const METRIC_MARK = 'metric';
 const PRISM_LANGUAGE = 'promql';
 
-export const wrapLabel = label => ({ label });
+export const wrapLabel = (label: string) => ({ label });
 export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
   suggestion.move = -1;
   return suggestion;
@@ -48,6 +51,22 @@ export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion
   };
 }
 
+export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
+  return _.chain(metrics)
+    .groupBy(metric => metric.split(delimiter)[0])
+    .map((metricsForPrefix: string[], prefix: string): CascaderOption => {
+      const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
+      const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
+      return {
+        children,
+        label: prefix,
+        value: prefix,
+      };
+    })
+    .sortBy('label')
+    .value();
+}
+
 export function willApplySuggestion(
   suggestion: string,
   { typeaheadContext, typeaheadText }: TypeaheadFieldState
@@ -78,22 +97,33 @@ export function willApplySuggestion(
   return suggestion;
 }
 
+interface CascaderOption {
+  label: string;
+  value: string;
+  children?: CascaderOption[];
+  disabled?: boolean;
+}
+
 interface PromQueryFieldProps {
   history?: any[];
+  histogramMetrics?: string[];
   initialQuery?: string | null;
   labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
   labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   metrics?: string[];
+  metricsByPrefix?: CascaderOption[];
   onPressEnter?: () => void;
-  onQueryChange?: (value: string) => void;
+  onQueryChange?: (value: string, override?: boolean) => void;
   portalPrefix?: string;
   request?: (url: string) => any;
 }
 
 interface PromQueryFieldState {
+  histogramMetrics: string[];
   labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
   labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   metrics: string[];
+  metricsByPrefix: CascaderOption[];
 }
 
 interface PromTypeaheadInput {
@@ -107,7 +137,7 @@ interface PromTypeaheadInput {
 class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
   plugins: any[];
 
-  constructor(props, context) {
+  constructor(props: PromQueryFieldProps, context) {
     super(props, context);
 
     this.plugins = [
@@ -117,21 +147,45 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     ];
 
     this.state = {
+      histogramMetrics: props.histogramMetrics || [],
       labelKeys: props.labelKeys || {},
       labelValues: props.labelValues || {},
       metrics: props.metrics || [],
+      metricsByPrefix: props.metricsByPrefix || [],
     };
   }
 
   componentDidMount() {
     this.fetchMetricNames();
+    this.fetchHistogramMetrics();
   }
 
-  onChangeQuery = value => {
+  onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
+    let query;
+    if (selectedOptions.length === 1) {
+      if (selectedOptions[0].children.length === 0) {
+        query = selectedOptions[0].value;
+      } else {
+        // Ignore click on group
+        return;
+      }
+    } else {
+      const prefix = selectedOptions[0].value;
+      const metric = selectedOptions[1].value;
+      if (prefix === HISTOGRAM_GROUP) {
+        query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
+      } else {
+        query = metric;
+      }
+    }
+    this.onChangeQuery(query, true);
+  };
+
+  onChangeQuery = (value: string, override?: boolean) => {
     // Send text change to parent
     const { onQueryChange } = this.props;
     if (onQueryChange) {
-      onQueryChange(value);
+      onQueryChange(value, override);
     }
   };
 
@@ -317,7 +371,17 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     return fetch(url);
   };
 
-  async fetchLabelValues(key) {
+  fetchHistogramMetrics() {
+    this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
+      const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
+      if (histogramSeries && histogramSeries['__name__']) {
+        const histogramMetrics = histogramSeries['__name__'].slice().sort();
+        this.setState({ histogramMetrics });
+      }
+    });
+  }
+
+  async fetchLabelValues(key: string) {
     const url = `/api/v1/label/${key}/values`;
     try {
       const res = await this.request(url);
@@ -337,7 +401,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     }
   }
 
-  async fetchSeriesLabels(name, withName?) {
+  async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
     const url = `/api/v1/series?match[]=${name}`;
     try {
       const res = await this.request(url);
@@ -351,7 +415,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
         ...this.state.labelValues,
         [name]: values,
       };
-      this.setState({ labelKeys, labelValues });
+      this.setState({ labelKeys, labelValues }, callback);
     } catch (e) {
       console.error(e);
     }
@@ -362,23 +426,41 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
     try {
       const res = await this.request(url);
       const body = await (res.data || res.json());
-      this.setState({ metrics: body.data }, this.onReceiveMetrics);
+      const metrics = body.data;
+      const metricsByPrefix = groupMetricsByPrefix(metrics);
+      this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
     } catch (error) {
       console.error(error);
     }
   }
 
   render() {
+    const { histogramMetrics, metricsByPrefix } = this.state;
+    const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
+    const metricsOptions = [
+      { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
+      ...metricsByPrefix,
+    ];
+
     return (
-      <TypeaheadField
-        additionalPlugins={this.plugins}
-        cleanText={cleanText}
-        initialValue={this.props.initialQuery}
-        onTypeahead={this.onTypeahead}
-        onWillApplySuggestion={willApplySuggestion}
-        onValueChanged={this.onChangeQuery}
-        placeholder="Enter a PromQL query"
-      />
+      <div className="prom-query-field">
+        <div className="prom-query-field-tools">
+          <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
+            <button className="btn navbar-button navbar-button--tight">Metrics</button>
+          </Cascader>
+        </div>
+        <div className="slate-query-field-wrapper">
+          <TypeaheadField
+            additionalPlugins={this.plugins}
+            cleanText={cleanText}
+            initialValue={this.props.initialQuery}
+            onTypeahead={this.onTypeahead}
+            onWillApplySuggestion={willApplySuggestion}
+            onValueChanged={this.onChangeQuery}
+            placeholder="Enter a PromQL query"
+          />
+        </div>
+      </div>
     );
   }
 }

+ 23 - 16
public/app/containers/Explore/QueryRows.tsx

@@ -3,28 +3,32 @@ import React, { PureComponent } from 'react';
 import QueryField from './PromQueryField';
 
 class QueryRow extends PureComponent<any, {}> {
-  handleChangeQuery = value => {
+  onChangeQuery = (value, override?: boolean) => {
     const { index, onChangeQuery } = this.props;
     if (onChangeQuery) {
-      onChangeQuery(value, index);
+      onChangeQuery(value, index, override);
     }
   };
 
-  handleClickAddButton = () => {
+  onClickAddButton = () => {
     const { index, onAddQueryRow } = this.props;
     if (onAddQueryRow) {
       onAddQueryRow(index);
     }
   };
 
-  handleClickRemoveButton = () => {
+  onClickClearButton = () => {
+    this.onChangeQuery('', true);
+  };
+
+  onClickRemoveButton = () => {
     const { index, onRemoveQueryRow } = this.props;
     if (onRemoveQueryRow) {
       onRemoveQueryRow(index);
     }
   };
 
-  handlePressEnter = () => {
+  onPressEnter = () => {
     const { onExecuteQuery } = this.props;
     if (onExecuteQuery) {
       onExecuteQuery();
@@ -35,24 +39,27 @@ class QueryRow extends PureComponent<any, {}> {
     const { edited, history, query, request } = this.props;
     return (
       <div className="query-row">
-        <div className="query-row-tools">
-          <button className="btn navbar-button navbar-button--tight" onClick={this.handleClickAddButton}>
-            <i className="fa fa-plus" />
-          </button>
-          <button className="btn navbar-button navbar-button--tight" onClick={this.handleClickRemoveButton}>
-            <i className="fa fa-minus" />
-          </button>
-        </div>
-        <div className="slate-query-field-wrapper">
+        <div className="query-row-field">
           <QueryField
             initialQuery={edited ? null : query}
             history={history}
             portalPrefix="explore"
-            onPressEnter={this.handlePressEnter}
-            onQueryChange={this.handleChangeQuery}
+            onPressEnter={this.onPressEnter}
+            onQueryChange={this.onChangeQuery}
             request={request}
           />
         </div>
+        <div className="query-row-tools">
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
+            <i className="fa fa-times" />
+          </button>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
+            <i className="fa fa-plus" />
+          </button>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
+            <i className="fa fa-minus" />
+          </button>
+        </div>
       </div>
     );
   }

+ 8 - 1
public/app/containers/Explore/Table.tsx

@@ -66,7 +66,14 @@ export default class Table extends PureComponent<TableProps, {}> {
           {tableModel.rows.map((row, i) => (
             <tr key={i}>
               {row.map((value, j) => (
-                <Cell key={j} columnIndex={j} rowIndex={i} value={value} table={data} onClickCell={onClickCell} />
+                <Cell
+                  key={j}
+                  columnIndex={j}
+                  rowIndex={i}
+                  value={String(value)}
+                  table={data}
+                  onClickCell={onClickCell}
+                />
               ))}
             </tr>
           ))}

+ 1 - 0
public/sass/_grafana.scss

@@ -1,6 +1,7 @@
 // vendor
 @import '../vendor/css/timepicker.css';
 @import '../vendor/css/spectrum.css';
+@import '../vendor/css/rc-cascader.scss';
 
 // MIXINS
 @import 'mixins/mixins';

+ 22 - 1
public/sass/pages/_explore.scss

@@ -47,6 +47,14 @@
     background-color: $btn-active-bg;
   }
 
+  .navbar-button--no-icon {
+    line-height: 18px;
+  }
+
+  .result-options {
+    margin-top: 2 * $panel-margin;
+  }
+
   .elapsed-time {
     position: absolute;
     left: 0;
@@ -99,7 +107,12 @@
 }
 
 .query-row-tools {
-  width: 4rem;
+  width: 6rem;
+}
+
+.query-row-field {
+  margin-right: 3px;
+  width: 100%;
 }
 
 .explore {
@@ -138,3 +151,11 @@
     }
   }
 }
+
+// Prometheus-specifics, to be extracted to datasource soon
+
+.explore {
+  .prom-query-field {
+    display: flex;
+  }
+}

+ 160 - 0
public/vendor/css/rc-cascader.scss

@@ -0,0 +1,160 @@
+.rc-cascader {
+  font-size: 12px;
+}
+.rc-cascader-menus {
+  font-size: 12px;
+  overflow: hidden;
+  background: $panel-bg;
+  position: absolute;
+  border: $panel-border;
+  border-radius: $border-radius;
+  box-shadow: $typeahead-shadow;
+  white-space: nowrap;
+}
+.rc-cascader-menus-hidden {
+  display: none;
+}
+.rc-cascader-menus.slide-up-enter,
+.rc-cascader-menus.slide-up-appear {
+  animation-duration: .3s;
+  animation-fill-mode: both;
+  transform-origin: 0 0;
+  opacity: 0;
+  animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
+  animation-play-state: paused;
+}
+.rc-cascader-menus.slide-up-leave {
+  animation-duration: .3s;
+  animation-fill-mode: both;
+  transform-origin: 0 0;
+  opacity: 1;
+  animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34);
+  animation-play-state: paused;
+}
+.rc-cascader-menus.slide-up-enter.slide-up-enter-active.rc-cascader-menus-placement-bottomLeft,
+.rc-cascader-menus.slide-up-appear.slide-up-appear-active.rc-cascader-menus-placement-bottomLeft {
+  animation-name: SlideUpIn;
+  animation-play-state: running;
+}
+.rc-cascader-menus.slide-up-enter.slide-up-enter-active.rc-cascader-menus-placement-topLeft,
+.rc-cascader-menus.slide-up-appear.slide-up-appear-active.rc-cascader-menus-placement-topLeft {
+  animation-name: SlideDownIn;
+  animation-play-state: running;
+}
+.rc-cascader-menus.slide-up-leave.slide-up-leave-active.rc-cascader-menus-placement-bottomLeft {
+  animation-name: SlideUpOut;
+  animation-play-state: running;
+}
+.rc-cascader-menus.slide-up-leave.slide-up-leave-active.rc-cascader-menus-placement-topLeft {
+  animation-name: SlideDownOut;
+  animation-play-state: running;
+}
+.rc-cascader-menu {
+  display: inline-block;
+  /* width: 100px; */
+  max-width: 50vw;
+  height: 192px;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  border-right: $panel-border;
+  overflow: auto;
+}
+.rc-cascader-menu:last-child {
+  border-right: 0;
+}
+.rc-cascader-menu-item {
+  height: 32px;
+  line-height: 32px;
+  padding: 0 16px;
+  cursor: pointer;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  transition: all 0.3s ease;
+  position: relative;
+}
+.rc-cascader-menu-item:hover {
+  background: $typeahead-selected-bg;
+}
+.rc-cascader-menu-item-disabled {
+  cursor: not-allowed;
+  color: $text-color-weak;
+}
+.rc-cascader-menu-item-disabled:hover {
+  background: transparent;
+}
+.rc-cascader-menu-item-loading:after {
+  position: absolute;
+  right: 12px;
+  content: 'loading';
+  color: $text-color-weak;
+  font-style: italic;
+}
+.rc-cascader-menu-item-active {
+  color: $typeahead-selected-color;
+  background: $typeahead-selected-bg;
+}
+.rc-cascader-menu-item-active:hover {
+  color: $typeahead-selected-color;
+  background: $typeahead-selected-bg;
+}
+.rc-cascader-menu-item-expand {
+  position: relative;
+}
+.rc-cascader-menu-item-expand:after {
+  content: '>';
+  font-size: 12px;
+  color: $text-color-weak;
+  position: absolute;
+  right: 16px;
+  line-height: 32px;
+}
+@keyframes SlideUpIn {
+  0% {
+    opacity: 0;
+    transform-origin: 0% 0%;
+    transform: scaleY(0.8);
+  }
+  100% {
+    opacity: 1;
+    transform-origin: 0% 0%;
+    transform: scaleY(1);
+  }
+}
+@keyframes SlideUpOut {
+  0% {
+    opacity: 1;
+    transform-origin: 0% 0%;
+    transform: scaleY(1);
+  }
+  100% {
+    opacity: 0;
+    transform-origin: 0% 0%;
+    transform: scaleY(0.8);
+  }
+}
+@keyframes SlideDownIn {
+  0% {
+    opacity: 0;
+    transform-origin: 0% 100%;
+    transform: scaleY(0.8);
+  }
+  100% {
+    opacity: 1;
+    transform-origin: 0% 100%;
+    transform: scaleY(1);
+  }
+}
+@keyframes SlideDownOut {
+  0% {
+    opacity: 1;
+    transform-origin: 0% 100%;
+    transform: scaleY(1);
+  }
+  100% {
+    opacity: 0;
+    transform-origin: 0% 100%;
+    transform: scaleY(0.8);
+  }
+}

+ 121 - 2
yarn.lock

@@ -478,6 +478,12 @@ acorn@~2.6.4:
   version "2.6.4"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.6.4.tgz#eb1f45b4a43fa31d03701a5ec46f3b52673e90ee"
 
+add-dom-event-listener@1.x:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/add-dom-event-listener/-/add-dom-event-listener-1.0.2.tgz#8faed2c41008721cf111da1d30d995b85be42bed"
+  dependencies:
+    object-assign "4.x"
+
 after@0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
@@ -771,6 +777,10 @@ array-slice@^0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5"
 
+array-tree-filter@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/array-tree-filter/-/array-tree-filter-1.0.1.tgz#0a8ad1eefd38ce88858632f9cc0423d7634e4d5d"
+
 array-union@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
@@ -1514,7 +1524,7 @@ babel-register@^6.26.0, babel-register@^6.9.0:
     mkdirp "^0.5.1"
     source-map-support "^0.4.15"
 
-babel-runtime@^6.0.0, babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.9.2:
+babel-runtime@6.x, babel-runtime@^6.0.0, babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.9.2:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
   dependencies:
@@ -2246,6 +2256,10 @@ classnames@2.x, classnames@^2.2.4, classnames@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
+classnames@^2.2.6:
+  version "2.2.6"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
+
 clean-css@3.4.x, clean-css@~3.4.2:
   version "3.4.28"
   resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-3.4.28.tgz#bf1945e82fc808f55695e6ddeaec01400efd03ff"
@@ -2553,6 +2567,12 @@ component-bind@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
 
+component-classes@^1.2.5:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/component-classes/-/component-classes-1.2.6.tgz#c642394c3618a4d8b0b8919efccbbd930e5cd691"
+  dependencies:
+    component-indexof "0.0.3"
+
 component-emitter@1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.1.2.tgz#296594f2753daa63996d2af08d15a95116c9aec3"
@@ -2561,6 +2581,10 @@ component-emitter@1.2.1, component-emitter@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
 
+component-indexof@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/component-indexof/-/component-indexof-0.0.3.tgz#11d091312239eb8f32c8f25ae9cb002ffe8d3c24"
+
 component-inherit@0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
@@ -2841,6 +2865,13 @@ crypto-random-string@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
 
+css-animation@^1.3.2:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/css-animation/-/css-animation-1.4.1.tgz#5b8813125de0fbbbb0bbe1b472ae84221469b7a8"
+  dependencies:
+    babel-runtime "6.x"
+    component-classes "^1.2.5"
+
 css-color-names@0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
@@ -3515,6 +3546,10 @@ doctrine@^1.2.2:
     esutils "^2.0.2"
     isarray "^1.0.0"
 
+dom-align@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.8.0.tgz#c0e89b5b674c6e836cd248c52c2992135f093654"
+
 dom-converter@~0.1:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b"
@@ -7354,6 +7389,10 @@ lodash._createset@~4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26"
 
+lodash._getnative@^3.0.0:
+  version "3.9.1"
+  resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
+
 lodash._root@~3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"
@@ -7386,6 +7425,14 @@ lodash.flattendeep@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2"
 
+lodash.isarguments@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
+
+lodash.isarray@^3.0.0:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
+
 lodash.isequal@^4.0.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
@@ -7406,6 +7453,14 @@ lodash.kebabcase@^4.0.0:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
 
+lodash.keys@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
+  dependencies:
+    lodash._getnative "^3.0.0"
+    lodash.isarguments "^3.0.0"
+    lodash.isarray "^3.0.0"
+
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -8651,7 +8706,7 @@ object-assign@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0"
 
-object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+object-assign@4.x, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
 
@@ -9981,6 +10036,54 @@ raw-body@2.3.3:
     iconv-lite "0.4.23"
     unpipe "1.0.0"
 
+rc-align@^2.4.0:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/rc-align/-/rc-align-2.4.3.tgz#b9b3c2a6d68adae71a8e1d041cd5e3b2a655f99a"
+  dependencies:
+    babel-runtime "^6.26.0"
+    dom-align "^1.7.0"
+    prop-types "^15.5.8"
+    rc-util "^4.0.4"
+
+rc-animate@2.x:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-2.4.4.tgz#a05a784c747beef140d99ff52b6117711bef4b1e"
+  dependencies:
+    babel-runtime "6.x"
+    css-animation "^1.3.2"
+    prop-types "15.x"
+
+rc-cascader@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-0.14.0.tgz#a956c99896f10883bf63d46fb894d0cb326842a4"
+  dependencies:
+    array-tree-filter "^1.0.0"
+    prop-types "^15.5.8"
+    rc-trigger "^2.2.0"
+    rc-util "^4.0.4"
+    shallow-equal "^1.0.0"
+    warning "^4.0.1"
+
+rc-trigger@^2.2.0:
+  version "2.5.4"
+  resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-2.5.4.tgz#9088a24ba5a811b254f742f004e38a9e2f8843fb"
+  dependencies:
+    babel-runtime "6.x"
+    classnames "^2.2.6"
+    prop-types "15.x"
+    rc-align "^2.4.0"
+    rc-animate "2.x"
+    rc-util "^4.4.0"
+
+rc-util@^4.0.4, rc-util@^4.4.0:
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-4.5.1.tgz#0e435057174c024901c7600ba8903dd03da3ab39"
+  dependencies:
+    add-dom-event-listener "1.x"
+    babel-runtime "6.x"
+    prop-types "^15.5.10"
+    shallowequal "^0.2.2"
+
 rc@^1.0.1, rc@^1.1.6, rc@^1.1.7:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -10980,6 +11083,16 @@ shallow-clone@^1.0.0:
     kind-of "^5.0.0"
     mixin-object "^2.0.1"
 
+shallow-equal@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7"
+
+shallowequal@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e"
+  dependencies:
+    lodash.keys "^3.1.2"
+
 shallowequal@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.0.2.tgz#1561dbdefb8c01408100319085764da3fcf83f8f"
@@ -12555,6 +12668,12 @@ walker@~1.0.5:
   dependencies:
     makeerror "1.0.x"
 
+warning@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.1.tgz#66ce376b7fbfe8a887c22bdf0e7349d73d397745"
+  dependencies:
+    loose-envify "^1.0.0"
+
 watch@~0.18.0:
   version "0.18.0"
   resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986"