Logs: Display log row menu cell on displayed fields (#71300)

* LogRowMenuCell: create component

* LogRowMessage: use new LogRowMenuCell component

* LogRowMessage: turn into functional component

* LogRowMenuCell: memoize component

* LogRowMessage: remove cx

* LogMessage: create component from function

* LogRowMessageDisplayedFields: turn into component

* LogRowMessageDisplayedFields: add LogRowMenuCell

* LogRowMessageDisplayedFields: rename prop and pass missing context prop

* LogRowMessageDisplayedFields: add unit test
This commit is contained in:
Matias Chomicki 2023-07-11 14:50:53 +02:00 committed by GitHub
parent 724e46a8a7
commit b8fbeb084a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 287 additions and 179 deletions

View File

@ -225,9 +225,16 @@ class UnThemedLogRow extends PureComponent<Props, State> {
{displayedFields && displayedFields.length > 0 ? (
<LogRowMessageDisplayedFields
row={processedRow}
showDetectedFields={displayedFields!}
showContextToggle={showContextToggle}
detectedFields={displayedFields}
getFieldLinks={getFieldLinks}
wrapLogMessage={wrapLogMessage}
onOpenContext={this.onOpenContext}
onPermalinkClick={this.props.onPermalinkClick}
styles={styles}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinned}
/>
) : (
<LogRowMessage

View File

@ -0,0 +1,122 @@
import React, { SyntheticEvent, useCallback } from 'react';
import { LogRowModel } from '@grafana/data';
import { ClipboardButton, IconButton } from '@grafana/ui';
import { LogRowStyles } from './getLogRowStyles';
interface Props {
logText: string;
row: LogRowModel;
showContextToggle?: (row?: LogRowModel) => boolean;
onOpenContext: (row: LogRowModel) => void;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinned?: boolean;
styles: LogRowStyles;
}
export const LogRowMenuCell = React.memo(
({
logText,
onOpenContext,
onPermalinkClick,
onPinLine,
onUnpinLine,
pinned,
row,
showContextToggle,
styles,
}: Props) => {
const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false;
const onLogRowClick = useCallback((e: SyntheticEvent) => {
e.stopPropagation();
}, []);
const onShowContextClick = useCallback(
(e: SyntheticEvent<HTMLElement, Event>) => {
e.stopPropagation();
onOpenContext(row);
},
[onOpenContext, row]
);
const getLogText = useCallback(() => logText, [logText]);
return (
<>
{pinned && (
// TODO: fix keyboard a11y
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span className={`log-row-menu log-row-menu-visible ${styles.rowMenu}`} onClick={onLogRowClick}>
<IconButton
className={styles.unPinButton}
size="md"
name="gf-pin"
onClick={() => onUnpinLine && onUnpinLine(row)}
tooltip="Unpin line"
tooltipPlacement="top"
aria-label="Unpin line"
/>
</span>
)}
{/* TODO: fix keyboard a11y */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<span className={`log-row-menu ${styles.rowMenu} ${styles.hidden}`} onClick={onLogRowClick}>
{shouldShowContextToggle && (
<IconButton
size="md"
name="gf-show-context"
onClick={onShowContextClick}
tooltip="Show context"
tooltipPlacement="top"
aria-label="Show context"
/>
)}
<ClipboardButton
className={styles.copyLogButton}
icon="copy"
variant="secondary"
fill="text"
size="md"
getText={getLogText}
tooltip="Copy to clipboard"
tooltipPlacement="top"
/>
{pinned && onUnpinLine && (
<IconButton
className={styles.unPinButton}
size="md"
name="gf-pin"
onClick={() => onUnpinLine && onUnpinLine(row)}
tooltip="Unpin line"
tooltipPlacement="top"
aria-label="Unpin line"
/>
)}
{!pinned && onPinLine && (
<IconButton
className={styles.unPinButton}
size="md"
name="gf-pin"
onClick={() => onPinLine && onPinLine(row)}
tooltip="Pin line"
tooltipPlacement="top"
aria-label="Pin line"
/>
)}
{onPermalinkClick && row.uid && (
<IconButton
tooltip="Copy shortlink"
aria-label="Copy shortlink"
tooltipPlacement="top"
size="md"
name="share-alt"
onClick={() => onPermalinkClick(row)}
/>
)}
</span>
</>
);
}
);
LogRowMenuCell.displayName = 'LogRowMenuCell';

View File

@ -1,12 +1,10 @@
import { cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import React, { useMemo } from 'react';
import Highlighter from 'react-highlight-words';
import { CoreApp, findHighlightChunksInText, LogRowModel } from '@grafana/data';
import { ClipboardButton, IconButton } from '@grafana/ui';
import { LogMessageAnsi } from './LogMessageAnsi';
import { LogRowMenuCell } from './LogRowMenuCell';
import { LogRowStyles } from './getLogRowStyles';
export const MAX_CHARACTERS = 100000;
@ -25,17 +23,19 @@ interface Props {
styles: LogRowStyles;
}
function renderLogMessage(
hasAnsi: boolean,
entry: string,
highlights: string[] | undefined,
highlightClassName: string
) {
interface LogMessageProps {
hasAnsi: boolean;
entry: string;
highlights: string[] | undefined;
styles: LogRowStyles;
}
const LogMessage = ({ hasAnsi, entry, highlights, styles }: LogMessageProps) => {
const needsHighlighter =
highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0 && entry.length < MAX_CHARACTERS;
const searchWords = highlights ?? [];
if (hasAnsi) {
const highlight = needsHighlighter ? { searchWords, highlightClassName } : undefined;
const highlight = needsHighlighter ? { searchWords, highlightClassName: styles.logsRowMatchHighLight } : undefined;
return <LogMessageAnsi value={entry} highlight={highlight} />;
} else if (needsHighlighter) {
return (
@ -43,15 +43,14 @@ function renderLogMessage(
textToHighlight={entry}
searchWords={searchWords}
findChunks={findHighlightChunksInText}
highlightClassName={highlightClassName}
highlightClassName={styles.logsRowMatchHighLight}
/>
);
} else {
return entry;
}
}
return <>{entry}</>;
};
const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): string => {
const restructureLog = (line: string, prettifyLogMessage: boolean): string => {
if (prettifyLogMessage) {
try {
return JSON.stringify(JSON.parse(line), undefined, 2);
@ -60,133 +59,51 @@ const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): s
}
}
return line;
};
export const LogRowMessage = React.memo((props: Props) => {
const {
row,
wrapLogMessage,
prettifyLogMessage,
showContextToggle,
styles,
onOpenContext,
onPermalinkClick,
onUnpinLine,
onPinLine,
pinned,
} = props;
const { hasAnsi, raw } = row;
const restructuredEntry = useMemo(() => restructureLog(raw, prettifyLogMessage), [raw, prettifyLogMessage]);
return (
<>
{
// When context is open, the position has to be NOT relative. // Setting the postion as inline-style to
// overwrite the more sepecific style definition from `styles.logsRowMessage`.
}
<td className={styles.logsRowMessage}>
<div className={wrapLogMessage ? styles.positionRelative : styles.horizontalScroll}>
<button className={`${styles.logLine} ${styles.positionRelative}`}>
<LogMessage hasAnsi={hasAnsi} entry={restructuredEntry} highlights={row.searchWords} styles={styles} />
</button>
</div>
</td>
<td className={`log-row-menu-cell ${styles.logRowMenuCell}`}>
<LogRowMenuCell
logText={restructuredEntry}
row={row}
showContextToggle={showContextToggle}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
onPinLine={onPinLine}
onUnpinLine={onUnpinLine}
pinned={pinned}
styles={styles}
/>
</td>
</>
);
});
export class LogRowMessage extends PureComponent<Props> {
onShowContextClick = (e: React.SyntheticEvent<HTMLElement, Event>) => {
const { onOpenContext } = this.props;
e.stopPropagation();
onOpenContext(this.props.row);
};
onLogRowClick = (e: React.SyntheticEvent) => {
e.stopPropagation();
};
getLogText = () => {
const { row, prettifyLogMessage } = this.props;
const { raw } = row;
return restructureLog(raw, prettifyLogMessage);
};
render() {
const {
row,
wrapLogMessage,
prettifyLogMessage,
showContextToggle,
styles,
onPermalinkClick,
onUnpinLine,
onPinLine,
pinned,
} = this.props;
const { hasAnsi, raw } = row;
const restructuredEntry = restructureLog(raw, prettifyLogMessage);
const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false;
return (
<>
{
// When context is open, the position has to be NOT relative. // Setting the postion as inline-style to
// overwrite the more sepecific style definition from `styles.logsRowMessage`.
}
<td className={styles.logsRowMessage}>
<div
className={cx(
{ [styles.positionRelative]: wrapLogMessage },
{ [styles.horizontalScroll]: !wrapLogMessage }
)}
>
<button className={cx(styles.logLine, styles.positionRelative)}>
{renderLogMessage(hasAnsi, restructuredEntry, row.searchWords, styles.logsRowMatchHighLight)}
</button>
</div>
</td>
<td className={cx('log-row-menu-cell', styles.logRowMenuCell)}>
{pinned && (
// TODO: fix keyboard a11y
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span className={cx('log-row-menu', 'log-row-menu-visible', styles.rowMenu)} onClick={this.onLogRowClick}>
<IconButton
className={styles.unPinButton}
size="md"
name="gf-pin"
onClick={() => onUnpinLine && onUnpinLine(row)}
tooltip="Unpin line"
tooltipPlacement="top"
aria-label="Unpin line"
/>
</span>
)}
{/* TODO: fix keyboard a11y */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<span className={cx('log-row-menu', styles.rowMenu, styles.hidden)} onClick={this.onLogRowClick}>
{shouldShowContextToggle && (
<IconButton
size="md"
name="gf-show-context"
onClick={this.onShowContextClick}
tooltip="Show context"
tooltipPlacement="top"
aria-label="Show context"
/>
)}
<ClipboardButton
className={styles.copyLogButton}
icon="copy"
variant="secondary"
fill="text"
size="md"
getText={this.getLogText}
tooltip="Copy to clipboard"
tooltipPlacement="top"
/>
{pinned && onUnpinLine && (
<IconButton
className={styles.unPinButton}
size="md"
name="gf-pin"
onClick={() => onUnpinLine && onUnpinLine(row)}
tooltip="Unpin line"
tooltipPlacement="top"
aria-label="Unpin line"
/>
)}
{!pinned && onPinLine && (
<IconButton
className={styles.unPinButton}
size="md"
name="gf-pin"
onClick={() => onPinLine && onPinLine(row)}
tooltip="Pin line"
tooltipPlacement="top"
aria-label="Pin line"
/>
)}
{onPermalinkClick && row.uid && (
<IconButton
tooltip="Copy shortlink"
aria-label="Copy shortlink"
tooltipPlacement="top"
size="md"
name="share-alt"
onClick={() => onPermalinkClick(row)}
/>
)}
</span>
</td>
</>
);
}
}
LogRowMessage.displayName = 'LogRowMessage';

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { createTheme, LogLevel } from '@grafana/data';
import { LogRowMessageDisplayedFields, Props } from './LogRowMessageDisplayedFields';
import { createLogRow } from './__mocks__/logRow';
import { getLogRowStyles } from './getLogRowStyles';
const setup = (propOverrides: Partial<Props> = {}, detectedFields = ['place', 'planet']) => {
const theme = createTheme();
const styles = getLogRowStyles(theme);
const labels = {
place: 'Earth',
planet: 'Mars',
};
const props: Props = {
wrapLogMessage: false,
row: createLogRow({ entry: 'Logs are wonderful', logLevel: LogLevel.error, timeEpochMs: 1546297200000, labels }),
onOpenContext: () => {},
styles,
detectedFields,
...propOverrides,
};
render(
<table>
<tbody>
<tr>
<LogRowMessageDisplayedFields {...props} />
</tr>
</tbody>
</table>
);
return props;
};
describe('LogRowMessageDisplayedFields', () => {
it('renders diplayed fields from a log row', () => {
setup();
expect(screen.queryByText('Logs are wonderful')).not.toBeInTheDocument();
expect(screen.getByText(/place=Earth/)).toBeInTheDocument();
expect(screen.getByText(/planet=Mars/)).toBeInTheDocument();
});
});

View File

@ -1,51 +1,67 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import React from 'react';
import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data';
import { withTheme2, Themeable2 } from '@grafana/ui';
import { LogRowMenuCell } from './LogRowMenuCell';
import { LogRowStyles } from './getLogRowStyles';
import { getAllFields } from './logParser';
export interface Props extends Themeable2 {
export interface Props {
row: LogRowModel;
showDetectedFields: string[];
detectedFields: string[];
wrapLogMessage: boolean;
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
styles: LogRowStyles;
showContextToggle?: (row?: LogRowModel) => boolean;
onOpenContext: (row: LogRowModel) => void;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinned?: boolean;
}
class UnThemedLogRowMessageDisplayedFields extends PureComponent<Props> {
render() {
const { row, showDetectedFields, getFieldLinks, wrapLogMessage } = this.props;
const fields = getAllFields(row, getFieldLinks);
const wrapClassName = wrapLogMessage
? ''
: css`
white-space: nowrap;
`;
// only single key/value rows are filterable, so we only need the first field key for filtering
const line = showDetectedFields
.map((parsedKey) => {
const field = fields.find((field) => {
const { keys } = field;
return keys[0] === parsedKey;
});
export const LogRowMessageDisplayedFields = React.memo((props: Props) => {
const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, ...rest } = props;
const fields = getAllFields(row, getFieldLinks);
const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap;
// only single key/value rows are filterable, so we only need the first field key for filtering
const line = detectedFields
.map((parsedKey) => {
const field = fields.find((field) => {
const { keys } = field;
return keys[0] === parsedKey;
});
if (field !== undefined && field !== null) {
return `${parsedKey}=${field.values}`;
}
if (field !== undefined && field !== null) {
return `${parsedKey}=${field.values}`;
}
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {
return `${parsedKey}=${row.labels[parsedKey]}`;
}
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {
return `${parsedKey}=${row.labels[parsedKey]}`;
}
return null;
})
.filter((s) => s !== null)
.join(' ');
return null;
})
.filter((s) => s !== null)
.join(' ');
return <td className={wrapClassName}>{line}</td>;
}
}
return (
<>
<td className={styles.logsRowMessage}>
<div className={wrapClassName}>{line}</div>
</td>
<td className={`log-row-menu-cell ${styles.logRowMenuCell}`}>
<LogRowMenuCell logText={line} row={row} styles={styles} {...rest} />
</td>
</>
);
});
const displayedFieldsStyles = {
noWrap: css`
white-space: nowrap;
`,
};
export const LogRowMessageDisplayedFields = withTheme2(UnThemedLogRowMessageDisplayedFields);
LogRowMessageDisplayedFields.displayName = 'LogRowMessageDisplayedFields';