import { debounce } from 'lodash'; import { MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CoreApp, DataFrame, dateTimeFormat, Field, LinkModel, LogRowContextOptions, LogRowModel, LogsSortOrder, } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { checkLogsError, checkLogsSampled, escapeUnescapedString } from '../utils'; import { LogDetails } from './LogDetails'; import { LogLabels } from './LogLabels'; import { LogRowMessage } from './LogRowMessage'; import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields'; import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles'; export interface Props { row: LogRowModel; showDuplicates: boolean; showLabels: boolean; showTime: boolean; wrapLogMessage: boolean; prettifyLogMessage: boolean; timeZone: TimeZone; enableLogDetails: boolean; logsSortOrder?: LogsSortOrder | null; forceEscape?: boolean; app?: CoreApp; displayedFields?: string[]; getRows: () => LogRowModel[]; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; onContextClick?: () => void; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; showContextToggle?: (row: LogRowModel) => boolean; onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; onLogRowHover?: (row?: LogRowModel) => void; onOpenContext: (row: LogRowModel, onClose: () => void) => void; getRowContextQuery?: ( row: LogRowModel, options?: LogRowContextOptions, cacheFilters?: boolean ) => Promise; onPermalinkClick?: (row: LogRowModel) => Promise; styles: LogRowStyles; permalinkedRowId?: string; scrollIntoView?: (element: HTMLElement) => void; isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void; onUnpinLine?: (row: LogRowModel) => void; pinLineButtonTooltipTitle?: PopoverContent; pinned?: boolean; handleTextSelection?: (e: MouseEvent, row: LogRowModel) => boolean; logRowMenuIconsBefore?: ReactNode[]; logRowMenuIconsAfter?: ReactNode[]; } export const LogRow = ({ getRows, onClickFilterLabel, onClickFilterOutLabel, onClickShowField, onClickHideField, enableLogDetails, row, showDuplicates, showContextToggle, showLabels, showTime, displayedFields, wrapLogMessage, prettifyLogMessage, getFieldLinks, forceEscape, app, styles, getRowContextQuery, pinned, logRowMenuIconsBefore, logRowMenuIconsAfter, timeZone, permalinkedRowId, scrollIntoView, handleTextSelection, onLogRowHover, ...props }: Props) => { const [showingContext, setShowingContext] = useState(false); const [showDetails, setShowDetails] = useState(false); const [mouseIsOver, setMouseIsOver] = useState(false); const [permalinked, setPermalinked] = useState(false); const logLineRef = useRef(null); const theme = useTheme2(); const timestamp = useMemo( () => dateTimeFormat(row.timeEpochMs, { timeZone: timeZone, defaultWithMS: true, }), [row.timeEpochMs, timeZone] ); const levelStyles = useMemo(() => getLogLevelStyles(theme, row.logLevel), [row.logLevel, theme]); const processedRow = useMemo( () => row.hasUnescapedContent && forceEscape ? { ...row, entry: escapeUnescapedString(row.entry), raw: escapeUnescapedString(row.raw) } : row, [forceEscape, row] ); const errorMessage = checkLogsError(row); const hasError = errorMessage !== undefined; const sampleMessage = checkLogsSampled(row); const isSampled = sampleMessage !== undefined; useEffect(() => { if (permalinkedRowId !== row.uid) { setPermalinked(false); return; } if (!permalinked) { setPermalinked(true); return; } if (logLineRef.current && scrollIntoView) { // at this point this row is the permalinked row, so we need to scroll to it and highlight it if possible. scrollIntoView(logLineRef.current); reportInteraction('grafana_explore_logs_permalink_opened', { datasourceType: row.datasourceType ?? 'unknown', logRowUid: row.uid, }); setPermalinked(true); } }, [permalinked, permalinkedRowId, row.datasourceType, row.uid, scrollIntoView]); // we are debouncing the state change by 3 seconds to highlight the logline after the context closed. // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedContextClose = useCallback( debounce(() => { setShowingContext(false); }, 3000), [] ); const onOpenContext = useCallback( (row: LogRowModel) => { setShowingContext(true); props.onOpenContext(row, debouncedContextClose); }, [debouncedContextClose, props] ); const onRowClick = useCallback( (e: MouseEvent) => { if (handleTextSelection?.(e, row)) { // Event handled by the parent. return; } if (!enableLogDetails) { return; } setShowDetails((showDetails: boolean) => !showDetails); }, [enableLogDetails, handleTextSelection, row] ); const onMouseEnter = useCallback(() => { setMouseIsOver(true); if (onLogRowHover) { onLogRowHover(row); } }, [onLogRowHover, row]); const onMouseMove = useCallback( (e: MouseEvent) => { // No need to worry about text selection. if (!handleTextSelection) { return; } // The user is selecting text, so hide the log row menu so it doesn't interfere. if (document.getSelection()?.toString() && e.buttons > 0) { setMouseIsOver(false); } }, [handleTextSelection] ); const onMouseLeave = useCallback(() => { setMouseIsOver(false); }, []); return ( <> {showDuplicates && ( {processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null} )} {hasError && ( )} {isSampled && ( )} {enableLogDetails && ( )} {showTime && {timestamp}} {showLabels && processedRow.uniqueLabels && ( )} {displayedFields && displayedFields.length > 0 ? ( ) : ( )} {showDetails && ( )} ); };