diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 1a099f950e9..3ab836aaa18 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -8,13 +8,14 @@ import { createLogLine } from '../mocks/logRow'; import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine'; import { LogListFontSize } from './LogList'; -import { LogListContextProvider } from './LogListContext'; +import { LogListContextProvider, LogListContext } from './LogListContext'; import { LogListSearchContext } from './LogListSearchContext'; -import { defaultProps } from './__mocks__/LogListContext'; +import { defaultProps, defaultValue } from './__mocks__/LogListContext'; import { LogListModel } from './processing'; import { LogLineVirtualization } from './virtualization'; jest.mock('./LogListContext'); +jest.mock('../LogDetails'); const theme = createTheme(); const virtualization = new LogLineVirtualization(theme, 'default'); @@ -424,6 +425,40 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { expect(screen.getByText('un')).toBeInTheDocument(); }); }); + + describe('Inline details', () => { + test('Details are not rendered if details mode is not inline', () => { + render( + + + + ); + expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); + }); + + test('Details are rendered if details mode is inline', () => { + render( + + + + ); + expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument(); + }); + }); }); describe('getGridTemplateColumns', () => { diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index ec3062ee3b5..9f96fb3a3b7 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -10,6 +10,7 @@ import { Button, Icon, Tooltip } from '@grafana/ui'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LogMessageAnsi } from '../LogMessageAnsi'; +import { InlineLogLineDetails } from './LogLineDetails'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext'; import { useLogListSearchContext } from './LogListSearchContext'; @@ -50,7 +51,7 @@ export const LogLine = ({ wrapLogMessage, }: Props) => { return ( -
+
{ const { detailsDisplayed, + detailsMode, dedupStrategy, enableLogDetails, fontSize, @@ -235,6 +237,7 @@ const LogLineComponent = memo(
)} + {detailsMode === 'inline' && detailsShown && } ); } diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 212da50add7..dbd2990ea6b 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -17,9 +17,11 @@ export interface Props { onResize(): void; } +export type LogLineDetailsMode = 'inline' | 'sidebar'; + export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => { - const { detailsWidth, logOptionsStorageKey, setDetailsWidth, showDetails } = useLogListContext(); - const styles = useStyles2(getStyles); + const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext(); + const styles = useStyles2(getStyles, 'sidebar'); const dragStyles = useStyles2(getDragStyles); const containerRef = useRef(null); @@ -54,20 +56,51 @@ export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize >
- +
); }; -const getStyles = (theme: GrafanaTheme2) => ({ +export interface InlineLogLineDetailsProps { + logs: LogListModel[]; +} + +export const InlineLogLineDetails = ({ logs }: InlineLogLineDetailsProps) => { + const { showDetails } = useLogListContext(); + const styles = useStyles2(getStyles, 'inline'); + + if (!showDetails.length) { + return null; + } + + return ( +
+
+
+ +
+
+
+ ); +}; + +export const LOG_LINE_DETAILS_HEIGHT = 35; + +const getStyles = (theme: GrafanaTheme2, mode: LogLineDetailsMode) => ({ + inlineWrapper: css({ + gridColumn: '1 / -1', + height: `${LOG_LINE_DETAILS_HEIGHT}vh`, + paddingBottom: theme.spacing(0.5), + marginRight: 1, + }), container: css({ overflow: 'auto', height: '100%', boxShadow: theme.shadows.z1, border: `1px solid ${theme.colors.border.medium}`, - borderRight: 'none', + borderRight: mode === 'sidebar' ? 'none' : undefined, }), scrollContainer: css({ overflow: 'auto', diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index fefd7004879..0024860a93a 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -18,12 +18,11 @@ import { LogListModel } from './processing'; interface LogLineDetailsComponentProps { log: LogListModel; - logOptionsStorageKey?: string; logs: LogListModel[]; } -export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => { - const { displayedFields, setDisplayedFields } = useLogListContext(); +export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentProps) => { + const { displayedFields, logOptionsStorageKey, setDisplayedFields } = useLogListContext(); const [search, setSearch] = useState(''); const inputRef = useRef(''); const styles = useStyles2(getStyles); diff --git a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx index 2c76d814cb6..140a7f86fca 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx @@ -1,13 +1,14 @@ import { css } from '@emotion/css'; import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react'; -import { colorManipulator, GrafanaTheme2, LogRowModel } from '@grafana/data'; +import { colorManipulator, GrafanaTheme2, LogRowModel, store } from '@grafana/data'; import { t } from '@grafana/i18n'; import { IconButton, Input, useStyles2 } from '@grafana/ui'; import { copyText, handleOpenLogsContextClick } from '../../utils'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { LogLineDetailsMode } from './LogLineDetails'; import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -20,18 +21,22 @@ interface Props { export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { const { closeDetails, + detailsMode, displayedFields, getRowContextQuery, + logOptionsStorageKey, logSupportsContext, + setDetailsMode, onClickHideField, onClickShowField, onOpenContext, onPermalinkClick, onPinLine, onUnpinLine, + wrapLogMessage, } = useLogListContext(); const pinned = useLogIsPinned(log); - const styles = useStyles2(getStyles); + const styles = useStyles2(getStyles, detailsMode, wrapLogMessage); const containerRef = useRef(null); const inputRef = useRef(null); @@ -66,6 +71,15 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { const showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0; const logLineDisplayed = displayedFields.includes(LOG_LINE_BODY_FIELD_NAME); + const toggleDetailsMode = useCallback(() => { + const newMode = detailsMode === 'inline' ? 'sidebar' : 'inline'; + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.detailsMode`, newMode); + } + + setDetailsMode(newMode); + }, [detailsMode, logOptionsStorageKey, setDetailsMode]); + const toggleLogLine = useCallback(() => { if (logLineDisplayed) { onClickHideField?.(LOG_LINE_BODY_FIELD_NAME); @@ -121,65 +135,76 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { variant={logLineDisplayed ? 'primary' : undefined} /> )} - - {onPermalinkClick && log.rowId !== undefined && log.uid && ( +
- )} - {pinned && onUnpinLine && ( - - )} - {!pinned && onPinLine && ( + {onPermalinkClick && log.rowId !== undefined && log.uid && ( + + )} + {pinned && onUnpinLine && ( + + )} + {!pinned && onPinLine && ( + + )} + {shouldlogSupportsContext && ( + + )} - )} - {shouldlogSupportsContext && ( - )} - +
); }; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2, mode: LogLineDetailsMode, wrapLogMessage: boolean) => ({ container: css({ overflow: 'auto', height: '100%', @@ -192,7 +217,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ alignItems: 'center', background: theme.colors.background.canvas, display: 'flex', - flexDirection: 'row', + flexDirection: !wrapLogMessage && mode === 'inline' ? 'row-reverse' : 'row', gap: theme.spacing(0.75), zIndex: theme.zIndex.navbarFixed, height: theme.spacing(5.5), @@ -201,6 +226,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ position: 'sticky', top: 0, }), + icons: css({ + display: 'flex', + gap: theme.spacing(0.75), + }), copyLogButton: css({ padding: 0, height: theme.spacing(4), diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 17eda97f4f6..fde0ea89dec 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder, store } from '@grafana/data'; import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils'; import { createLogRow } from '../mocks/logRow'; @@ -75,6 +75,55 @@ describe('LogList', () => { }); test('Supports showing log details', async () => { + jest.spyOn(store, 'get').mockImplementation((option: string) => { + if (option === 'storage-key.detailsMode') { + return 'sidebar'; + } + return undefined; + }); + const onClickFilterLabel = jest.fn(); + const onClickFilterOutLabel = jest.fn(); + const onClickShowField = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText('log message 1')); + await screen.findByText('Fields'); + + expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); + expect(screen.getByText('value of the label')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + expect(onClickShowField).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Close log details')); + + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + }); + + test('Supports showing inline log details', async () => { + jest.spyOn(store, 'get').mockImplementation((option: string) => { + if (option === 'storage-key.detailsMode') { + return 'inline'; + } + return undefined; + }); const onClickFilterLabel = jest.fn(); const onClickFilterOutLabel = jest.fn(); const onClickShowField = jest.fn(); @@ -86,6 +135,7 @@ describe('LogList', () => { onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} onClickShowField={onClickShowField} + logOptionsStorageKey="storage-key" /> ); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 489741fd59b..a5115f5e447 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -26,7 +26,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { InfiniteScroll } from './InfiniteScroll'; import { getGridTemplateColumns } from './LogLine'; -import { LogLineDetails } from './LogLineDetails'; +import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; import { LogListControls } from './LogListControls'; @@ -41,6 +41,7 @@ export interface Props { app: CoreApp; containerElement: HTMLDivElement; dedupStrategy: LogsDedupStrategy; + detailsMode?: LogLineDetailsMode; displayedFields: string[]; enableLogDetails: boolean; eventBus?: EventBus; @@ -105,11 +106,12 @@ export const LogList = ({ app, displayedFields, containerElement, + logOptionsStorageKey, + detailsMode = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? 'sidebar') : 'sidebar', dedupStrategy, enableLogDetails, eventBus, filterLevels, - logOptionsStorageKey, fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default', getFieldLinks, getRowContextQuery, @@ -152,6 +154,7 @@ export const LogList = ({ app={app} containerElement={containerElement} dedupStrategy={dedupStrategy} + detailsMode={detailsMode} displayedFields={displayedFields} enableLogDetails={enableLogDetails} filterLevels={filterLevels} @@ -222,6 +225,7 @@ const LogListComponent = ({ app, displayedFields, dedupStrategy, + detailsMode, filterLevels, fontSize, forceEscape, @@ -456,9 +460,11 @@ const LogListComponent = ({ height={listHeight} itemCount={itemCount} itemSize={getLogLineSize.bind(null, virtualization, filteredLogs, widthContainer, displayedFields, { + detailsMode, hasLogsWithErrors, hasSampledLogs, showDuplicates: dedupStrategy !== LogsDedupStrategy.none, + showDetails, showTime, wrap: wrapLogMessage, })} @@ -476,7 +482,7 @@ const LogListComponent = ({ )} - {showDetails.length > 0 && ( + {detailsMode === 'sidebar' && showDetails.length > 0 && ( { closeDetails: () => void; detailsDisplayed: (log: LogListModel) => boolean; + detailsMode: LogLineDetailsMode; detailsWidth: number; downloadLogs: (format: DownloadFormat) => void; enableLogDetails: boolean; @@ -43,6 +45,7 @@ export interface LogListContextData extends Omit void; + setDetailsMode: (mode: LogLineDetailsMode) => void; setDetailsWidth: (width: number) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; setFontSize: (size: LogListFontSize) => void; @@ -64,6 +67,7 @@ export const LogListContext = createContext({ closeDetails: () => {}, dedupStrategy: LogsDedupStrategy.none, detailsDisplayed: () => false, + detailsMode: 'sidebar', detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, @@ -72,6 +76,7 @@ export const LogListContext = createContext({ fontSize: 'default', hasUnescapedContent: false, setDedupStrategy: () => {}, + setDetailsMode: () => {}, setDetailsWidth: () => {}, setFilterLevels: () => {}, setFontSize: () => {}, @@ -132,6 +137,7 @@ export interface Props { children?: ReactNode; // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx containerElement?: HTMLDivElement; + detailsMode?: LogLineDetailsMode; dedupStrategy: LogsDedupStrategy; displayedFields: string[]; enableLogDetails: boolean; @@ -176,6 +182,7 @@ export const LogListContextProvider = ({ children, containerElement, enableLogDetails, + detailsMode: detailsModeProp, dedupStrategy, displayedFields, filterLevels, @@ -230,6 +237,7 @@ export const LogListContextProvider = ({ }); const [showDetails, setShowDetails] = useState([]); const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey)); + const [detailsMode, setDetailsMode] = useState(detailsModeProp ?? 'sidebar'); useEffect(() => { // Props are updated in the context only of the panel is being externally controlled. @@ -480,6 +488,7 @@ export const LogListContextProvider = ({ closeDetails, detailsDisplayed, dedupStrategy: logListState.dedupStrategy, + detailsMode, detailsWidth, displayedFields, downloadLogs, @@ -511,6 +520,7 @@ export const LogListContextProvider = ({ pinnedLogs: logListState.pinnedLogs, prettifyJSON: logListState.prettifyJSON, setDedupStrategy, + setDetailsMode, setDetailsWidth, setDisplayedFields, setFilterLevels, @@ -563,7 +573,9 @@ function getDetailsWidth( const defaultWidth = containerElement.clientWidth * 0.4; const detailsWidth = currentWidth || - (logOptionsStorageKey ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10) : defaultWidth); + (logOptionsStorageKey + ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`) ?? defaultWidth, 10) + : defaultWidth); const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index f0cb9be3ce6..1a991f79ecf 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext } from 'react'; import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils'; +import { LogLineDetailsMode } from '../LogLineDetails'; import { LogListContextData, Props } from '../LogListContext'; import { LogListModel } from '../processing'; @@ -37,6 +38,10 @@ export const LogListContext = createContext({ syntaxHighlighting: true, toggleDetails: () => {}, wrapLogMessage: false, + detailsMode: 'sidebar', + setDetailsMode: function (mode: LogLineDetailsMode): void { + throw new Error('Function not implemented.'); + }, }); export const useLogListContextData = (key: keyof LogListContextData) => { @@ -59,6 +64,8 @@ export const useLogIsPermalinked = (log: LogListModel) => { }; export const defaultValue: LogListContextData = { + detailsMode: 'sidebar', + setDetailsMode: jest.fn(), setDedupStrategy: jest.fn(), setFilterLevels: jest.fn(), setFontSize: jest.fn(), diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts index 75534d4e170..479b7b4a746 100644 --- a/public/app/features/logs/components/panel/virtualization.test.ts +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -3,14 +3,17 @@ import { createTheme, LogsSortOrder } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; +import { LOG_LINE_DETAILS_HEIGHT } from './LogLineDetails'; import { LogListModel, PreProcessOptions } from './processing'; -import { LogLineVirtualization, getLogLineSize, DisplayOptions } from './virtualization'; +import { LogLineVirtualization, getLogLineSize, DisplayOptions, FIELD_GAP_MULTIPLIER } from './virtualization'; describe('Virtualization', () => { let log: LogListModel, container: HTMLDivElement; let virtualization = new LogLineVirtualization(createTheme(), 'default'); + const GAP = virtualization.getGridSize() * FIELD_GAP_MULTIPLIER; + const DETAILS_HEIGHT = window.innerHeight * (LOG_LINE_DETAILS_HEIGHT / 100) + GAP / 2; const PADDING_BOTTOM = 6; const LINE_HEIGHT = virtualization.getLineHeight(); const SINGLE_LINE_HEIGHT = LINE_HEIGHT + PADDING_BOTTOM; @@ -21,8 +24,10 @@ describe('Virtualization', () => { let TWO_LINES_OF_CHARACTERS: number; const defaultOptions: DisplayOptions = { + detailsMode: 'sidebar', wrap: false, showTime: false, + showDetails: [], showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false, @@ -50,6 +55,18 @@ describe('Virtualization', () => { expect(size).toBe(SINGLE_LINE_HEIGHT); }); + test('Returns the a single line plus inline details if the display mode is unwrapped', () => { + const size = getLogLineSize( + virtualization, + [log], + container, + [], + { ...defaultOptions, showTime: true, showDetails: [log], detailsMode: 'inline' }, + 0 + ); + expect(size).toBe(SINGLE_LINE_HEIGHT + DETAILS_HEIGHT); + }); + test('Returns the a single line if the line is not loaded yet', () => { const logs = [log]; const size = getLogLineSize( @@ -78,6 +95,21 @@ describe('Virtualization', () => { expect(size).toBe((virtualization.getTruncationLineCount() + 1) * LINE_HEIGHT); }); + test('Returns the size of a truncated long line with inline details', () => { + // Very small container + log.collapsed = true; + jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); + const size = getLogLineSize( + virtualization, + [log], + container, + [], + { ...defaultOptions, wrap: true, showTime: true, showDetails: [log], detailsMode: 'inline' }, + 0 + ); + expect(size).toBe((virtualization.getTruncationLineCount() + 1) * LINE_HEIGHT + DETAILS_HEIGHT); + }); + test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => { const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true, showTime }, 0); expect(size).toBe(SINGLE_LINE_HEIGHT); @@ -115,6 +147,24 @@ describe('Virtualization', () => { expect(size).toBe(THREE_LINES_HEIGHT); }); + test('Measures a multi-line log line with level, controls, displayed time, and inline details', () => { + log = createLogLine( + { labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }, + preProcessOptions + ); + + const size = getLogLineSize( + virtualization, + [log], + container, + [], + { ...defaultOptions, wrap: true, showTime: true, showDetails: [log], detailsMode: 'inline' }, + 0 + ); + // Two lines for the log and one extra for level and time + expect(size).toBe(THREE_LINES_HEIGHT + DETAILS_HEIGHT); + }); + test('Measures a multi-line log line with displayed fields', () => { log = createLogLine( { diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 2f9b6b6773a..f9e7676a5f5 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -2,6 +2,7 @@ import ansicolor from 'ansicolor'; import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; +import { LOG_LINE_DETAILS_HEIGHT, LogLineDetailsMode } from './LogLineDetails'; import { LogListFontSize } from './LogList'; import { LogListModel } from './processing'; @@ -232,8 +233,10 @@ export class LogLineVirtualization { } export interface DisplayOptions { + detailsMode: LogLineDetailsMode; hasLogsWithErrors?: boolean; hasSampledLogs?: boolean; + showDetails: LogListModel[]; showDuplicates: boolean; showTime: boolean; wrap: boolean; @@ -244,20 +247,25 @@ export function getLogLineSize( logs: LogListModel[], container: HTMLDivElement | null, displayedFields: string[], - { hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, + { detailsMode, hasLogsWithErrors, hasSampledLogs, showDuplicates, showDetails, showTime, wrap }: DisplayOptions, index: number ) { if (!container) { return 0; } + const gap = virtualization.getGridSize() * FIELD_GAP_MULTIPLIER; + const detailsHeight = + detailsMode === 'inline' && showDetails.findIndex((log) => log.uid === logs[index].uid) >= 0 + ? window.innerHeight * (LOG_LINE_DETAILS_HEIGHT / 100) + gap / 2 + : 0; // !logs[index] means the line is not yet loaded by infinite scrolling if (!wrap || !logs[index]) { - return virtualization.getLineHeight() + virtualization.getPaddingBottom(); + return virtualization.getLineHeight() + virtualization.getPaddingBottom() + detailsHeight; } // If a long line is collapsed, we show the line count + an extra line for the expand/collapse control logs[index].updateCollapsedState(displayedFields, container); if (logs[index].collapsed) { - return (virtualization.getTruncationLineCount() + 1) * virtualization.getLineHeight(); + return (virtualization.getTruncationLineCount() + 1) * virtualization.getLineHeight() + detailsHeight; } const storedSize = virtualization.retrieveLogLineSize(logs[index].uid, container); @@ -266,7 +274,6 @@ export function getLogLineSize( } let textToMeasure = ''; - const gap = virtualization.getGridSize() * FIELD_GAP_MULTIPLIER; const iconsGap = virtualization.getGridSize() * 0.5; let optionsWidth = 0; if (showDuplicates) { @@ -296,7 +303,9 @@ export function getLogLineSize( const { height } = virtualization.measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); // When the log is collapsed, add an extra line for the expand/collapse control - return logs[index].collapsed === false ? height + virtualization.getLineHeight() : height; + return logs[index].collapsed === false + ? height + virtualization.getLineHeight() + detailsHeight + : height + detailsHeight; } export interface LogFieldDimension { @@ -313,14 +322,28 @@ export function hasUnderOrOverflow( if (collapsed !== undefined && calculatedHeight) { calculatedHeight -= virtualization.getLineHeight(); } + const inlineDetails = element.parentElement + ? Array.from(element.parentElement.children).filter((element) => + element.classList.contains('log-line-inline-details') + ) + : undefined; + const detailsHeight = inlineDetails?.length ? inlineDetails[0].clientHeight : 0; + + // Line overflows container + let measuredHeight = element.scrollHeight + detailsHeight; const height = calculatedHeight ?? element.clientHeight; - if (element.scrollHeight > height) { - return collapsed !== undefined ? element.scrollHeight + virtualization.getLineHeight() : element.scrollHeight; + if (measuredHeight > height) { + return collapsed !== undefined ? measuredHeight + virtualization.getLineHeight() : measuredHeight; } + + // Line is smaller than container const child = element.children[1]; - if (child instanceof HTMLDivElement && child.clientHeight < height) { - return collapsed !== undefined ? child.clientHeight + virtualization.getLineHeight() : child.clientHeight; + measuredHeight = child.clientHeight + detailsHeight; + if (child instanceof HTMLDivElement && measuredHeight < height) { + return collapsed !== undefined ? measuredHeight + virtualization.getLineHeight() : measuredHeight; } + + // No overflow or undermeasurement return null; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8cfdb522034..d80bd556f31 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8739,6 +8739,7 @@ }, "fields-section": "Fields", "hide-log-line": "Hide log line", + "inline-mode": "Display inline", "links-section": "Links", "log-line-field": "Log line", "log-line-section": "Log line", @@ -8751,6 +8752,7 @@ "search-placeholder": "Search field names and values", "show-context": "Show context", "show-log-line": "Show log line", + "sidebar-mode": "Anchor to the right", "unpin-line": "Unpin log" }, "log-line-menu": {