import { css } from '@emotion/css'; import { debounce } from 'lodash'; import { Grammar } from 'prismjs'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, MouseEvent } from 'react'; import { Align, VariableSizeList } from 'react-window'; import { AbsoluteTimeRange, CoreApp, DataFrame, EventBus, EventBusSrv, GrafanaTheme2, LogLevel, LogRowModel, LogsDedupStrategy, LogsMetaItem, LogsSortOrder, store, TimeRange, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { ConfirmModal, Icon, PopoverContent, useStyles2, useTheme2 } from '@grafana/ui'; import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { InfiniteScroll } from './InfiniteScroll'; import { getGridTemplateColumns } from './LogLine'; import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; import { LogListControls } from './LogListControls'; import { LOG_LIST_SEARCH_HEIGHT, LogListSearch } from './LogListSearch'; import { LogListSearchContextProvider, useLogListSearchContext } from './LogListSearchContext'; import { preProcessLogs, LogListModel } from './processing'; import { useKeyBindings } from './useKeyBindings'; import { usePopoverMenu } from './usePopoverMenu'; import { LogLineVirtualization, getLogLineSize, LogFieldDimension, ScrollToLogsEvent } from './virtualization'; export interface Props { app: CoreApp; containerElement: HTMLDivElement; dedupStrategy: LogsDedupStrategy; detailsMode?: LogLineDetailsMode; displayedFields: string[]; enableLogDetails: boolean; eventBus?: EventBus; filterLevels?: LogLevel[]; fontSize?: LogListFontSize; getFieldLinks?: GetFieldLinksFn; getRowContextQuery?: GetRowContextQueryFn; grammar?: Grammar; initialScrollPosition?: 'top' | 'bottom'; isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise; loading?: boolean; loadMore?: (range: AbsoluteTimeRange) => void; logLineMenuCustomItems?: LogLineMenuCustomItem[]; logOptionsStorageKey?: string; logs: LogRowModel[]; logsMeta?: LogsMetaItem[]; logSupportsContext?: (row: LogRowModel) => boolean; noInteractions?: boolean; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void; onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; onLogLineHover?: (row?: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; onPinLine?: (row: LogRowModel) => void; onOpenContext?: (row: LogRowModel, onClose: () => void) => void; onUnpinLine?: (row: LogRowModel) => void; permalinkedLogId?: string; pinLineButtonTooltipTitle?: PopoverContent; pinnedLogs?: string[]; setDisplayedFields?: (displayedFields: string[]) => void; showControls: boolean; showTime: boolean; sortOrder: LogsSortOrder; timeRange: TimeRange; timeZone: string; syntaxHighlighting?: boolean; wrapLogMessage: boolean; } export type LogListFontSize = 'default' | 'small'; export type LogListControlOptions = LogListState; type LogListComponentProps = Omit< Props, | 'app' | 'dedupStrategy' | 'displayedFields' | 'enableLogDetails' | 'logOptionsStorageKey' | 'permalinkedLogId' | 'showTime' | 'sortOrder' | 'syntaxHighlighting' | 'wrapLogMessage' >; export const LogList = ({ app, displayedFields, containerElement, logOptionsStorageKey, detailsMode = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? 'sidebar') : 'sidebar', dedupStrategy, enableLogDetails, eventBus, filterLevels, fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default', getFieldLinks, getRowContextQuery, grammar, initialScrollPosition = 'top', isLabelFilterActive, loading, loadMore, logLineMenuCustomItems, logs, logsMeta, logSupportsContext, noInteractions, onClickFilterLabel, onClickFilterOutLabel, onClickFilterString, onClickFilterOutString, onClickShowField, onClickHideField, onLogOptionsChange, onLogLineHover, onPermalinkClick, onPinLine, onOpenContext, onUnpinLine, permalinkedLogId, pinLineButtonTooltipTitle, pinnedLogs, setDisplayedFields, showControls, showTime, sortOrder, syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true, timeRange, timeZone, wrapLogMessage, }: Props) => { return ( ); }; const LogListComponent = ({ containerElement, eventBus = new EventBusSrv(), getFieldLinks, grammar, initialScrollPosition = 'top', loading, loadMore, logs, showControls, timeRange, timeZone, }: LogListComponentProps) => { const { app, displayedFields, dedupStrategy, detailsMode, filterLevels, fontSize, forceEscape, hasLogsWithErrors, hasSampledLogs, onClickFilterString, onClickFilterOutString, permalinkedLogId, showDetails, showTime, sortOrder, toggleDetails, wrapLogMessage, } = useLogListContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState( app === CoreApp.Explore ? Math.max(window.innerHeight * 0.8, containerElement.clientHeight) : containerElement.clientHeight ); const theme = useTheme2(); const listRef = useRef(null); const widthRef = useRef(containerElement.clientWidth); const wrapperRef = useRef(null); const scrollRef = useRef(null); const virtualization = useMemo(() => new LogLineVirtualization(theme, fontSize), [theme, fontSize]); const dimensions = useMemo( () => (wrapLogMessage ? [] : virtualization.calculateFieldDimensions(processedLogs, displayedFields)), [displayedFields, processedLogs, virtualization, wrapLogMessage] ); const styles = useStyles2(getStyles, dimensions, displayedFields, { showTime }); const widthContainer = wrapperRef.current ?? containerElement; const { closePopoverMenu, handleTextSelection, onDisableCancel, onDisableConfirm, onDisablePopoverMenu, popoverState, showDisablePopoverOptions, } = usePopoverMenu(wrapperRef.current); useKeyBindings(); const { filterLogs, matchingUids, searchVisible } = useLogListSearchContext(); const debouncedResetAfterIndex = useMemo(() => { return debounce((index: number) => { listRef.current?.resetAfterIndex(index); overflowIndexRef.current = Infinity; }, 25); }, []); const debouncedScrollToItem = useMemo(() => { return debounce((index: number, align?: Align) => { listRef.current?.scrollToItem(index, align); }, 250); }, []); useEffect(() => { const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => handleScrollToEvent(e, logs.length, listRef.current) ); return () => subscription.unsubscribe(); }, [eventBus, logs.length]); useEffect(() => { if (loading) { return; } setProcessedLogs( preProcessLogs( logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization, wrapLogMessage }, grammar ) ); virtualization.resetLogLineSizes(); listRef.current?.resetAfterIndex(0); }, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone, virtualization, wrapLogMessage]); useEffect(() => { listRef.current?.resetAfterIndex(0); }, [wrapLogMessage, showDetails, displayedFields, dedupStrategy]); useEffect(() => { const handleResize = debounce(() => { setListHeight( (app === CoreApp.Explore ? Math.max(window.innerHeight * 0.8, containerElement.clientHeight) : containerElement.clientHeight) - (searchVisible ? LOG_LIST_SEARCH_HEIGHT : 0) ); }, 50); window.addEventListener('resize', handleResize); handleResize(); return () => { window.removeEventListener('resize', handleResize); }; }, [app, containerElement.clientHeight, searchVisible]); useLayoutEffect(() => { if (widthRef.current === widthContainer.clientWidth) { return; } widthRef.current = widthContainer.clientWidth; debouncedResetAfterIndex(0); }); const overflowIndexRef = useRef(Infinity); const handleOverflow = useCallback( (index: number, id: string, height?: number) => { if (height !== undefined) { virtualization.storeLogLineSize(id, widthContainer, height); } if (index === overflowIndexRef.current) { return; } overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current; debouncedResetAfterIndex(overflowIndexRef.current); }, [debouncedResetAfterIndex, virtualization, widthContainer] ); const handleScrollPosition = useCallback(() => { if (permalinkedLogId) { const index = processedLogs.findIndex((log) => log.uid === permalinkedLogId); if (index >= 0) { listRef.current?.scrollToItem(index, 'start'); return; } } listRef.current?.scrollToItem(initialScrollPosition === 'top' ? 0 : processedLogs.length - 1); }, [initialScrollPosition, permalinkedLogId, processedLogs]); if (!containerElement || listHeight == null) { // Wait for container to be rendered return null; } const handleLogLineClick = useCallback( (e: MouseEvent, log: LogListModel) => { if (handleTextSelection(e, log)) { // Event handled by the parent. return; } toggleDetails(log); }, [handleTextSelection, toggleDetails] ); const handleLogDetailsResize = useCallback(() => { debouncedResetAfterIndex(0); }, [debouncedResetAfterIndex]); const levelFilteredLogs = useMemo( () => filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)), [filterLevels, processedLogs] ); const filteredLogs = useMemo( () => matchingUids && filterLogs ? levelFilteredLogs.filter((log) => matchingUids.includes(log.uid)) : levelFilteredLogs, [filterLogs, levelFilteredLogs, matchingUids] ); const focusLogLine = useCallback( (log: LogListModel) => { const index = filteredLogs.indexOf(log); if (index >= 0) { debouncedScrollToItem(index, 'start'); } }, [debouncedScrollToItem, filteredLogs] ); return (
{popoverState.selection && popoverState.selectedRow && ( )} {showDisablePopoverOptions && ( You are about to disable the logs filter menu. To re-enable it, select text in a log line while holding the alt key.
alt+select to enable again
} confirmText={t('logs.log-rows.disable-popover.confirm', 'Confirm')} icon="exclamation-triangle" onConfirm={onDisableConfirm} onDismiss={onDisableCancel} /> )} {({ getItemKey, itemCount, onItemsRendered, Renderer }) => ( {Renderer} )}
{detailsMode === 'sidebar' && showDetails.length > 0 && ( )} {showControls && }
); }; function getStyles( theme: GrafanaTheme2, dimensions: LogFieldDimension[], displayedFields: string[], { showTime }: { showTime: boolean } ) { const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0); return { logList: css({ '& .unwrapped-log-line': { display: 'grid', gridTemplateColumns: getGridTemplateColumns(columns, displayedFields), '& .field': { overflow: 'hidden', }, }, }), logListContainer: css({ display: 'flex', // Minimum width to prevent rendering issues and a sausage-like logs panel. minWidth: theme.spacing(35), }), logListWrapper: css({ position: 'relative', width: '100%', }), shortcut: css({ display: 'inline-flex', alignItems: 'center', gap: theme.spacing(1), color: theme.colors.text.secondary, opacity: 0.7, fontSize: theme.typography.bodySmall.fontSize, marginTop: theme.spacing(1), }), }; } function handleScrollToEvent(event: ScrollToLogsEvent, logsCount: number, list: VariableSizeList | null) { if (event.payload.scrollTo === 'top') { list?.scrollTo(0); } else { list?.scrollToItem(logsCount - 1); } }