Logs: Added option to show the log line body when displayed fields are used (#97209)
* LogDetailsBody: create and integrate component * Chore: sort prop names * Add tests * Address lint issues * Update betterer * Update public/app/features/logs/components/LogDetailsBody.tsx Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> * Update public/app/features/logs/components/LogDetailsBody.tsx Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> * Update tests --------- Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com>
This commit is contained in:
co-authored by
Galen Kistler
parent
18c1c4cf95
commit
3fdf0ea1f4
@@ -3303,11 +3303,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/logs/components/InfiniteScroll.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/logs/components/LogDetails.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/logs/components/LogLabelStats.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
|
||||
import { LogDetails, Props } from './LogDetails';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { createLogRow } from './__mocks__/logRow';
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
|
||||
@@ -58,6 +59,33 @@ describe('LogDetails', () => {
|
||||
expect(screen.getByRole('cell', { name: 'key2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: 'label2' })).toBeInTheDocument();
|
||||
});
|
||||
it('should show an option to display the log line when displayed fields are used', async () => {
|
||||
const onClickShowField = jest.fn();
|
||||
|
||||
setup({ displayedFields: ['key1'], onClickShowField }, { labels: { key1: 'label1' } });
|
||||
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Show log line')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Show log line'));
|
||||
|
||||
expect(onClickShowField).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should show an active option to display the log line when displayed fields are used', async () => {
|
||||
const onClickHideField = jest.fn();
|
||||
|
||||
setup({ displayedFields: ['key1', LOG_LINE_BODY_FIELD_NAME], onClickHideField }, { labels: { key1: 'label1' } });
|
||||
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Hide log line')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Hide log line'));
|
||||
|
||||
expect(onClickHideField).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should not show an option to display the log line when displayed fields are not used', () => {
|
||||
setup({ displayedFields: undefined }, { labels: { key1: 'label1' } });
|
||||
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Show log line')).not.toBeInTheDocument();
|
||||
});
|
||||
it('should render filter controls when the callbacks are provided', () => {
|
||||
setup(
|
||||
{
|
||||
|
||||
@@ -3,9 +3,11 @@ import { PureComponent } from 'react';
|
||||
|
||||
import { CoreApp, DataFrame, DataFrameType, Field, LinkModel, LogRowModel } from '@grafana/data';
|
||||
import { PopoverContent, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { calculateLogsLabelStats, calculateStats } from '../utils';
|
||||
|
||||
import { LogDetailsBody } from './LogDetailsBody';
|
||||
import { LogDetailsRow } from './LogDetailsRow';
|
||||
import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles';
|
||||
import { getAllFields, createLogLineLinks } from './logParser';
|
||||
@@ -86,10 +88,28 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
<div className={styles.logDetailsContainer}>
|
||||
<table className={styles.logDetailsTable}>
|
||||
<tbody>
|
||||
{displayedFields && displayedFields.length > 0 && (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Fields">
|
||||
<Trans i18nKey="logs.log-details.log-line">Log line</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<LogDetailsBody
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
row={row}
|
||||
app={app}
|
||||
displayedFields={displayedFields}
|
||||
disableActions={false}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(labelsAvailable || fieldsAvailable) && (
|
||||
<tr>
|
||||
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Fields">
|
||||
Fields
|
||||
<Trans i18nKey="logs.log-details.fields">Fields</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -142,7 +162,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
{fieldsWithLinksAvailable && (
|
||||
<tr>
|
||||
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links">
|
||||
Links
|
||||
<Trans i18nKey="logs.log-details.links">Links</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -192,7 +212,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
{!fieldsAvailable && !labelsAvailable && !fieldsWithLinksAvailable && (
|
||||
<tr>
|
||||
<td colSpan={100} aria-label="No details">
|
||||
No details available
|
||||
<Trans i18nKey="logs.log-details.no-details">No details available</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { css } from '@emotion/css';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { CoreApp, GrafanaTheme2, LogRowModel } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { IconButton, Themeable2 } from '@grafana/ui';
|
||||
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
|
||||
export interface Props extends Themeable2 {
|
||||
app?: CoreApp;
|
||||
disableActions: boolean;
|
||||
displayedFields?: string[];
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
row: LogRowModel;
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
const getStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
buttonRow: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(0.5),
|
||||
marginLeft: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const LOG_LINE_BODY_FIELD_NAME = '___LOG_LINE_BODY___';
|
||||
|
||||
export const LogDetailsBody = (props: Props) => {
|
||||
const showField = () => {
|
||||
const { onClickShowField, row } = props;
|
||||
if (onClickShowField) {
|
||||
onClickShowField(LOG_LINE_BODY_FIELD_NAME);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_show_body_clicked', {
|
||||
datasourceType: row.datasourceType,
|
||||
logRowUid: row.uid,
|
||||
type: 'enable',
|
||||
app: props.app,
|
||||
});
|
||||
};
|
||||
|
||||
const hideField = () => {
|
||||
const { onClickHideField, row } = props;
|
||||
if (onClickHideField) {
|
||||
onClickHideField(LOG_LINE_BODY_FIELD_NAME);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_show_body_clicked', {
|
||||
datasourceType: row.datasourceType,
|
||||
logRowUid: row.uid,
|
||||
type: 'disable',
|
||||
app: props.app,
|
||||
});
|
||||
};
|
||||
|
||||
const { theme, displayedFields, disableActions, row } = props;
|
||||
const styles = getStyles(theme);
|
||||
const rowStyles = getLogRowStyles(theme);
|
||||
|
||||
const toggleFieldButton =
|
||||
displayedFields != null && displayedFields.includes(LOG_LINE_BODY_FIELD_NAME) ? (
|
||||
<IconButton variant="primary" tooltip="Hide log line" name="eye" onClick={hideField} />
|
||||
) : (
|
||||
<IconButton tooltip="Show log line" name="eye" onClick={showField} />
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={rowStyles.logDetailsValue}>
|
||||
<td className={rowStyles.logsDetailsIcon}>
|
||||
<div className={styles.buttonRow}>{!disableActions && displayedFields && toggleFieldButton}</div>
|
||||
</td>
|
||||
|
||||
<td className={rowStyles.logDetailsLabel} colSpan={100}>
|
||||
{row.entry}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogLabels, LogLabelsList } from './LogLabels';
|
||||
|
||||
describe('<LogLabels />', () => {
|
||||
@@ -38,8 +39,9 @@ describe('<LogLabels />', () => {
|
||||
|
||||
describe('<LogLabelsList />', () => {
|
||||
it('renders labels', () => {
|
||||
render(<LogLabelsList labels={['bar', '42']} />);
|
||||
render(<LogLabelsList labels={['bar', '42', LOG_LINE_BODY_FIELD_NAME]} />);
|
||||
expect(screen.queryByText('bar')).toBeInTheDocument();
|
||||
expect(screen.queryByText('42')).toBeInTheDocument();
|
||||
expect(screen.queryByText('log line')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { memo, forwardRef, useMemo } from 'react';
|
||||
import { GrafanaTheme2, Labels } from '@grafana/data';
|
||||
import { Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
|
||||
// Levels are already encoded in color, filename is a Loki-ism
|
||||
const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
|
||||
|
||||
@@ -59,7 +61,7 @@ export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => {
|
||||
<span className={cx([styles.logsLabels])}>
|
||||
{labels.map((label) => (
|
||||
<LogLabel key={label} styles={styles} tooltip={label}>
|
||||
{label}
|
||||
{label === LOG_LINE_BODY_FIELD_NAME ? 'log line' : label}
|
||||
</LogLabel>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { createTheme, LogLevel } from '@grafana/data';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogRowMessageDisplayedFields, Props } from './LogRowMessageDisplayedFields';
|
||||
import { createLogRow } from './__mocks__/logRow';
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
@@ -42,7 +43,14 @@ const setup = (propOverrides: Partial<Props> = {}, detectedFields = ['place', 'p
|
||||
describe('LogRowMessageDisplayedFields', () => {
|
||||
it('renders diplayed fields from a log row', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('Logs are wonderful')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Logs are wonderful/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/place=Earth/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/planet=Mars/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders diplayed fields and body from a log row', () => {
|
||||
setup({}, ['place', 'planet', LOG_LINE_BODY_FIELD_NAME]);
|
||||
expect(screen.queryByText(/Logs are wonderful/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/place=Earth/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/planet=Mars/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo, ReactNode, useMemo } from 'react';
|
||||
|
||||
import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogRowMenuCell } from './LogRowMenuCell';
|
||||
import { LogRowStyles } from './getLogRowStyles';
|
||||
import { getAllFields } from './logParser';
|
||||
@@ -45,21 +46,26 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => {
|
||||
let line = '';
|
||||
for (let i = 0; i < detectedFields.length; i++) {
|
||||
const parsedKey = detectedFields[i];
|
||||
|
||||
if (parsedKey === LOG_LINE_BODY_FIELD_NAME) {
|
||||
line += ` ${row.entry}`;
|
||||
}
|
||||
|
||||
const field = fields.find((field) => {
|
||||
const { keys } = field;
|
||||
return keys[0] === parsedKey;
|
||||
});
|
||||
|
||||
if (field) {
|
||||
if (field != null) {
|
||||
line += ` ${parsedKey}=${field.values}`;
|
||||
}
|
||||
|
||||
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {
|
||||
if (row.labels[parsedKey] != null && row.labels[parsedKey] != null) {
|
||||
line += ` ${parsedKey}=${row.labels[parsedKey]}`;
|
||||
}
|
||||
}
|
||||
return line.trimStart();
|
||||
}, [detectedFields, fields, row.labels]);
|
||||
}, [detectedFields, fields, row.entry, row.labels]);
|
||||
|
||||
const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]);
|
||||
|
||||
|
||||
@@ -1758,6 +1758,12 @@
|
||||
"infinite-scroll": {
|
||||
"older-logs": "Older logs"
|
||||
},
|
||||
"log-details": {
|
||||
"fields": "Fields",
|
||||
"links": "Links",
|
||||
"log-line": "Log line",
|
||||
"no-details": "No details available"
|
||||
},
|
||||
"log-row-message": {
|
||||
"ellipsis": "… ",
|
||||
"more": "more"
|
||||
|
||||
@@ -1758,6 +1758,12 @@
|
||||
"infinite-scroll": {
|
||||
"older-logs": "Øľđęř ľőģş"
|
||||
},
|
||||
"log-details": {
|
||||
"fields": "Fįęľđş",
|
||||
"links": "Ŀįʼnĸş",
|
||||
"log-line": "Ŀőģ ľįʼnę",
|
||||
"no-details": "Ńő đęŧäįľş äväįľäþľę"
|
||||
},
|
||||
"log-row-message": {
|
||||
"ellipsis": "… ",
|
||||
"more": "mőřę"
|
||||
|
||||
Reference in New Issue
Block a user