Commit bda49fca by David Committed by Alexander Zobnin

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
parent 72af8a70
......@@ -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>
......
......@@ -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>
);
}
......
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>
);
......
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;
......
......@@ -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);
......
......@@ -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 });
......
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;
......
......@@ -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' },
......
......@@ -80,6 +80,10 @@
.relative {
position: relative;
}
.link {
text-decoration: underline;
}
}
.explore + .explore {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment