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:
Matias Chomicki
2024-12-12 17:44:06 +02:00
committed by GitHub
co-authored by Galen Kistler
parent 18c1c4cf95
commit 3fdf0ea1f4
10 changed files with 171 additions and 14 deletions
-5
View File
@@ -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]);
+6
View File
@@ -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őřę"