diff --git a/packages/grafana-ui/src/components/Table/FilterableTableCell.tsx b/packages/grafana-ui/src/components/Table/FilterableTableCell.tsx new file mode 100644 index 00000000000..fa1962d5ba2 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/FilterableTableCell.tsx @@ -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 { + onCellFilterAdded: TableFilterActionCallback; + cellProps: TableCellProps; +} + +export const FilterableTableCell: FC = ({ + cell, + field, + tableStyles, + onCellFilterAdded, + cellProps, +}) => { + const [showFilters, setShowFilter] = useState(false); + const onMouseOver = useCallback((event: React.MouseEvent) => setShowFilter(true), [setShowFilter]); + const onMouseLeave = useCallback((event: React.MouseEvent) => setShowFilter(false), [setShowFilter]); + const onFilterFor = useCallback( + (event: React.MouseEvent) => + onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }), + [cell, field, onCellFilterAdded] + ); + const onFilterOut = useCallback( + (event: React.MouseEvent) => + onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }), + [cell, field, onCellFilterAdded] + ); + const theme = useTheme(); + const styles = getFilterableTableCellStyles(theme, tableStyles); + + return ( +
+ {renderCell(cell, field, tableStyles)} + {showFilters && cell.value && ( +
+
+ + + +
+
+ + + +
+
+ )} +
+ ); +}; + +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}; + `, +})); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index ce234702e25..25e71483cf1 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -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 = 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 = memo((props: Props) => { field={data.fields[index]} tableStyles={tableStyles} cell={cell} - onCellClick={onCellClick} + onCellFilterAdded={onCellFilterAdded} /> ))} diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index 2b4c4909c1b..ae9796c4d8d 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -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 = ({ cell, field, tableStyles, onCellClick }) => { +export const TableCell: FC = ({ 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 ( + + ); + } + return ( -
- {cell.render('Cell', { field, tableStyles })} +
+ {renderCell(cell, field, tableStyles)}
); }; + +export const renderCell = (cell: Cell, field: Field, tableStyles: TableStyles) => + cell.render('Cell', { field, tableStyles }); diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index c599f941123..687c9e60295 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -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; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index be4287ceae7..bda92fd476b 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { 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 { /> )} {mode === ExploreMode.Metrics && ( - + )} {mode === ExploreMode.Logs && ( { 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', diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index 2c75bf3c476..c7539f0c301 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/public/app/features/explore/TableContainer.tsx @@ -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 { } 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 { return ( {hasTableResult ? ( - +
) : ( )} diff --git a/public/app/plugins/datasource/prometheus/datasource.test.ts b/public/app/plugins/datasource/prometheus/datasource.test.ts index c20c19cd14a..e490818ed4f 100644 --- a/public/app/plugins/datasource/prometheus/datasource.test.ts +++ b/public/app/plugins/datasource/prometheus/datasource.test.ts @@ -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; + 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; + 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; + 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; + 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 { const defaults = { app: CoreApp.Dashboard, diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 77c6ab39f8a..5ab4aeb85bf 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -693,6 +693,10 @@ export class PrometheusDatasource extends DataSourceApi 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; diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index f31a65b698d..49d424470dc 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -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 {} @@ -62,6 +65,20 @@ export class TablePanel extends Component { 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 { initialSortBy={options.sortBy} onSortByChange={this.onSortByChange} onColumnResize={this.onColumnResize} + onCellFilterAdded={this.onCellFilterAdded} /> ); }