mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Table: Adds adhoc filtering (#25467)
* Table: Adds adhoc filtering * Refactor: changes after PR comments * Refactor: hides filtering for data sources that do not support modifyQuery in Explore * Refactor: fixes strict null error * Changed tooltip position to above icon Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
parent
1040d824c5
commit
72b8300571
@ -0,0 +1,86 @@
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { TableCellProps } from 'react-table';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
|
||||
import { stylesFactory, useTheme } from '../../themes';
|
||||
import { TableStyles } from './styles';
|
||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableFilterActionCallback } from './types';
|
||||
import { Icon, Tooltip } from '..';
|
||||
import { Props, renderCell } from './TableCell';
|
||||
|
||||
interface FilterableTableCellProps extends Pick<Props, 'cell' | 'field' | 'tableStyles'> {
|
||||
onCellFilterAdded: TableFilterActionCallback;
|
||||
cellProps: TableCellProps;
|
||||
}
|
||||
|
||||
export const FilterableTableCell: FC<FilterableTableCellProps> = ({
|
||||
cell,
|
||||
field,
|
||||
tableStyles,
|
||||
onCellFilterAdded,
|
||||
cellProps,
|
||||
}) => {
|
||||
const [showFilters, setShowFilter] = useState(false);
|
||||
const onMouseOver = useCallback((event: React.MouseEvent<HTMLDivElement>) => setShowFilter(true), [setShowFilter]);
|
||||
const onMouseLeave = useCallback((event: React.MouseEvent<HTMLDivElement>) => setShowFilter(false), [setShowFilter]);
|
||||
const onFilterFor = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) =>
|
||||
onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }),
|
||||
[cell, field, onCellFilterAdded]
|
||||
);
|
||||
const onFilterOut = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) =>
|
||||
onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }),
|
||||
[cell, field, onCellFilterAdded]
|
||||
);
|
||||
const theme = useTheme();
|
||||
const styles = getFilterableTableCellStyles(theme, tableStyles);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...cellProps}
|
||||
className={showFilters ? styles.tableCellWrapper : tableStyles.tableCellWrapper}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{renderCell(cell, field, tableStyles)}
|
||||
{showFilters && cell.value && (
|
||||
<div className={styles.filterWrapper}>
|
||||
<div className={styles.filterItem}>
|
||||
<Tooltip content="Filter for value" placement="top">
|
||||
<Icon name={'search-plus'} onClick={onFilterFor} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<Tooltip content="Filter out value" placement="top">
|
||||
<Icon name={'search-minus'} onClick={onFilterOut} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFilterableTableCellStyles = stylesFactory((theme: GrafanaTheme, tableStyles: TableStyles) => ({
|
||||
tableCellWrapper: cx(
|
||||
tableStyles.tableCellWrapper,
|
||||
css`
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`
|
||||
),
|
||||
filterWrapper: css`
|
||||
label: filterWrapper;
|
||||
display: inline-flex;
|
||||
justify-content: space-around;
|
||||
cursor: pointer;
|
||||
`,
|
||||
filterItem: css`
|
||||
label: filterItem;
|
||||
color: ${theme.colors.textSemiWeak};
|
||||
padding: 0 ${theme.spacing.xxs};
|
||||
`,
|
||||
}));
|
@ -36,9 +36,9 @@ export interface Props {
|
||||
noHeader?: boolean;
|
||||
resizable?: boolean;
|
||||
initialSortBy?: TableSortByFieldState[];
|
||||
onCellClick?: TableFilterActionCallback;
|
||||
onColumnResize?: TableColumnResizeActionCallback;
|
||||
onSortByChange?: TableSortByActionCallback;
|
||||
onCellFilterAdded?: TableFilterActionCallback;
|
||||
}
|
||||
|
||||
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}> {}
|
||||
@ -110,7 +110,15 @@ function getInitialState(props: Props, columns: Column[]): Partial<ReactTableInt
|
||||
}
|
||||
|
||||
export const Table: FC<Props> = memo((props: Props) => {
|
||||
const { data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = true } = props;
|
||||
const {
|
||||
data,
|
||||
height,
|
||||
onCellFilterAdded,
|
||||
width,
|
||||
columnMinWidth = COLUMN_MIN_WIDTH,
|
||||
noHeader,
|
||||
resizable = true,
|
||||
} = props;
|
||||
const theme = useTheme();
|
||||
const tableStyles = getTableStyles(theme);
|
||||
|
||||
@ -162,7 +170,7 @@ export const Table: FC<Props> = memo((props: Props) => {
|
||||
field={data.fields[index]}
|
||||
tableStyles={tableStyles}
|
||||
cell={cell}
|
||||
onCellClick={onCellClick}
|
||||
onCellFilterAdded={onCellFilterAdded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,38 +1,45 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Cell } from 'react-table';
|
||||
import { Field } from '@grafana/data';
|
||||
|
||||
import { getTextAlign } from './utils';
|
||||
import { TableFilterActionCallback } from './types';
|
||||
import { TableStyles } from './styles';
|
||||
import { FilterableTableCell } from './FilterableTableCell';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
cell: Cell;
|
||||
field: Field;
|
||||
tableStyles: TableStyles;
|
||||
onCellClick?: TableFilterActionCallback;
|
||||
onCellFilterAdded?: TableFilterActionCallback;
|
||||
}
|
||||
|
||||
export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick }) => {
|
||||
export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellFilterAdded }) => {
|
||||
const filterable = field.config.filterable;
|
||||
const cellProps = cell.getCellProps();
|
||||
|
||||
let onClick: ((event: React.SyntheticEvent) => void) | undefined = undefined;
|
||||
|
||||
if (filterable && onCellClick) {
|
||||
if (cellProps.style) {
|
||||
cellProps.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
onClick = () => onCellClick(cell.column.Header as string, cell.value);
|
||||
}
|
||||
|
||||
if (cellProps.style) {
|
||||
cellProps.style.textAlign = getTextAlign(field);
|
||||
}
|
||||
|
||||
if (filterable && onCellFilterAdded) {
|
||||
return (
|
||||
<FilterableTableCell
|
||||
cell={cell}
|
||||
field={field}
|
||||
tableStyles={tableStyles}
|
||||
onCellFilterAdded={onCellFilterAdded}
|
||||
cellProps={cellProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...cellProps} onClick={onClick} className={tableStyles.tableCellWrapper}>
|
||||
{cell.render('Cell', { field, tableStyles })}
|
||||
<div {...cellProps} className={tableStyles.tableCellWrapper}>
|
||||
{renderCell(cell, field, tableStyles)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderCell = (cell: Cell, field: Field, tableStyles: TableStyles) =>
|
||||
cell.render('Cell', { field, tableStyles });
|
||||
|
@ -25,7 +25,11 @@ export interface TableRow {
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
export type TableFilterActionCallback = (key: string, value: string) => void;
|
||||
export const FILTER_FOR_OPERATOR = '=';
|
||||
export const FILTER_OUT_OPERATOR = '!=';
|
||||
export type FilterOperator = typeof FILTER_FOR_OPERATOR | typeof FILTER_OUT_OPERATOR;
|
||||
export type FilterItem = { key: string; value: string; operator: FilterOperator };
|
||||
export type TableFilterActionCallback = (item: FilterItem) => void;
|
||||
export type TableColumnResizeActionCallback = (fieldDisplayName: string, width: number) => void;
|
||||
export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void;
|
||||
|
||||
|
@ -58,6 +58,7 @@ import { scanStopAction } from './state/actionTypes';
|
||||
import { ExploreGraphPanel } from './ExploreGraphPanel';
|
||||
import { TraceView } from './TraceView/TraceView';
|
||||
import { SecondaryActions } from './SecondaryActions';
|
||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
@ -211,6 +212,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
this.props.setQueries(this.props.exploreId, [query]);
|
||||
};
|
||||
|
||||
onCellFilterAdded = (filter: FilterItem) => {
|
||||
const { value, key, operator } = filter;
|
||||
if (operator === FILTER_FOR_OPERATOR) {
|
||||
this.onClickFilterLabel(key, value);
|
||||
}
|
||||
|
||||
if (operator === FILTER_OUT_OPERATOR) {
|
||||
this.onClickFilterOutLabel(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
onClickFilterLabel = (key: string, value: string) => {
|
||||
this.onModifyQueries({ type: 'ADD_FILTER', key, value });
|
||||
};
|
||||
@ -366,7 +378,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
/>
|
||||
)}
|
||||
{mode === ExploreMode.Metrics && (
|
||||
<TableContainer width={width} exploreId={exploreId} onClickCell={this.onClickFilterLabel} />
|
||||
<TableContainer
|
||||
width={width}
|
||||
exploreId={exploreId}
|
||||
onCellFilterAdded={
|
||||
this.props.datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mode === ExploreMode.Logs && (
|
||||
<LogsContainer
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { shallow, render } from 'enzyme';
|
||||
import { render, shallow } from 'enzyme';
|
||||
import { TableContainer } from './TableContainer';
|
||||
import { DataFrame } from '@grafana/data';
|
||||
import { toggleTable } from './state/actions';
|
||||
@ -11,7 +11,7 @@ describe('TableContainer', () => {
|
||||
exploreId: ExploreId.left as ExploreId,
|
||||
loading: false,
|
||||
width: 800,
|
||||
onClickCell: jest.fn(),
|
||||
onCellFilterAdded: jest.fn(),
|
||||
showingTable: true,
|
||||
tableResult: {} as DataFrame,
|
||||
toggleTable: {} as typeof toggleTable,
|
||||
@ -26,7 +26,7 @@ describe('TableContainer', () => {
|
||||
exploreId: ExploreId.left as ExploreId,
|
||||
loading: false,
|
||||
width: 800,
|
||||
onClickCell: jest.fn(),
|
||||
onCellFilterAdded: jest.fn(),
|
||||
showingTable: true,
|
||||
tableResult: {
|
||||
name: 'TableResultName',
|
||||
|
@ -2,19 +2,20 @@ import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { DataFrame } from '@grafana/data';
|
||||
import { Table, Collapse } from '@grafana/ui';
|
||||
import { Collapse, Table } from '@grafana/ui';
|
||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
import { toggleTable } from './state/actions';
|
||||
import { config } from 'app/core/config';
|
||||
import { PANEL_BORDER } from 'app/core/constants';
|
||||
import { MetaInfoText } from './MetaInfoText';
|
||||
import { FilterItem } from '@grafana/ui/src/components/Table/types';
|
||||
|
||||
interface TableContainerProps {
|
||||
exploreId: ExploreId;
|
||||
loading: boolean;
|
||||
width: number;
|
||||
onClickCell: (key: string, value: string) => void;
|
||||
onCellFilterAdded?: (filter: FilterItem) => void;
|
||||
showingTable: boolean;
|
||||
tableResult?: DataFrame;
|
||||
toggleTable: typeof toggleTable;
|
||||
@ -37,7 +38,7 @@ export class TableContainer extends PureComponent<TableContainerProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, onClickCell, showingTable, tableResult, width } = this.props;
|
||||
const { loading, onCellFilterAdded, showingTable, tableResult, width } = this.props;
|
||||
|
||||
const height = this.getTableHeight();
|
||||
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
|
||||
@ -46,7 +47,7 @@ export class TableContainer extends PureComponent<TableContainerProps> {
|
||||
return (
|
||||
<Collapse label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
|
||||
{hasTableResult ? (
|
||||
<Table data={tableResult!} width={tableWidth} height={height} onCellClick={onClickCell} />
|
||||
<Table data={tableResult!} width={tableWidth} height={height} onCellFilterAdded={onCellFilterAdded} />
|
||||
) : (
|
||||
<MetaInfoText metaItems={[{ value: '0 series returned' }]} />
|
||||
)}
|
||||
|
@ -20,6 +20,7 @@ import { PromOptions, PromQuery } from './types';
|
||||
import templateSrv from 'app/features/templating/template_srv';
|
||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { VariableHide } from '../../../features/variables/types';
|
||||
import { describe } from '../../../../test/lib/common';
|
||||
|
||||
const datasourceRequestMock = jest.fn().mockResolvedValue(createDefaultPromResponse());
|
||||
|
||||
@ -1886,6 +1887,68 @@ describe('prepareTargets', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifyQuery', () => {
|
||||
describe('when called with ADD_FILTER', () => {
|
||||
describe('and query has no labels', () => {
|
||||
it('then the correct label should be added', () => {
|
||||
const query: PromQuery = { refId: 'A', expr: 'go_goroutines' };
|
||||
const action = { key: 'cluster', value: 'us-cluster', type: 'ADD_FILTER' };
|
||||
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
|
||||
const ds = new PrometheusDatasource(instanceSettings);
|
||||
|
||||
const result = ds.modifyQuery(query, action);
|
||||
|
||||
expect(result.refId).toEqual('A');
|
||||
expect(result.expr).toEqual('go_goroutines{cluster="us-cluster"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('and query has labels', () => {
|
||||
it('then the correct label should be added', () => {
|
||||
const query: PromQuery = { refId: 'A', expr: 'go_goroutines{cluster="us-cluster"}' };
|
||||
const action = { key: 'pod', value: 'pod-123', type: 'ADD_FILTER' };
|
||||
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
|
||||
const ds = new PrometheusDatasource(instanceSettings);
|
||||
|
||||
const result = ds.modifyQuery(query, action);
|
||||
|
||||
expect(result.refId).toEqual('A');
|
||||
expect(result.expr).toEqual('go_goroutines{cluster="us-cluster",pod="pod-123"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with ADD_FILTER_OUT', () => {
|
||||
describe('and query has no labels', () => {
|
||||
it('then the correct label should be added', () => {
|
||||
const query: PromQuery = { refId: 'A', expr: 'go_goroutines' };
|
||||
const action = { key: 'cluster', value: 'us-cluster', type: 'ADD_FILTER_OUT' };
|
||||
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
|
||||
const ds = new PrometheusDatasource(instanceSettings);
|
||||
|
||||
const result = ds.modifyQuery(query, action);
|
||||
|
||||
expect(result.refId).toEqual('A');
|
||||
expect(result.expr).toEqual('go_goroutines{cluster!="us-cluster"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('and query has labels', () => {
|
||||
it('then the correct label should be added', () => {
|
||||
const query: PromQuery = { refId: 'A', expr: 'go_goroutines{cluster="us-cluster"}' };
|
||||
const action = { key: 'pod', value: 'pod-123', type: 'ADD_FILTER_OUT' };
|
||||
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
|
||||
const ds = new PrometheusDatasource(instanceSettings);
|
||||
|
||||
const result = ds.modifyQuery(query, action);
|
||||
|
||||
expect(result.refId).toEqual('A');
|
||||
expect(result.expr).toEqual('go_goroutines{cluster="us-cluster",pod!="pod-123"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createDataRequest(targets: any[], overrides?: Partial<DataQueryRequest>): DataQueryRequest<PromQuery> {
|
||||
const defaults = {
|
||||
app: CoreApp.Dashboard,
|
||||
|
@ -693,6 +693,10 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
|
||||
expression = addLabelToQuery(expression, action.key, action.value);
|
||||
break;
|
||||
}
|
||||
case 'ADD_FILTER_OUT': {
|
||||
expression = addLabelToQuery(expression, action.key, action.value, '!=');
|
||||
break;
|
||||
}
|
||||
case 'ADD_HISTOGRAM_QUANTILE': {
|
||||
expression = `histogram_quantile(0.95, sum(rate(${expression}[5m])) by (le))`;
|
||||
break;
|
||||
|
@ -1,11 +1,14 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Table, Select } from '@grafana/ui';
|
||||
import { FieldMatcherID, PanelProps, DataFrame, SelectableValue, getFrameDisplayName } from '@grafana/data';
|
||||
import { Select, Table } from '@grafana/ui';
|
||||
import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableValue } from '@grafana/data';
|
||||
import { Options } from './types';
|
||||
import { css } from 'emotion';
|
||||
import { config } from 'app/core/config';
|
||||
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
|
||||
import { FilterItem, TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
|
||||
import { dispatch } from '../../../store/store';
|
||||
import { applyFilterFromTable } from '../../../features/variables/adhoc/actions';
|
||||
import { getDashboardSrv } from '../../../features/dashboard/services/DashboardSrv';
|
||||
|
||||
interface Props extends PanelProps<Options> {}
|
||||
|
||||
@ -62,6 +65,20 @@ export class TablePanel extends Component<Props> {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
onCellFilterAdded = (filter: FilterItem) => {
|
||||
const { key, value, operator } = filter;
|
||||
const panelModel = getDashboardSrv()
|
||||
.getCurrent()
|
||||
.getPanelById(this.props.id);
|
||||
const datasource = panelModel?.datasource;
|
||||
|
||||
if (!datasource) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(applyFilterFromTable({ datasource, key, operator, value }));
|
||||
};
|
||||
|
||||
renderTable(frame: DataFrame, width: number, height: number) {
|
||||
const { options } = this.props;
|
||||
|
||||
@ -75,6 +92,7 @@ export class TablePanel extends Component<Props> {
|
||||
initialSortBy={options.sortBy}
|
||||
onSortByChange={this.onSortByChange}
|
||||
onColumnResize={this.onColumnResize}
|
||||
onCellFilterAdded={this.onCellFilterAdded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user