mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
72af8a7044
commit
bda49fcaa2
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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 });
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user