Log Line Details: Add inline mode for the Details component (#107718)

* LogLineContext: add sidebar mode

* LogLine: initial support for inline details

* LogLine: finally figure out field overflow

* LogLineDetails: support inline details in unwrapped mode

* LogLineDetails: fix borders in inline mode

* LogLineDetails: support unwrapped inline details

* LogLineDetails: fix inline unwrapped mode

* LogLineDetails: debug and translations

* Fix types

* LogLine: update test

* LogLineDetails: slightly taller

* LogListContext: fix NaN default width

* Remove console log

* LogList: add inline details test

* virtualization: update test

* Fix imports
pull/107900/head
Matias Chomicki 2 weeks ago committed by GitHub
parent 5d92f3eee5
commit 2e58ce7980
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 39
      public/app/features/logs/components/panel/LogLine.test.tsx
  2. 5
      public/app/features/logs/components/panel/LogLine.tsx
  3. 43
      public/app/features/logs/components/panel/LogLineDetails.tsx
  4. 5
      public/app/features/logs/components/panel/LogLineDetailsComponent.tsx
  5. 37
      public/app/features/logs/components/panel/LogLineDetailsHeader.tsx
  6. 52
      public/app/features/logs/components/panel/LogList.test.tsx
  7. 15
      public/app/features/logs/components/panel/LogList.tsx
  8. 14
      public/app/features/logs/components/panel/LogListContext.tsx
  9. 7
      public/app/features/logs/components/panel/__mocks__/LogListContext.tsx
  10. 52
      public/app/features/logs/components/panel/virtualization.test.ts
  11. 41
      public/app/features/logs/components/panel/virtualization.ts
  12. 2
      public/locales/en-US/grafana.json

@ -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(
<LogListContext.Provider
value={{
...defaultValue,
showDetails: [log],
detailsMode: 'sidebar',
detailsDisplayed: jest.fn().mockReturnValue(true),
}}
>
<LogLine {...defaultProps} />
</LogListContext.Provider>
);
expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument();
});
test('Details are rendered if details mode is inline', () => {
render(
<LogListContext.Provider
value={{
...defaultValue,
showDetails: [log],
detailsMode: 'inline',
detailsDisplayed: jest.fn().mockReturnValue(true),
}}
>
<LogLine {...defaultProps} />
</LogListContext.Provider>
);
expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument();
});
});
});
describe('getGridTemplateColumns', () => {

@ -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 (
<div style={style}>
<div style={wrapLogMessage ? style : { ...style, width: 'max-content', minWidth: '100%' }}>
<LogLineComponent
displayedFields={displayedFields}
height={style.height}
@ -88,6 +89,7 @@ const LogLineComponent = memo(
}: LogLineComponentProps) => {
const {
detailsDisplayed,
detailsMode,
dedupStrategy,
enableLogDetails,
fontSize,
@ -235,6 +237,7 @@ const LogLineComponent = memo(
</Button>
</div>
)}
{detailsMode === 'inline' && detailsShown && <InlineLogLineDetails logs={[]} />}
</>
);
}

@ -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<HTMLDivElement | null>(null);
@ -54,20 +56,51 @@ export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize
>
<div className={styles.container} ref={containerRef}>
<div className={styles.scrollContainer}>
<LogLineDetailsComponent log={showDetails[0]} logOptionsStorageKey={logOptionsStorageKey} logs={logs} />
<LogLineDetailsComponent log={showDetails[0]} logs={logs} />
</div>
</div>
</Resizable>
);
};
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 (
<div className={`${styles.inlineWrapper} log-line-inline-details`}>
<div className={styles.container}>
<div className={styles.scrollContainer}>
<LogLineDetailsComponent log={showDetails[0]} logs={logs} />
</div>
</div>
</div>
);
};
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',

@ -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);

@ -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<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(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,6 +135,7 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
variant={logLineDisplayed ? 'primary' : undefined}
/>
)}
<div className={styles.icons}>
<IconButton
tooltip={t('logs.log-line-details.copy-to-clipboard', 'Copy to clipboard')}
tooltipPlacement="top"
@ -170,16 +185,26 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
tabIndex={0}
/>
)}
<IconButton
name={detailsMode === 'inline' ? 'columns' : 'gf-layout-simple'}
tooltip={
detailsMode === 'inline'
? t('logs.log-line-details.sidebar-mode', 'Anchor to the right')
: t('logs.log-line-details.inline-mode', 'Display inline')
}
onClick={toggleDetailsMode}
/>
<IconButton
name="times"
aria-label={t('logs.log-line-details.close', 'Close log details')}
onClick={closeDetails}
/>
</div>
</div>
);
};
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),

@ -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(
<LogList
{...defaultProps}
enableLogDetails={true}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
logOptionsStorageKey="storage-key"
/>
);
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"
/>
);

@ -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 = ({
)}
</InfiniteScroll>
</div>
{showDetails.length > 0 && (
{detailsMode === 'sidebar' && showDetails.length > 0 && (
<LogLineDetails
containerElement={containerElement}
focusLogLine={focusLogLine}
@ -501,6 +507,9 @@ function getStyles(
'& .unwrapped-log-line': {
display: 'grid',
gridTemplateColumns: getGridTemplateColumns(columns, displayedFields),
'& .field': {
overflow: 'hidden',
},
},
}),
logListContainer: css({

@ -26,6 +26,7 @@ import { PopoverContent } from '@grafana/ui';
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
import { LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListFontSize } from './LogList';
import { LogListModel } from './processing';
@ -34,6 +35,7 @@ import { LOG_LIST_MIN_WIDTH } from './virtualization';
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
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<Props, 'containerElement' | 'lo
hasUnescapedContent?: boolean;
logLineMenuCustomItems?: LogLineMenuCustomItem[];
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => 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<LogListContextData>({
closeDetails: () => {},
dedupStrategy: LogsDedupStrategy.none,
detailsDisplayed: () => false,
detailsMode: 'sidebar',
detailsWidth: 0,
displayedFields: [],
downloadLogs: () => {},
@ -72,6 +76,7 @@ export const LogListContext = createContext<LogListContextData>({
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<LogListModel[]>([]);
const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey));
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(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;

@ -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<LogListContextData>({
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(),

@ -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(
{

@ -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;
}

@ -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": {

Loading…
Cancel
Save