Logs panel: Support details view (#34125)

* Show log details in Logs panel

* Add hide log details as panel option

* Refactor tests to use testing library

* Change hideLogDetails to enableLogsDetails

* Add enableLogDetails to test file
This commit is contained in:
Ivana Huckova 2021-05-18 11:16:29 +02:00 committed by GitHub
parent 10a19ab790
commit a40aef0822
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 104 additions and 43 deletions

View File

@ -1,12 +1,11 @@
import React from 'react'; import React, { ComponentProps } from 'react';
import { LogDetailsRow, Props } from './LogDetailsRow'; import { screen, render, fireEvent } from '@testing-library/react';
import { GrafanaTheme } from '@grafana/data'; import { LogDetailsRow } from './LogDetailsRow';
import { mount } from 'enzyme';
import { LogLabelStats } from './LogLabelStats'; type Props = ComponentProps<typeof LogDetailsRow>;
const setup = (propOverrides?: Partial<Props>) => { const setup = (propOverrides?: Partial<Props>) => {
const props: Props = { const props: Props = {
theme: {} as GrafanaTheme,
parsedValue: '', parsedValue: '',
parsedKey: '', parsedKey: '',
isLabel: true, isLabel: true,
@ -14,40 +13,67 @@ const setup = (propOverrides?: Partial<Props>) => {
getStats: () => null, getStats: () => null,
onClickFilterLabel: () => {}, onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {}, onClickFilterOutLabel: () => {},
onClickShowDetectedField: () => {},
onClickHideDetectedField: () => {},
showDetectedFields: [],
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
const wrapper = mount(<LogDetailsRow {...props} />); return render(<LogDetailsRow {...props} />);
return wrapper;
}; };
describe('LogDetailsRow', () => { describe('LogDetailsRow', () => {
it('should render parsed key', () => { it('should render parsed key', () => {
const wrapper = setup({ parsedKey: 'test key' }); setup({ parsedKey: 'test key' });
expect(wrapper.text().includes('test key')).toBe(true); expect(screen.getByText('test key')).toBeInTheDocument();
}); });
it('should render parsed value', () => { it('should render parsed value', () => {
const wrapper = setup({ parsedValue: 'test value' }); setup({ parsedValue: 'test value' });
expect(wrapper.text().includes('test value')).toBe(true); expect(screen.getByText('test value')).toBeInTheDocument();
}); });
it('should render metrics button', () => { it('should render metrics button', () => {
const wrapper = setup(); setup();
expect(wrapper.find({ title: 'Ad-hoc statistics' }).hostNodes()).toHaveLength(1); expect(screen.getAllByTitle('Ad-hoc statistics')).toHaveLength(1);
}); });
describe('if props is a label', () => { describe('if props is a label', () => {
it('should render filter label button', () => { it('should render filter label button', () => {
const wrapper = setup(); setup();
expect(wrapper.find({ title: 'Filter for value' }).hostNodes()).toHaveLength(1); expect(screen.getAllByTitle('Filter for value')).toHaveLength(1);
}); });
it('should render filter out label button', () => { it('should render filter out label button', () => {
const wrapper = setup(); setup();
expect(wrapper.find({ title: 'Filter out value' }).hostNodes()).toHaveLength(1); expect(screen.getAllByTitle('Filter out value')).toHaveLength(1);
});
it('should not render filtering buttons if no filtering functions provided', () => {
setup({ onClickFilterLabel: undefined, onClickFilterOutLabel: undefined });
expect(screen.queryByTitle('Filter out value')).not.toBeInTheDocument();
});
});
describe('if props is not a label', () => {
it('should not render a filter label button', () => {
setup({ isLabel: false });
expect(screen.queryByTitle('Filter for value')).not.toBeInTheDocument();
});
it('should render a show toggleFieldButton button', () => {
setup({ isLabel: false });
expect(screen.getAllByTitle('Show this field instead of the message')).toHaveLength(1);
});
it('should not render a show toggleFieldButton button if no detected fields toggling functions provided', () => {
setup({
isLabel: false,
onClickShowDetectedField: undefined,
onClickHideDetectedField: undefined,
});
expect(screen.queryByTitle('Show this field instead of the message')).not.toBeInTheDocument();
}); });
}); });
it('should render stats when stats icon is clicked', () => { it('should render stats when stats icon is clicked', () => {
const wrapper = setup({ setup({
parsedKey: 'key', parsedKey: 'key',
parsedValue: 'value', parsedValue: 'value',
getStats: () => { getStats: () => {
@ -66,9 +92,10 @@ describe('LogDetailsRow', () => {
}, },
}); });
expect(wrapper.find(LogLabelStats).length).toBe(0); expect(screen.queryByTestId('logLabelStats')).not.toBeInTheDocument();
wrapper.find({ title: 'Ad-hoc statistics' }).hostNodes().simulate('click'); const adHocStatsButton = screen.getByTitle('Ad-hoc statistics');
expect(wrapper.find(LogLabelStats).length).toBe(1); fireEvent.click(adHocStatsButton);
expect(wrapper.find(LogLabelStats).contains('another value')).toBeTruthy(); expect(screen.getByTestId('logLabelStats')).toBeInTheDocument();
expect(screen.getByTestId('logLabelStats')).toHaveTextContent('another value');
}); });
}); });

View File

@ -112,10 +112,26 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
} }
render() { render() {
const { theme, parsedKey, parsedValue, isLabel, links, showDetectedFields, wrapLogMessage } = this.props; const {
theme,
parsedKey,
parsedValue,
isLabel,
links,
showDetectedFields,
wrapLogMessage,
onClickShowDetectedField,
onClickHideDetectedField,
onClickFilterLabel,
onClickFilterOutLabel,
} = this.props;
const { showFieldsStats, fieldStats, fieldCount } = this.state; const { showFieldsStats, fieldStats, fieldCount } = this.state;
const styles = getStyles(theme); const styles = getStyles(theme);
const style = getLogRowStyles(theme); const style = getLogRowStyles(theme);
const hasDetectedFieldsFunctionality = onClickShowDetectedField && onClickHideDetectedField;
const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel;
const toggleFieldButton = const toggleFieldButton =
!isLabel && showDetectedFields && showDetectedFields.includes(parsedKey) ? ( !isLabel && showDetectedFields && showDetectedFields.includes(parsedKey) ? (
<IconButton name="eye" className={styles.showingField} title="Hide this field" onClick={this.hideField} /> <IconButton name="eye" className={styles.showingField} title="Hide this field" onClick={this.hideField} />
@ -130,7 +146,7 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
<IconButton name="signal" title={'Ad-hoc statistics'} onClick={this.showStats} /> <IconButton name="signal" title={'Ad-hoc statistics'} onClick={this.showStats} />
</td> </td>
{isLabel && ( {hasFilteringFunctionality && isLabel && (
<> <>
<td className={style.logsDetailsIcon}> <td className={style.logsDetailsIcon}>
<IconButton name="search-plus" title="Filter for value" onClick={this.filterLabel} /> <IconButton name="search-plus" title="Filter for value" onClick={this.filterLabel} />
@ -141,12 +157,10 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
</> </>
)} )}
{!isLabel && ( {hasDetectedFieldsFunctionality && !isLabel && (
<> <td className={style.logsDetailsIcon} colSpan={2}>
<td className={style.logsDetailsIcon} colSpan={2}> {toggleFieldButton}
{toggleFieldButton} </td>
</td>
</>
)} )}
{/* Key - value columns */} {/* Key - value columns */}

View File

@ -74,7 +74,7 @@ class UnThemedLogLabelStats extends PureComponent<Props> {
const otherProportion = otherCount / total; const otherProportion = otherCount / total;
return ( return (
<td className={style.logsStats}> <td className={style.logsStats} data-testid="logLabelStats">
<div className={style.logsStatsHeader}> <div className={style.logsStatsHeader}>
<div className={style.logsStatsTitle}> <div className={style.logsStatsTitle}>
{label}: {total} of {rowCount} rows have that {isLabel ? 'label' : 'field'} {label}: {total} of {rowCount} rows have that {isLabel ? 'label' : 'field'}

View File

@ -41,7 +41,7 @@ interface Props extends Themeable {
showTime: boolean; showTime: boolean;
wrapLogMessage: boolean; wrapLogMessage: boolean;
timeZone: TimeZone; timeZone: TimeZone;
allowDetails?: boolean; enableLogDetails: boolean;
logsSortOrder?: LogsSortOrder | null; logsSortOrder?: LogsSortOrder | null;
forceEscape?: boolean; forceEscape?: boolean;
showDetectedFields?: string[]; showDetectedFields?: string[];
@ -102,7 +102,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
}; };
toggleDetails = () => { toggleDetails = () => {
if (this.props.allowDetails) { if (!this.props.enableLogDetails) {
return; return;
} }
this.setState((state) => { this.setState((state) => {
@ -131,7 +131,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
onClickShowDetectedField, onClickShowDetectedField,
onClickHideDetectedField, onClickHideDetectedField,
highlighterExpressions, highlighterExpressions,
allowDetails, enableLogDetails,
row, row,
showDuplicates, showDuplicates,
showContextToggle, showContextToggle,
@ -171,7 +171,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
</Tooltip> </Tooltip>
)} )}
</td> </td>
{!allowDetails && ( {enableLogDetails && (
<td title={showDetails ? 'Hide log details' : 'See log details'} className={style.logsRowToggleDetails}> <td title={showDetails ? 'Hide log details' : 'See log details'} className={style.logsRowToggleDetails}>
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} /> <Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
</td> </td>

View File

@ -17,6 +17,7 @@ describe('LogRows', () => {
showTime={false} showTime={false}
wrapLogMessage={true} wrapLogMessage={true}
timeZone={'utc'} timeZone={'utc'}
enableLogDetails={true}
/> />
); );
@ -39,6 +40,7 @@ describe('LogRows', () => {
wrapLogMessage={true} wrapLogMessage={true}
timeZone={'utc'} timeZone={'utc'}
previewLimit={1} previewLimit={1}
enableLogDetails={true}
/> />
); );
@ -68,6 +70,7 @@ describe('LogRows', () => {
showTime={false} showTime={false}
wrapLogMessage={true} wrapLogMessage={true}
timeZone={'utc'} timeZone={'utc'}
enableLogDetails={true}
/> />
); );
@ -88,6 +91,7 @@ describe('LogRows', () => {
showTime={false} showTime={false}
wrapLogMessage={true} wrapLogMessage={true}
timeZone={'utc'} timeZone={'utc'}
enableLogDetails={true}
/> />
); );
@ -110,6 +114,7 @@ describe('LogRows', () => {
wrapLogMessage={true} wrapLogMessage={true}
timeZone={'utc'} timeZone={'utc'}
logsSortOrder={LogsSortOrder.Ascending} logsSortOrder={LogsSortOrder.Ascending}
enableLogDetails={true}
/> />
); );
@ -133,6 +138,7 @@ describe('LogRows', () => {
wrapLogMessage={true} wrapLogMessage={true}
timeZone={'utc'} timeZone={'utc'}
logsSortOrder={LogsSortOrder.Descending} logsSortOrder={LogsSortOrder.Descending}
enableLogDetails={true}
/> />
); );

View File

@ -21,8 +21,8 @@ export interface Props extends Themeable {
showTime: boolean; showTime: boolean;
wrapLogMessage: boolean; wrapLogMessage: boolean;
timeZone: TimeZone; timeZone: TimeZone;
enableLogDetails: boolean;
logsSortOrder?: LogsSortOrder | null; logsSortOrder?: LogsSortOrder | null;
allowDetails?: boolean;
previewLimit?: number; previewLimit?: number;
forceEscape?: boolean; forceEscape?: boolean;
showDetectedFields?: string[]; showDetectedFields?: string[];
@ -91,7 +91,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
onClickFilterLabel, onClickFilterLabel,
onClickFilterOutLabel, onClickFilterOutLabel,
theme, theme,
allowDetails, enableLogDetails,
previewLimit, previewLimit,
getFieldLinks, getFieldLinks,
logsSortOrder, logsSortOrder,
@ -136,7 +136,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
showDetectedFields={showDetectedFields} showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
timeZone={timeZone} timeZone={timeZone}
allowDetails={allowDetails} enableLogDetails={enableLogDetails}
onClickFilterLabel={onClickFilterLabel} onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField} onClickShowDetectedField={onClickShowDetectedField}
@ -161,7 +161,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
showDetectedFields={showDetectedFields} showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
timeZone={timeZone} timeZone={timeZone}
allowDetails={allowDetails} enableLogDetails={enableLogDetails}
onClickFilterLabel={onClickFilterLabel} onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField} onClickShowDetectedField={onClickShowDetectedField}

View File

@ -341,6 +341,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
showContextToggle={showContextToggle} showContextToggle={showContextToggle}
showLabels={showLabels} showLabels={showLabels}
showTime={showTime} showTime={showTime}
enableLogDetails={true}
forceEscape={forceEscape} forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
timeZone={timeZone} timeZone={timeZone}

View File

@ -1,15 +1,16 @@
import React from 'react'; import React from 'react';
import { LogRows, CustomScrollbar } from '@grafana/ui'; import { LogRows, CustomScrollbar } from '@grafana/ui';
import { PanelProps } from '@grafana/data'; import { PanelProps, Field } from '@grafana/data';
import { Options } from './types'; import { Options } from './types';
import { dataFrameToLogsModel, dedupLogRows } from 'app/core/logs_model'; import { dataFrameToLogsModel, dedupLogRows } from 'app/core/logs_model';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
interface LogsPanelProps extends PanelProps<Options> {} interface LogsPanelProps extends PanelProps<Options> {}
export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
data, data,
timeZone, timeZone,
options: { showLabels, showTime, wrapLogMessage, sortOrder, dedupStrategy }, options: { showLabels, showTime, wrapLogMessage, sortOrder, dedupStrategy, enableLogDetails },
}) => { }) => {
if (!data) { if (!data) {
return ( return (
@ -23,6 +24,10 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
const logRows = newResults?.rows || []; const logRows = newResults?.rows || [];
const deduplicatedRows = dedupLogRows(logRows, dedupStrategy); const deduplicatedRows = dedupLogRows(logRows, dedupStrategy);
const getFieldLinks = (field: Field, rowIndex: number) => {
return getFieldLinksForExplore({ field, rowIndex, range: data.timeRange });
};
return ( return (
<CustomScrollbar autoHide> <CustomScrollbar autoHide>
<LogRows <LogRows
@ -34,8 +39,9 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
showTime={showTime} showTime={showTime}
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
timeZone={timeZone} timeZone={timeZone}
allowDetails={true} getFieldLinks={getFieldLinks}
logsSortOrder={sortOrder} logsSortOrder={sortOrder}
enableLogDetails={enableLogDetails}
/> />
</CustomScrollbar> </CustomScrollbar>
); );

View File

@ -22,6 +22,12 @@ export const plugin = new PanelPlugin<Options>(LogsPanel).setPanelOptions((build
description: '', description: '',
defaultValue: false, defaultValue: false,
}) })
.addBooleanSwitch({
path: 'enableLogDetails',
name: 'Enable log details',
description: '',
defaultValue: true,
})
.addRadio({ .addRadio({
path: 'dedupStrategy', path: 'dedupStrategy',
name: 'Deduplication', name: 'Deduplication',

View File

@ -4,6 +4,7 @@ export interface Options {
showLabels: boolean; showLabels: boolean;
showTime: boolean; showTime: boolean;
wrapLogMessage: boolean; wrapLogMessage: boolean;
enableLogDetails: boolean;
sortOrder: LogsSortOrder; sortOrder: LogsSortOrder;
dedupStrategy: LogsDedupStrategy; dedupStrategy: LogsDedupStrategy;
} }