diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 5ec7f5e4314..250f6d83143 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -15,6 +15,14 @@ export const pluginVersion = "11.1.0-pre"; export interface Options { dedupStrategy: common.LogsDedupStrategy; enableLogDetails: boolean; + isFilterLabelActive?: unknown; + /** + * TODO: figure out how to define callbacks + */ + onClickFilterLabel?: unknown; + onClickFilterOutLabel?: unknown; + onClickFilterOutString?: unknown; + onClickFilterString?: unknown; prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index a890bcf48c1..a7a31d5075b 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -188,8 +188,7 @@ export class Explore extends React.PureComponent { /** * Used by Logs details. - * Returns true if all queries have the filter, otherwise false. - * TODO: In the future, we would like to return active filters based the query that produced the log line. + * Returns true if the query identified by `refId` has a filter with the provided key and value. * @alpha */ isFilterLabelActive = async (key: string, value: string | number, refId?: string) => { @@ -235,14 +234,14 @@ export class Explore extends React.PureComponent { /** * Used by Logs Popover Menu. */ - onClickFilterValue = (value: string | number, refId?: string) => { + onClickFilterString = (value: string | number, refId?: string) => { this.onModifyQueries({ type: 'ADD_STRING_FILTER', options: { value: value.toString() } }, refId); }; /** * Used by Logs Popover Menu. */ - onClickFilterOutValue = (value: string | number, refId?: string) => { + onClickFilterOutString = (value: string | number, refId?: string) => { this.onModifyQueries({ type: 'ADD_STRING_FILTER_OUT', options: { value: value.toString() } }, refId); }; @@ -437,8 +436,8 @@ export class Explore extends React.PureComponent { splitOpenFn={this.onSplitOpen('logs')} scrollElement={this.scrollElement} isFilterLabelActive={this.isFilterLabelActive} - onClickFilterValue={this.onClickFilterValue} - onClickFilterOutValue={this.onClickFilterOutValue} + onClickFilterString={this.onClickFilterString} + onClickFilterOutString={this.onClickFilterOutString} /> ); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index e49468555f5..3a912190381 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -109,8 +109,8 @@ interface Props extends Themeable2 { isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; logsFrames?: DataFrame[]; range: TimeRange; - onClickFilterValue?: (value: string, refId?: string) => void; - onClickFilterOutValue?: (value: string, refId?: string) => void; + onClickFilterString?: (value: string, refId?: string) => void; + onClickFilterOutString?: (value: string, refId?: string) => void; loadMoreLogs?(range: AbsoluteTimeRange): void; } @@ -943,8 +943,8 @@ class UnthemedLogs extends PureComponent { scrollIntoView={this.scrollIntoView} isFilterLabelActive={this.props.isFilterLabelActive} containerRendered={!!this.state.logsContainer} - onClickFilterValue={this.props.onClickFilterValue} - onClickFilterOutValue={this.props.onClickFilterOutValue} + onClickFilterString={this.props.onClickFilterString} + onClickFilterOutString={this.props.onClickFilterOutString} /> diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 48a173cd064..528daafa093 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -58,8 +58,8 @@ interface LogsContainerProps extends PropsFromRedux { splitOpenFn: SplitOpen; scrollElement?: HTMLDivElement; isFilterLabelActive: (key: string, value: string, refId?: string) => Promise; - onClickFilterValue: (value: string, refId?: string) => void; - onClickFilterOutValue: (value: string, refId?: string) => void; + onClickFilterString: (value: string, refId?: string) => void; + onClickFilterOutString: (value: string, refId?: string) => void; } type DataSourceInstance = @@ -350,8 +350,8 @@ class LogsContainer extends PureComponent diff --git a/public/app/features/explore/Logs/PopoverMenu.test.tsx b/public/app/features/explore/Logs/PopoverMenu.test.tsx index 879e9fc0be5..81b9371526a 100644 --- a/public/app/features/explore/Logs/PopoverMenu.test.tsx +++ b/public/app/features/explore/Logs/PopoverMenu.test.tsx @@ -15,9 +15,9 @@ test('Does not render if the filter functions are not defined', () => { }); test('Renders copy and line contains filter', async () => { - const onClickFilterValue = jest.fn(); + const onClickFilterString = jest.fn(); render( - {}} onClickFilterValue={onClickFilterValue} /> + {}} onClickFilterString={onClickFilterString} /> ); expect(screen.getByText('Copy selection')).toBeInTheDocument(); @@ -25,12 +25,12 @@ test('Renders copy and line contains filter', async () => { await userEvent.click(screen.getByText('Add as line contains filter')); - expect(onClickFilterValue).toHaveBeenCalledTimes(1); - expect(onClickFilterValue).toHaveBeenCalledWith('test', row.dataFrame.refId); + expect(onClickFilterString).toHaveBeenCalledTimes(1); + expect(onClickFilterString).toHaveBeenCalledWith('test', row.dataFrame.refId); }); test('Renders copy and line does not contain filter', async () => { - const onClickFilterOutValue = jest.fn(); + const onClickFilterOutString = jest.fn(); render( { y={0} row={row} close={() => {}} - onClickFilterOutValue={onClickFilterOutValue} + onClickFilterOutString={onClickFilterOutString} /> ); @@ -47,8 +47,8 @@ test('Renders copy and line does not contain filter', async () => { await userEvent.click(screen.getByText('Add as line does not contain filter')); - expect(onClickFilterOutValue).toHaveBeenCalledTimes(1); - expect(onClickFilterOutValue).toHaveBeenCalledWith('test', row.dataFrame.refId); + expect(onClickFilterOutString).toHaveBeenCalledTimes(1); + expect(onClickFilterOutString).toHaveBeenCalledWith('test', row.dataFrame.refId); }); test('Renders copy, line contains filter, and line does not contain filter', () => { @@ -59,8 +59,8 @@ test('Renders copy, line contains filter, and line does not contain filter', () y={0} row={row} close={() => {}} - onClickFilterValue={() => {}} - onClickFilterOutValue={() => {}} + onClickFilterString={() => {}} + onClickFilterOutString={() => {}} /> ); @@ -78,8 +78,8 @@ test('Can be dismissed with escape', async () => { y={0} row={row} close={close} - onClickFilterValue={() => {}} - onClickFilterOutValue={() => {}} + onClickFilterString={() => {}} + onClickFilterOutString={() => {}} /> ); diff --git a/public/app/features/explore/Logs/PopoverMenu.tsx b/public/app/features/explore/Logs/PopoverMenu.tsx index fe62668307c..6bc0265145c 100644 --- a/public/app/features/explore/Logs/PopoverMenu.tsx +++ b/public/app/features/explore/Logs/PopoverMenu.tsx @@ -11,8 +11,8 @@ interface PopoverMenuProps { selection: string; x: number; y: number; - onClickFilterValue?: (value: string, refId?: string) => void; - onClickFilterOutValue?: (value: string, refId?: string) => void; + onClickFilterString?: (value: string, refId?: string) => void; + onClickFilterOutString?: (value: string, refId?: string) => void; row: LogRowModel; close: () => void; } @@ -20,8 +20,8 @@ interface PopoverMenuProps { export const PopoverMenu = ({ x, y, - onClickFilterValue, - onClickFilterOutValue, + onClickFilterString, + onClickFilterOutString, selection, row, close, @@ -42,7 +42,7 @@ export const PopoverMenu = ({ }; }, [close]); - const supported = onClickFilterValue || onClickFilterOutValue; + const supported = onClickFilterString || onClickFilterOutString; if (!supported) { return null; @@ -59,21 +59,21 @@ export const PopoverMenu = ({ track('copy', selection.length, row.datasourceType); }} /> - {onClickFilterValue && ( + {onClickFilterString && ( { - onClickFilterValue(selection, row.dataFrame.refId); + onClickFilterString(selection, row.dataFrame.refId); close(); track('line_contains', selection.length, row.datasourceType); }} /> )} - {onClickFilterOutValue && ( + {onClickFilterOutString && ( { - onClickFilterOutValue(selection, row.dataFrame.refId); + onClickFilterOutString(selection, row.dataFrame.refId); close(); track('line_does_not_contain', selection.length, row.datasourceType); }} diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index f412cb22cf7..5bf9fcb53da 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -221,8 +221,8 @@ describe('Popover menu', () => { logsSortOrder={LogsSortOrder.Descending} enableLogDetails={true} displayedFields={[]} - onClickFilterOutValue={() => {}} - onClickFilterValue={() => {}} + onClickFilterOutString={() => {}} + onClickFilterString={() => {}} app={app} /> ); diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 3049d5ba9d7..400266137f4 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -68,8 +68,8 @@ export interface Props extends Themeable2 { * Any overflowing content will be clipped at the table boundary. */ overflowingContent?: boolean; - onClickFilterValue?: (value: string, refId?: string) => void; - onClickFilterOutValue?: (value: string, refId?: string) => void; + onClickFilterString?: (value: string, refId?: string) => void; + onClickFilterOutString?: (value: string, refId?: string) => void; } interface State { @@ -107,7 +107,7 @@ class UnThemedLogRows extends PureComponent { if (!config.featureToggles.logRowsPopoverMenu || this.props.app !== CoreApp.Explore) { return false; } - return Boolean(this.props.onClickFilterOutValue || this.props.onClickFilterValue); + return Boolean(this.props.onClickFilterOutString || this.props.onClickFilterString); } handleSelection = (e: MouseEvent, row: LogRowModel): boolean => { @@ -217,8 +217,8 @@ class UnThemedLogRows extends PureComponent { row={this.state.selectedRow} selection={this.state.selection} {...this.state.popoverMenuCoordinates} - onClickFilterValue={rest.onClickFilterValue} - onClickFilterOutValue={rest.onClickFilterOutValue} + onClickFilterString={rest.onClickFilterString} + onClickFilterOutString={rest.onClickFilterOutString} /> )} diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 2230f70dae1..400423a8842 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -324,6 +324,63 @@ describe('LogsPanel', () => { expect(jest.mocked(styles.getLogRowStyles).mock.calls.length).toBeGreaterThan(3); }); }); + + describe('Filters', () => { + const series = [ + createDataFrame({ + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z'], + }, + { + name: 'message', + type: FieldType.string, + values: ['logline text'], + labels: { + app: 'common_app', + }, + }, + ], + }), + ]; + + it('allow to filter for a value or filter out a value', async () => { + const filterForMock = jest.fn(); + const filterOutMock = jest.fn(); + const isFilterLabelActiveMock = jest.fn(); + setup({ + data: { + series, + }, + options: { + showLabels: false, + showTime: false, + wrapLogMessage: false, + showCommonLabels: false, + prettifyLogMessage: false, + sortOrder: LogsSortOrder.Descending, + dedupStrategy: LogsDedupStrategy.none, + enableLogDetails: true, + onClickFilterLabel: filterForMock, + onClickFilterOutLabel: filterOutMock, + isFilterLabelActive: isFilterLabelActiveMock, + }, + }); + + expect(await screen.findByRole('row')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('logline text')); + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(filterForMock).toHaveBeenCalledTimes(1); + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(filterOutMock).toHaveBeenCalledTimes(1); + + expect(isFilterLabelActiveMock).toHaveBeenCalledTimes(1); + }); + }); }); const setup = (propsOverrides?: {}) => { diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 40aa8dab3f0..0bafecd0004 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -29,10 +29,34 @@ import { LogLabels } from '../../../features/logs/components/LogLabels'; import { LogRows } from '../../../features/logs/components/LogRows'; import { COMMON_LABELS, dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel'; -import { Options } from './types'; +import { + isIsFilterLabelActive, + isOnClickFilterLabel, + isOnClickFilterOutLabel, + isOnClickFilterOutString, + isOnClickFilterString, + Options, +} from './types'; import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; -interface LogsPanelProps extends PanelProps {} +interface LogsPanelProps extends PanelProps { + /** + * Adds a key => value filter to the query referenced by the provided DataFrame refId. Used by Log details and Logs table. + * onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; + * + * Adds a negative key => value filter to the query referenced by the provided DataFrame refId. Used by Log details and Logs table. + * onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; + * + * Adds a string filter to the query referenced by the provided DataFrame refId. Used by the Logs popover menu. + * onClickFilterOutString?: (value: string, refId?: string) => void; + * + * Removes a string filter to the query referenced by the provided DataFrame refId. Used by the Logs popover menu. + * onClickFilterString?: (value: string, refId?: string) => void; + * + * Determines if a given key => value filter is active in a given query. Used by Log details. + * isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; + */ +} interface LogsPermalinkUrlState { logs?: { id?: string; @@ -55,6 +79,11 @@ export const LogsPanel = ({ dedupStrategy, enableLogDetails, showLogContextToggle, + onClickFilterLabel, + onClickFilterOutLabel, + onClickFilterOutString, + onClickFilterString, + isFilterLabelActive, }, id, }: LogsPanelProps) => { @@ -68,7 +97,7 @@ export const LogsPanel = ({ const [scrollElement, setScrollElement] = useState(null); let closeCallback = useRef<() => void>(); - const { eventBus } = usePanelContext(); + const { eventBus, onAddAdHocFilter } = usePanelContext(); const onLogRowHover = useCallback( (row?: LogRowModel) => { if (!row) { @@ -220,6 +249,28 @@ export const LogsPanel = ({ [scrollElement] ); + const defaultOnClickFilterLabel = useCallback( + (key: string, value: string) => { + onAddAdHocFilter?.({ + key, + value, + operator: '=', + }); + }, + [onAddAdHocFilter] + ); + + const defaultOnClickFilterOutLabel = useCallback( + (key: string, value: string) => { + onAddAdHocFilter?.({ + key, + value, + operator: '!=', + }); + }, + [onAddAdHocFilter] + ); + if (!data || logRows.length === 0) { return ; } @@ -275,6 +326,17 @@ export const LogsPanel = ({ onLogRowHover={onLogRowHover} app={CoreApp.Dashboard} onOpenContext={onOpenContext} + onClickFilterLabel={ + isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel + } + onClickFilterOutLabel={ + isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel + } + onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined} + onClickFilterOutString={ + isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined + } + isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} /> {showCommonLabels && isAscending && renderCommonLabels()} diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index fd3719ec65c..481fb99efe6 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -26,15 +26,21 @@ composableKinds: PanelCfg: { version: [0, 0] schema: { Options: { - showLabels: bool - showCommonLabels: bool - showTime: bool - showLogContextToggle: bool - wrapLogMessage: bool - prettifyLogMessage: bool - enableLogDetails: bool - sortOrder: common.LogsSortOrder - dedupStrategy: common.LogsDedupStrategy + showLabels: bool + showCommonLabels: bool + showTime: bool + showLogContextToggle: bool + wrapLogMessage: bool + prettifyLogMessage: bool + enableLogDetails: bool + sortOrder: common.LogsSortOrder + dedupStrategy: common.LogsDedupStrategy + // TODO: figure out how to define callbacks + onClickFilterLabel?: _ + onClickFilterOutLabel?: _ + isFilterLabelActive?: _ + onClickFilterString?: _ + onClickFilterOutString?: _ } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index 62e8fc956ed..c41589a2ce0 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -13,6 +13,14 @@ import * as common from '@grafana/schema'; export interface Options { dedupStrategy: common.LogsDedupStrategy; enableLogDetails: boolean; + isFilterLabelActive?: unknown; + /** + * TODO: figure out how to define callbacks + */ + onClickFilterLabel?: unknown; + onClickFilterOutLabel?: unknown; + onClickFilterOutString?: unknown; + onClickFilterString?: unknown; prettifyLogMessage: boolean; showCommonLabels: boolean; showLabels: boolean; diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 1473f1fd934..711bd17c54f 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -1 +1,29 @@ +import { DataFrame } from '@grafana/data'; + export { Options } from './panelcfg.gen'; + +type onClickFilterLabelType = (key: string, value: string, frame?: DataFrame) => void; +type onClickFilterOutLabelType = (key: string, value: string, frame?: DataFrame) => void; +type onClickFilterValueType = (value: string, refId?: string) => void; +type onClickFilterOutStringType = (value: string, refId?: string) => void; +type isFilterLabelActiveType = (key: string, value: string, refId?: string) => Promise; + +export function isOnClickFilterLabel(callback: unknown): callback is onClickFilterLabelType { + return typeof callback === 'function'; +} + +export function isOnClickFilterOutLabel(callback: unknown): callback is onClickFilterOutLabelType { + return typeof callback === 'function'; +} + +export function isOnClickFilterString(callback: unknown): callback is onClickFilterValueType { + return typeof callback === 'function'; +} + +export function isOnClickFilterOutString(callback: unknown): callback is onClickFilterOutStringType { + return typeof callback === 'function'; +} + +export function isIsFilterLabelActive(callback: unknown): callback is isFilterLabelActiveType { + return typeof callback === 'function'; +}