mirror of https://github.com/grafana/grafana.git
				
				
				
			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:
		
							parent
							
								
									724e46a8a7
								
							
						
					
					
						commit
						b8fbeb084a
					
				|  | @ -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 | ||||
|  |  | |||
|  | @ -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'; | ||||
|  | @ -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,41 +59,23 @@ const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): s | |||
|     } | ||||
|   } | ||||
|   return line; | ||||
| }); | ||||
| }; | ||||
| 
 | ||||
| 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() { | ||||
| export const LogRowMessage = React.memo((props: Props) => { | ||||
|   const { | ||||
|     row, | ||||
|     wrapLogMessage, | ||||
|     prettifyLogMessage, | ||||
|     showContextToggle, | ||||
|     styles, | ||||
|     onOpenContext, | ||||
|     onPermalinkClick, | ||||
|     onUnpinLine, | ||||
|     onPinLine, | ||||
|     pinned, | ||||
|     } = this.props; | ||||
|   } = props; | ||||
|   const { hasAnsi, raw } = row; | ||||
|     const restructuredEntry = restructureLog(raw, prettifyLogMessage); | ||||
|     const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; | ||||
| 
 | ||||
|   const restructuredEntry = useMemo(() => restructureLog(raw, prettifyLogMessage), [raw, prettifyLogMessage]); | ||||
|   return ( | ||||
|     <> | ||||
|       { | ||||
|  | @ -102,91 +83,27 @@ export class LogRowMessage extends PureComponent<Props> { | |||
|         // 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)} | ||||
|         <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={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" | ||||
|       <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} | ||||
|         /> | ||||
|             </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'; | ||||
|  |  | |||
|  | @ -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(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,29 +1,32 @@ | |||
| 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; | ||||
| export const LogRowMessageDisplayedFields = React.memo((props: Props) => { | ||||
|   const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, ...rest } = props; | ||||
|   const fields = getAllFields(row, getFieldLinks); | ||||
|     const wrapClassName = wrapLogMessage | ||||
|       ? '' | ||||
|       : css` | ||||
|           white-space: nowrap; | ||||
|         `;
 | ||||
|   const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap; | ||||
|   // only single key/value rows are filterable, so we only need the first field key for filtering
 | ||||
|     const line = showDetectedFields | ||||
|   const line = detectedFields | ||||
|     .map((parsedKey) => { | ||||
|       const field = fields.find((field) => { | ||||
|         const { keys } = field; | ||||
|  | @ -43,9 +46,22 @@ class UnThemedLogRowMessageDisplayedFields extends PureComponent<Props> { | |||
|     .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'; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue