Log: Added panel support for filtering callbacks (#88980)

* chore: update stale comment

* Logs Panel: add props for interactive callbacks

* LogsPanel: type guard unknown props

* chore: add comments

* chore: rename popover filtering callbacks prop names

* chore: format panelcfg

* Formatting

* LogsPanel: add default label filter handlers using panel context

* Formatting

* chore: add tests for new props
This commit is contained in:
Matias Chomicki 2024-06-11 15:54:41 +02:00 committed by GitHub
parent f32afbcb0a
commit ff0c9bd66a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 222 additions and 54 deletions

View File

@ -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;

View File

@ -188,8 +188,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
/**
* 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<Props, ExploreState> {
/**
* 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<Props, ExploreState> {
splitOpenFn={this.onSplitOpen('logs')}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
onClickFilterValue={this.onClickFilterValue}
onClickFilterOutValue={this.onClickFilterOutValue}
onClickFilterString={this.onClickFilterString}
onClickFilterOutString={this.onClickFilterOutString}
/>
</ContentOutlineItem>
);

View File

@ -109,8 +109,8 @@ interface Props extends Themeable2 {
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
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<Props, State> {
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}
/>
</InfiniteScroll>
</div>

View File

@ -58,8 +58,8 @@ interface LogsContainerProps extends PropsFromRedux {
splitOpenFn: SplitOpen;
scrollElement?: HTMLDivElement;
isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<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;
}
type DataSourceInstance =
@ -350,8 +350,8 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
scrollElement={scrollElement}
isFilterLabelActive={this.logDetailsFilterAvailable() ? this.props.isFilterLabelActive : undefined}
range={range}
onClickFilterValue={this.filterValueAvailable() ? this.props.onClickFilterValue : undefined}
onClickFilterOutValue={this.filterOutValueAvailable() ? this.props.onClickFilterOutValue : undefined}
onClickFilterString={this.filterValueAvailable() ? this.props.onClickFilterString : undefined}
onClickFilterOutString={this.filterOutValueAvailable() ? this.props.onClickFilterOutString : undefined}
/>
</LogsCrossFadeTransition>
</>

View File

@ -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(
<PopoverMenu selection="test" x={0} y={0} row={row} close={() => {}} onClickFilterValue={onClickFilterValue} />
<PopoverMenu selection="test" x={0} y={0} row={row} close={() => {}} 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(
<PopoverMenu
selection="test"
@ -38,7 +38,7 @@ test('Renders copy and line does not contain filter', async () => {
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={() => {}}
/>
);

View File

@ -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 && (
<Menu.Item
label="Add as line contains filter"
onClick={() => {
onClickFilterValue(selection, row.dataFrame.refId);
onClickFilterString(selection, row.dataFrame.refId);
close();
track('line_contains', selection.length, row.datasourceType);
}}
/>
)}
{onClickFilterOutValue && (
{onClickFilterOutString && (
<Menu.Item
label="Add as line does not contain filter"
onClick={() => {
onClickFilterOutValue(selection, row.dataFrame.refId);
onClickFilterOutString(selection, row.dataFrame.refId);
close();
track('line_does_not_contain', selection.length, row.datasourceType);
}}

View File

@ -221,8 +221,8 @@ describe('Popover menu', () => {
logsSortOrder={LogsSortOrder.Descending}
enableLogDetails={true}
displayedFields={[]}
onClickFilterOutValue={() => {}}
onClickFilterValue={() => {}}
onClickFilterOutString={() => {}}
onClickFilterString={() => {}}
app={app}
/>
);

View File

@ -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<Props, State> {
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<HTMLTableRowElement>, row: LogRowModel): boolean => {
@ -217,8 +217,8 @@ class UnThemedLogRows extends PureComponent<Props, State> {
row={this.state.selectedRow}
selection={this.state.selection}
{...this.state.popoverMenuCoordinates}
onClickFilterValue={rest.onClickFilterValue}
onClickFilterOutValue={rest.onClickFilterOutValue}
onClickFilterString={rest.onClickFilterString}
onClickFilterOutString={rest.onClickFilterOutString}
/>
)}
<table className={cx(styles.logsRowsTable, this.props.overflowingContent ? '' : styles.logsRowsTableContain)}>

View File

@ -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?: {}) => {

View File

@ -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<Options> {}
interface LogsPanelProps extends PanelProps<Options> {
/**
* 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<boolean>;
*/
}
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<HTMLDivElement | null>(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 <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
}
@ -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()}
</div>

View File

@ -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")
}
}]

View File

@ -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;

View File

@ -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<boolean>;
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';
}