소스 검색

Add click on explore table cell to add filter to query (#12729)

* Add click on explore table cell to add filter to query

- move query state from query row to explore container to be able to set
  modified queries
- added TS interface for columns in table model
- plumbing from table cell click to datasource
- add modifyQuery to prometheus datasource
- implement addFilter as addLabelToQuery with tests

* Review feedback

- using airbnb style for Cell declaration
- fixed addLabelToQuery for complex label values
David 7 년 전
부모
커밋
bda49fcaa2

+ 23 - 8
public/app/containers/Explore/Explore.tsx

@@ -187,11 +187,14 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setDatasource(datasource);
   };
 
-  handleChangeQuery = (query, index) => {
+  handleChangeQuery = (value, index) => {
     const { queries } = this.state;
+    const prevQuery = queries[index];
+    const edited = prevQuery.query !== value;
     const nextQuery = {
       ...queries[index],
-      query,
+      edited,
+      query: value,
     };
     const nextQueries = [...queries];
     nextQueries[index] = nextQuery;
@@ -254,6 +257,18 @@ 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());
+    }
+  };
+
   buildQueryOptions(targetOptions: { format: string; instant?: boolean }) {
     const { datasource, queries, range } = this.state;
     const resolution = this.el.offsetWidth;
@@ -390,12 +405,12 @@ export class Explore extends React.Component<any, IExploreState> {
               </a>
             </div>
           ) : (
-            <div className="navbar-buttons explore-first-button">
-              <button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
-                Close Split
+              <div className="navbar-buttons explore-first-button">
+                <button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
+                  Close Split
               </button>
-            </div>
-          )}
+              </div>
+            )}
           {!datasourceMissing ? (
             <div className="navbar-buttons">
               <Select
@@ -473,7 +488,7 @@ export class Explore extends React.Component<any, IExploreState> {
                   split={split}
                 />
               ) : null}
-              {supportsTable && showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+              {supportsTable && showingTable ? <Table data={tableResult} onClickCell={this.onClickTableCell} className="m-t-3" /> : null}
               {supportsLogs && showingLogs ? <Logs data={logsResult} /> : null}
             </main>
           </div>

+ 4 - 14
public/app/containers/Explore/QueryRows.tsx

@@ -3,19 +3,8 @@ import React, { PureComponent } from 'react';
 import QueryField from './PromQueryField';
 
 class QueryRow extends PureComponent<any, any> {
-  constructor(props) {
-    super(props);
-    this.state = {
-      edited: false,
-      query: props.query || '',
-    };
-  }
-
   handleChangeQuery = value => {
     const { index, onChangeQuery } = this.props;
-    const { query } = this.state;
-    const edited = query !== value;
-    this.setState({ edited, query: value });
     if (onChangeQuery) {
       onChangeQuery(value, index);
     }
@@ -43,8 +32,7 @@ class QueryRow extends PureComponent<any, any> {
   };
 
   render() {
-    const { request } = this.props;
-    const { edited, query } = this.state;
+    const { request, query, edited } = this.props;
     return (
       <div className="query-row">
         <div className="query-row-tools">
@@ -74,7 +62,9 @@ export default class QueryRows extends PureComponent<any, any> {
     const { className = '', queries, ...handlers } = this.props;
     return (
       <div className={className}>
-        {queries.map((q, index) => <QueryRow key={q.key} index={index} query={q.query} {...handlers} />)}
+        {queries.map((q, index) => (
+          <QueryRow key={q.key} index={index} query={q.query} edited={q.edited} {...handlers} />
+        ))}
       </div>
     );
   }

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

@@ -1,14 +1,44 @@
 import React, { PureComponent } from 'react';
-// import TableModel from 'app/core/table_model';
+import TableModel from 'app/core/table_model';
 
-const EMPTY_TABLE = {
-  columns: [],
-  rows: [],
-};
+const EMPTY_TABLE = new TableModel();
 
-export default class Table extends PureComponent<any, any> {
+interface TableProps {
+  className?: string;
+  data: TableModel;
+  onClickCell?: (columnKey: string, rowValue: string) => void;
+}
+
+interface SFCCellProps {
+  columnIndex: number;
+  onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void;
+  rowIndex: number;
+  table: TableModel;
+  value: string;
+}
+
+function Cell(props: SFCCellProps) {
+  const { columnIndex, rowIndex, table, value, onClickCell } = props;
+  const column = table.columns[columnIndex];
+  if (column && column.filterable && onClickCell) {
+    const onClick = event => {
+      event.preventDefault();
+      onClickCell(column.text, value, columnIndex, rowIndex, table);
+    };
+    return (
+      <td>
+        <a className="link" onClick={onClick}>
+          {value}
+        </a>
+      </td>
+    );
+  }
+  return <td>{value}</td>;
+}
+
+export default class Table extends PureComponent<TableProps, {}> {
   render() {
-    const { className = '', data } = this.props;
+    const { className = '', data, onClickCell } = this.props;
     const tableModel = data || EMPTY_TABLE;
     return (
       <table className={`${className} filter-table`}>
@@ -16,7 +46,13 @@ export default class Table extends PureComponent<any, any> {
           <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>)}
+          {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} />
+              ))}
+            </tr>
+          ))}
         </tbody>
       </table>
     );

+ 11 - 1
public/app/core/table_model.ts

@@ -1,5 +1,15 @@
+interface Column {
+  text: string;
+  title?: string;
+  type?: string;
+  sort?: boolean;
+  desc?: boolean;
+  filterable?: boolean;
+  unit?: string;
+}
+
 export default class TableModel {
-  columns: any[];
+  columns: Column[];
   rows: any[];
   type: string;
   columnMap: any;

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

@@ -16,6 +16,72 @@ export function alignRange(start, end, step) {
   };
 }
 
+const keywords = 'by|without|on|ignoring|group_left|group_right';
+
+// Duplicate from mode-prometheus.js, which can't be used in tests due to global ace not being loaded.
+const builtInWords = [
+  keywords,
+  'count|count_values|min|max|avg|sum|stddev|stdvar|bottomk|topk|quantile',
+  'true|false|null|__name__|job',
+  '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',
+]
+  .join('|')
+  .split('|');
+
+// addLabelToQuery('foo', 'bar', 'baz') => 'foo{bar="baz"}'
+export function addLabelToQuery(query: string, key: string, value: string): string {
+  if (!key || !value) {
+    throw new Error('Need label to add to query.');
+  }
+
+  // Add empty selector to bare metric name
+  let previousWord;
+  query = query.replace(/(\w+)\b(?![\({=",])/g, (match, word, offset) => {
+    // Check if inside a selector
+    const nextSelectorStart = query.slice(offset).indexOf('{');
+    const nextSelectorEnd = query.slice(offset).indexOf('}');
+    const insideSelector = nextSelectorEnd > -1 && (nextSelectorStart === -1 || nextSelectorStart > nextSelectorEnd);
+    // Handle "sum by (key) (metric)"
+    const previousWordIsKeyWord = previousWord && keywords.split('|').indexOf(previousWord) > -1;
+    previousWord = word;
+    if (!insideSelector && !previousWordIsKeyWord && builtInWords.indexOf(word) === -1) {
+      return `${word}{}`;
+    }
+    return word;
+  });
+
+  // Adding label to existing selectors
+  const selectorRegexp = /{([^{]*)}/g;
+  let match = null;
+  const parts = [];
+  let lastIndex = 0;
+  let suffix = '';
+  while ((match = selectorRegexp.exec(query))) {
+    const prefix = query.slice(lastIndex, match.index);
+    const selectorParts = match[1].split(',');
+    const labels = selectorParts.reduce((acc, label) => {
+      const labelParts = label.split('=');
+      if (labelParts.length === 2) {
+        acc[labelParts[0]] = labelParts[1];
+      }
+      return acc;
+    }, {});
+    labels[key] = `"${value}"`;
+    const selector = Object.keys(labels)
+      .sort()
+      .map(key => `${key}=${labels[key]}`)
+      .join(',');
+    lastIndex = match.index + match[1].length + 2;
+    suffix = query.slice(match.index + match[0].length);
+    parts.push(prefix, '{', selector, '}');
+  }
+  parts.push(suffix);
+  return parts.join('');
+}
+
 export function prometheusRegularEscape(value) {
   if (typeof value === 'string') {
     return value.replace(/'/g, "\\\\'");
@@ -384,6 +450,14 @@ export class PrometheusDatasource {
     return state;
   }
 
+  modifyQuery(query: string, options: any): string {
+    const { addFilter } = options;
+    if (addFilter) {
+      return addLabelToQuery(query, addFilter.key, addFilter.value);
+    }
+    return query;
+  }
+
   getPrometheusTime(date, roundUp) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);

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

@@ -86,7 +86,7 @@ export class ResultTransformer {
     table.columns.push({ text: 'Time', type: 'time' });
     _.each(sortedLabels, function(label, labelIndex) {
       metricLabels[label] = labelIndex + 1;
-      table.columns.push({ text: label });
+      table.columns.push({ text: label, filterable: !label.startsWith('__') });
     });
     let valueText = resultCount > 1 ? `Value #${refId}` : 'Value';
     table.columns.push({ text: valueText });

+ 26 - 1
public/app/plugins/datasource/prometheus/specs/datasource.jest.ts

@@ -1,7 +1,14 @@
 import _ from 'lodash';
 import moment from 'moment';
 import q from 'q';
-import { alignRange, PrometheusDatasource, prometheusSpecialRegexEscape, prometheusRegularEscape } from '../datasource';
+import {
+  alignRange,
+  PrometheusDatasource,
+  prometheusSpecialRegexEscape,
+  prometheusRegularEscape,
+  addLabelToQuery,
+} from '../datasource';
+
 jest.mock('../metric_find_query');
 
 describe('PrometheusDatasource', () => {
@@ -245,6 +252,24 @@ describe('PrometheusDatasource', () => {
       expect(intervalMs).toEqual({ text: 15000, value: 15000 });
     });
   });
+
+  describe('addLabelToQuery()', () => {
+    expect(() => {
+      addLabelToQuery('foo', '', '');
+    }).toThrow();
+    expect(addLabelToQuery('foo + foo', 'bar', 'baz')).toBe('foo{bar="baz"} + foo{bar="baz"}');
+    expect(addLabelToQuery('foo{}', 'bar', 'baz')).toBe('foo{bar="baz"}');
+    expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz')).toBe('foo{bar="baz",x="yy"}');
+    expect(addLabelToQuery('foo{x="yy"} + metric', 'bar', 'baz')).toBe('foo{bar="baz",x="yy"} + metric{bar="baz"}');
+    expect(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz')).toBe('avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})');
+    expect(addLabelToQuery('foo{x="yy"} * metric{y="zz",a="bb"} * metric2', 'bar', 'baz')).toBe(
+      'foo{bar="baz",x="yy"} * metric{a="bb",bar="baz",y="zz"} * metric2{bar="baz"}'
+    );
+    expect(addLabelToQuery('sum by (xx) (foo)', 'bar', 'baz')).toBe('sum by (xx) (foo{bar="baz"})');
+    expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe(
+      'foo{bar="baz",instance="my-host.com:9100"}'
+    );
+  });
 });
 
 const SECOND = 1000;

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

@@ -39,7 +39,7 @@ describe('Prometheus Result Transformer', () => {
         [1443454528000, 'test', '', 'testjob', 3846],
         [1443454529000, 'test', 'localhost:8080', 'otherjob', 3847],
       ]);
-      expect(table.columns).toEqual([
+      expect(table.columns).toMatchObject([
         { text: 'Time', type: 'time' },
         { text: '__name__' },
         { text: 'instance' },
@@ -51,7 +51,7 @@ describe('Prometheus Result Transformer', () => {
     it('should column title include refId if response count is more than 2', () => {
       var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result, 2, 'B');
       expect(table.type).toBe('table');
-      expect(table.columns).toEqual([
+      expect(table.columns).toMatchObject([
         { text: 'Time', type: 'time' },
         { text: '__name__' },
         { text: 'instance' },
@@ -79,7 +79,7 @@ describe('Prometheus Result Transformer', () => {
       var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result);
       expect(table.type).toBe('table');
       expect(table.rows).toEqual([[1443454528000, 'test', 'testjob', 3846]]);
-      expect(table.columns).toEqual([
+      expect(table.columns).toMatchObject([
         { text: 'Time', type: 'time' },
         { text: '__name__' },
         { text: 'job' },

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

@@ -80,6 +80,10 @@
   .relative {
     position: relative;
   }
+
+  .link {
+    text-decoration: underline;
+  }
 }
 
 .explore + .explore {