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. 127
      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 { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine';
import { LogListFontSize } from './LogList'; import { LogListFontSize } from './LogList';
import { LogListContextProvider } from './LogListContext'; import { LogListContextProvider, LogListContext } from './LogListContext';
import { LogListSearchContext } from './LogListSearchContext'; import { LogListSearchContext } from './LogListSearchContext';
import { defaultProps } from './__mocks__/LogListContext'; import { defaultProps, defaultValue } from './__mocks__/LogListContext';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
import { LogLineVirtualization } from './virtualization'; import { LogLineVirtualization } from './virtualization';
jest.mock('./LogListContext'); jest.mock('./LogListContext');
jest.mock('../LogDetails');
const theme = createTheme(); const theme = createTheme();
const virtualization = new LogLineVirtualization(theme, 'default'); const virtualization = new LogLineVirtualization(theme, 'default');
@ -424,6 +425,40 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
expect(screen.getByText('un')).toBeInTheDocument(); 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', () => { describe('getGridTemplateColumns', () => {

@ -10,6 +10,7 @@ import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogMessageAnsi } from '../LogMessageAnsi'; import { LogMessageAnsi } from '../LogMessageAnsi';
import { InlineLogLineDetails } from './LogLineDetails';
import { LogLineMenu } from './LogLineMenu'; import { LogLineMenu } from './LogLineMenu';
import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext'; import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext';
import { useLogListSearchContext } from './LogListSearchContext'; import { useLogListSearchContext } from './LogListSearchContext';
@ -50,7 +51,7 @@ export const LogLine = ({
wrapLogMessage, wrapLogMessage,
}: Props) => { }: Props) => {
return ( return (
<div style={style}> <div style={wrapLogMessage ? style : { ...style, width: 'max-content', minWidth: '100%' }}>
<LogLineComponent <LogLineComponent
displayedFields={displayedFields} displayedFields={displayedFields}
height={style.height} height={style.height}
@ -88,6 +89,7 @@ const LogLineComponent = memo(
}: LogLineComponentProps) => { }: LogLineComponentProps) => {
const { const {
detailsDisplayed, detailsDisplayed,
detailsMode,
dedupStrategy, dedupStrategy,
enableLogDetails, enableLogDetails,
fontSize, fontSize,
@ -235,6 +237,7 @@ const LogLineComponent = memo(
</Button> </Button>
</div> </div>
)} )}
{detailsMode === 'inline' && detailsShown && <InlineLogLineDetails logs={[]} />}
</> </>
); );
} }

@ -17,9 +17,11 @@ export interface Props {
onResize(): void; onResize(): void;
} }
export type LogLineDetailsMode = 'inline' | 'sidebar';
export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => { export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => {
const { detailsWidth, logOptionsStorageKey, setDetailsWidth, showDetails } = useLogListContext(); const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles, 'sidebar');
const dragStyles = useStyles2(getDragStyles); const dragStyles = useStyles2(getDragStyles);
const containerRef = useRef<HTMLDivElement | null>(null); 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.container} ref={containerRef}>
<div className={styles.scrollContainer}> <div className={styles.scrollContainer}>
<LogLineDetailsComponent log={showDetails[0]} logOptionsStorageKey={logOptionsStorageKey} logs={logs} /> <LogLineDetailsComponent log={showDetails[0]} logs={logs} />
</div> </div>
</div> </div>
</Resizable> </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({ container: css({
overflow: 'auto', overflow: 'auto',
height: '100%', height: '100%',
boxShadow: theme.shadows.z1, boxShadow: theme.shadows.z1,
border: `1px solid ${theme.colors.border.medium}`, border: `1px solid ${theme.colors.border.medium}`,
borderRight: 'none', borderRight: mode === 'sidebar' ? 'none' : undefined,
}), }),
scrollContainer: css({ scrollContainer: css({
overflow: 'auto', overflow: 'auto',

@ -18,12 +18,11 @@ import { LogListModel } from './processing';
interface LogLineDetailsComponentProps { interface LogLineDetailsComponentProps {
log: LogListModel; log: LogListModel;
logOptionsStorageKey?: string;
logs: LogListModel[]; logs: LogListModel[];
} }
export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => { export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentProps) => {
const { displayedFields, setDisplayedFields } = useLogListContext(); const { displayedFields, logOptionsStorageKey, setDisplayedFields } = useLogListContext();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const inputRef = useRef(''); const inputRef = useRef('');
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);

@ -1,13 +1,14 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react'; 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 { t } from '@grafana/i18n';
import { IconButton, Input, useStyles2 } from '@grafana/ui'; import { IconButton, Input, useStyles2 } from '@grafana/ui';
import { copyText, handleOpenLogsContextClick } from '../../utils'; import { copyText, handleOpenLogsContextClick } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLineDetailsMode } from './LogLineDetails';
import { useLogIsPinned, useLogListContext } from './LogListContext'; import { useLogIsPinned, useLogListContext } from './LogListContext';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
@ -20,18 +21,22 @@ interface Props {
export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
const { const {
closeDetails, closeDetails,
detailsMode,
displayedFields, displayedFields,
getRowContextQuery, getRowContextQuery,
logOptionsStorageKey,
logSupportsContext, logSupportsContext,
setDetailsMode,
onClickHideField, onClickHideField,
onClickShowField, onClickShowField,
onOpenContext, onOpenContext,
onPermalinkClick, onPermalinkClick,
onPinLine, onPinLine,
onUnpinLine, onUnpinLine,
wrapLogMessage,
} = useLogListContext(); } = useLogListContext();
const pinned = useLogIsPinned(log); const pinned = useLogIsPinned(log);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles, detailsMode, wrapLogMessage);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | 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 showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0;
const logLineDisplayed = displayedFields.includes(LOG_LINE_BODY_FIELD_NAME); 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(() => { const toggleLogLine = useCallback(() => {
if (logLineDisplayed) { if (logLineDisplayed) {
onClickHideField?.(LOG_LINE_BODY_FIELD_NAME); onClickHideField?.(LOG_LINE_BODY_FIELD_NAME);
@ -121,65 +135,76 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
variant={logLineDisplayed ? 'primary' : undefined} variant={logLineDisplayed ? 'primary' : undefined}
/> />
)} )}
<IconButton <div className={styles.icons}>
tooltip={t('logs.log-line-details.copy-to-clipboard', 'Copy to clipboard')}
tooltipPlacement="top"
size="md"
name="copy"
onClick={copyLogLine}
tabIndex={0}
/>
{onPermalinkClick && log.rowId !== undefined && log.uid && (
<IconButton <IconButton
tooltip={t('logs.log-line-details.copy-shortlink', 'Copy shortlink')} tooltip={t('logs.log-line-details.copy-to-clipboard', 'Copy to clipboard')}
tooltipPlacement="top" tooltipPlacement="top"
size="md" size="md"
name="share-alt" name="copy"
onClick={copyLinkToLogLine} onClick={copyLogLine}
tabIndex={0}
/>
)}
{pinned && onUnpinLine && (
<IconButton
size="md"
name="gf-pin"
onClick={togglePinning}
tooltip={t('logs.log-line-details.unpin-line', 'Unpin log')}
tooltipPlacement="top"
tabIndex={0} tabIndex={0}
variant="primary"
/> />
)} {onPermalinkClick && log.rowId !== undefined && log.uid && (
{!pinned && onPinLine && ( <IconButton
tooltip={t('logs.log-line-details.copy-shortlink', 'Copy shortlink')}
tooltipPlacement="top"
size="md"
name="share-alt"
onClick={copyLinkToLogLine}
tabIndex={0}
/>
)}
{pinned && onUnpinLine && (
<IconButton
size="md"
name="gf-pin"
onClick={togglePinning}
tooltip={t('logs.log-line-details.unpin-line', 'Unpin log')}
tooltipPlacement="top"
tabIndex={0}
variant="primary"
/>
)}
{!pinned && onPinLine && (
<IconButton
size="md"
name="gf-pin"
onClick={togglePinning}
tooltip={t('logs.log-line-details.pin-line', 'Pin log')}
tooltipPlacement="top"
tabIndex={0}
/>
)}
{shouldlogSupportsContext && (
<IconButton
size="md"
name="gf-show-context"
onClick={showContext}
tooltip={t('logs.log-line-details.show-context', 'Show context')}
tooltipPlacement="top"
tabIndex={0}
/>
)}
<IconButton <IconButton
size="md" name={detailsMode === 'inline' ? 'columns' : 'gf-layout-simple'}
name="gf-pin" tooltip={
onClick={togglePinning} detailsMode === 'inline'
tooltip={t('logs.log-line-details.pin-line', 'Pin log')} ? t('logs.log-line-details.sidebar-mode', 'Anchor to the right')
tooltipPlacement="top" : t('logs.log-line-details.inline-mode', 'Display inline')
tabIndex={0} }
onClick={toggleDetailsMode}
/> />
)}
{shouldlogSupportsContext && (
<IconButton <IconButton
size="md" name="times"
name="gf-show-context" aria-label={t('logs.log-line-details.close', 'Close log details')}
onClick={showContext} onClick={closeDetails}
tooltip={t('logs.log-line-details.show-context', 'Show context')}
tooltipPlacement="top"
tabIndex={0}
/> />
)} </div>
<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({ container: css({
overflow: 'auto', overflow: 'auto',
height: '100%', height: '100%',
@ -192,7 +217,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
alignItems: 'center', alignItems: 'center',
background: theme.colors.background.canvas, background: theme.colors.background.canvas,
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: !wrapLogMessage && mode === 'inline' ? 'row-reverse' : 'row',
gap: theme.spacing(0.75), gap: theme.spacing(0.75),
zIndex: theme.zIndex.navbarFixed, zIndex: theme.zIndex.navbarFixed,
height: theme.spacing(5.5), height: theme.spacing(5.5),
@ -201,6 +226,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
position: 'sticky', position: 'sticky',
top: 0, top: 0,
}), }),
icons: css({
display: 'flex',
gap: theme.spacing(0.75),
}),
copyLogButton: css({ copyLogButton: css({
padding: 0, padding: 0,
height: theme.spacing(4), height: theme.spacing(4),

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; 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 { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { createLogRow } from '../mocks/logRow'; import { createLogRow } from '../mocks/logRow';
@ -75,6 +75,55 @@ describe('LogList', () => {
}); });
test('Supports showing log details', async () => { 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 onClickFilterLabel = jest.fn();
const onClickFilterOutLabel = jest.fn(); const onClickFilterOutLabel = jest.fn();
const onClickShowField = jest.fn(); const onClickShowField = jest.fn();
@ -86,6 +135,7 @@ describe('LogList', () => {
onClickFilterLabel={onClickFilterLabel} onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField} onClickShowField={onClickShowField}
logOptionsStorageKey="storage-key"
/> />
); );

@ -26,7 +26,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { InfiniteScroll } from './InfiniteScroll'; import { InfiniteScroll } from './InfiniteScroll';
import { getGridTemplateColumns } from './LogLine'; import { getGridTemplateColumns } from './LogLine';
import { LogLineDetails } from './LogLineDetails'; import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
import { LogListControls } from './LogListControls'; import { LogListControls } from './LogListControls';
@ -41,6 +41,7 @@ export interface Props {
app: CoreApp; app: CoreApp;
containerElement: HTMLDivElement; containerElement: HTMLDivElement;
dedupStrategy: LogsDedupStrategy; dedupStrategy: LogsDedupStrategy;
detailsMode?: LogLineDetailsMode;
displayedFields: string[]; displayedFields: string[];
enableLogDetails: boolean; enableLogDetails: boolean;
eventBus?: EventBus; eventBus?: EventBus;
@ -105,11 +106,12 @@ export const LogList = ({
app, app,
displayedFields, displayedFields,
containerElement, containerElement,
logOptionsStorageKey,
detailsMode = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? 'sidebar') : 'sidebar',
dedupStrategy, dedupStrategy,
enableLogDetails, enableLogDetails,
eventBus, eventBus,
filterLevels, filterLevels,
logOptionsStorageKey,
fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default', fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default',
getFieldLinks, getFieldLinks,
getRowContextQuery, getRowContextQuery,
@ -152,6 +154,7 @@ export const LogList = ({
app={app} app={app}
containerElement={containerElement} containerElement={containerElement}
dedupStrategy={dedupStrategy} dedupStrategy={dedupStrategy}
detailsMode={detailsMode}
displayedFields={displayedFields} displayedFields={displayedFields}
enableLogDetails={enableLogDetails} enableLogDetails={enableLogDetails}
filterLevels={filterLevels} filterLevels={filterLevels}
@ -222,6 +225,7 @@ const LogListComponent = ({
app, app,
displayedFields, displayedFields,
dedupStrategy, dedupStrategy,
detailsMode,
filterLevels, filterLevels,
fontSize, fontSize,
forceEscape, forceEscape,
@ -456,9 +460,11 @@ const LogListComponent = ({
height={listHeight} height={listHeight}
itemCount={itemCount} itemCount={itemCount}
itemSize={getLogLineSize.bind(null, virtualization, filteredLogs, widthContainer, displayedFields, { itemSize={getLogLineSize.bind(null, virtualization, filteredLogs, widthContainer, displayedFields, {
detailsMode,
hasLogsWithErrors, hasLogsWithErrors,
hasSampledLogs, hasSampledLogs,
showDuplicates: dedupStrategy !== LogsDedupStrategy.none, showDuplicates: dedupStrategy !== LogsDedupStrategy.none,
showDetails,
showTime, showTime,
wrap: wrapLogMessage, wrap: wrapLogMessage,
})} })}
@ -476,7 +482,7 @@ const LogListComponent = ({
)} )}
</InfiniteScroll> </InfiniteScroll>
</div> </div>
{showDetails.length > 0 && ( {detailsMode === 'sidebar' && showDetails.length > 0 && (
<LogLineDetails <LogLineDetails
containerElement={containerElement} containerElement={containerElement}
focusLogLine={focusLogLine} focusLogLine={focusLogLine}
@ -501,6 +507,9 @@ function getStyles(
'& .unwrapped-log-line': { '& .unwrapped-log-line': {
display: 'grid', display: 'grid',
gridTemplateColumns: getGridTemplateColumns(columns, displayedFields), gridTemplateColumns: getGridTemplateColumns(columns, displayedFields),
'& .field': {
overflow: 'hidden',
},
}, },
}), }),
logListContainer: css({ logListContainer: css({

@ -26,6 +26,7 @@ import { PopoverContent } from '@grafana/ui';
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils'; import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
import { LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListFontSize } from './LogList'; import { LogListFontSize } from './LogList';
import { LogListModel } from './processing'; 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'> { export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
closeDetails: () => void; closeDetails: () => void;
detailsDisplayed: (log: LogListModel) => boolean; detailsDisplayed: (log: LogListModel) => boolean;
detailsMode: LogLineDetailsMode;
detailsWidth: number; detailsWidth: number;
downloadLogs: (format: DownloadFormat) => void; downloadLogs: (format: DownloadFormat) => void;
enableLogDetails: boolean; enableLogDetails: boolean;
@ -43,6 +45,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
hasUnescapedContent?: boolean; hasUnescapedContent?: boolean;
logLineMenuCustomItems?: LogLineMenuCustomItem[]; logLineMenuCustomItems?: LogLineMenuCustomItem[];
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
setDetailsMode: (mode: LogLineDetailsMode) => void;
setDetailsWidth: (width: number) => void; setDetailsWidth: (width: number) => void;
setFilterLevels: (filterLevels: LogLevel[]) => void; setFilterLevels: (filterLevels: LogLevel[]) => void;
setFontSize: (size: LogListFontSize) => void; setFontSize: (size: LogListFontSize) => void;
@ -64,6 +67,7 @@ export const LogListContext = createContext<LogListContextData>({
closeDetails: () => {}, closeDetails: () => {},
dedupStrategy: LogsDedupStrategy.none, dedupStrategy: LogsDedupStrategy.none,
detailsDisplayed: () => false, detailsDisplayed: () => false,
detailsMode: 'sidebar',
detailsWidth: 0, detailsWidth: 0,
displayedFields: [], displayedFields: [],
downloadLogs: () => {}, downloadLogs: () => {},
@ -72,6 +76,7 @@ export const LogListContext = createContext<LogListContextData>({
fontSize: 'default', fontSize: 'default',
hasUnescapedContent: false, hasUnescapedContent: false,
setDedupStrategy: () => {}, setDedupStrategy: () => {},
setDetailsMode: () => {},
setDetailsWidth: () => {}, setDetailsWidth: () => {},
setFilterLevels: () => {}, setFilterLevels: () => {},
setFontSize: () => {}, setFontSize: () => {},
@ -132,6 +137,7 @@ export interface Props {
children?: ReactNode; children?: ReactNode;
// Only ControlledLogRows can send an undefined containerElement. See LogList.tsx // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
containerElement?: HTMLDivElement; containerElement?: HTMLDivElement;
detailsMode?: LogLineDetailsMode;
dedupStrategy: LogsDedupStrategy; dedupStrategy: LogsDedupStrategy;
displayedFields: string[]; displayedFields: string[];
enableLogDetails: boolean; enableLogDetails: boolean;
@ -176,6 +182,7 @@ export const LogListContextProvider = ({
children, children,
containerElement, containerElement,
enableLogDetails, enableLogDetails,
detailsMode: detailsModeProp,
dedupStrategy, dedupStrategy,
displayedFields, displayedFields,
filterLevels, filterLevels,
@ -230,6 +237,7 @@ export const LogListContextProvider = ({
}); });
const [showDetails, setShowDetails] = useState<LogListModel[]>([]); const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey)); const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey));
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(detailsModeProp ?? 'sidebar');
useEffect(() => { useEffect(() => {
// Props are updated in the context only of the panel is being externally controlled. // Props are updated in the context only of the panel is being externally controlled.
@ -480,6 +488,7 @@ export const LogListContextProvider = ({
closeDetails, closeDetails,
detailsDisplayed, detailsDisplayed,
dedupStrategy: logListState.dedupStrategy, dedupStrategy: logListState.dedupStrategy,
detailsMode,
detailsWidth, detailsWidth,
displayedFields, displayedFields,
downloadLogs, downloadLogs,
@ -511,6 +520,7 @@ export const LogListContextProvider = ({
pinnedLogs: logListState.pinnedLogs, pinnedLogs: logListState.pinnedLogs,
prettifyJSON: logListState.prettifyJSON, prettifyJSON: logListState.prettifyJSON,
setDedupStrategy, setDedupStrategy,
setDetailsMode,
setDetailsWidth, setDetailsWidth,
setDisplayedFields, setDisplayedFields,
setFilterLevels, setFilterLevels,
@ -563,7 +573,9 @@ function getDetailsWidth(
const defaultWidth = containerElement.clientWidth * 0.4; const defaultWidth = containerElement.clientWidth * 0.4;
const detailsWidth = const detailsWidth =
currentWidth || 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; 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 { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils'; import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils';
import { LogLineDetailsMode } from '../LogLineDetails';
import { LogListContextData, Props } from '../LogListContext'; import { LogListContextData, Props } from '../LogListContext';
import { LogListModel } from '../processing'; import { LogListModel } from '../processing';
@ -37,6 +38,10 @@ export const LogListContext = createContext<LogListContextData>({
syntaxHighlighting: true, syntaxHighlighting: true,
toggleDetails: () => {}, toggleDetails: () => {},
wrapLogMessage: false, wrapLogMessage: false,
detailsMode: 'sidebar',
setDetailsMode: function (mode: LogLineDetailsMode): void {
throw new Error('Function not implemented.');
},
}); });
export const useLogListContextData = (key: keyof LogListContextData) => { export const useLogListContextData = (key: keyof LogListContextData) => {
@ -59,6 +64,8 @@ export const useLogIsPermalinked = (log: LogListModel) => {
}; };
export const defaultValue: LogListContextData = { export const defaultValue: LogListContextData = {
detailsMode: 'sidebar',
setDetailsMode: jest.fn(),
setDedupStrategy: jest.fn(), setDedupStrategy: jest.fn(),
setFilterLevels: jest.fn(), setFilterLevels: jest.fn(),
setFontSize: jest.fn(), setFontSize: jest.fn(),

@ -3,14 +3,17 @@ import { createTheme, LogsSortOrder } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../mocks/logRow'; import { createLogLine } from '../mocks/logRow';
import { LOG_LINE_DETAILS_HEIGHT } from './LogLineDetails';
import { LogListModel, PreProcessOptions } from './processing'; import { LogListModel, PreProcessOptions } from './processing';
import { LogLineVirtualization, getLogLineSize, DisplayOptions } from './virtualization'; import { LogLineVirtualization, getLogLineSize, DisplayOptions, FIELD_GAP_MULTIPLIER } from './virtualization';
describe('Virtualization', () => { describe('Virtualization', () => {
let log: LogListModel, container: HTMLDivElement; let log: LogListModel, container: HTMLDivElement;
let virtualization = new LogLineVirtualization(createTheme(), 'default'); 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 PADDING_BOTTOM = 6;
const LINE_HEIGHT = virtualization.getLineHeight(); const LINE_HEIGHT = virtualization.getLineHeight();
const SINGLE_LINE_HEIGHT = LINE_HEIGHT + PADDING_BOTTOM; const SINGLE_LINE_HEIGHT = LINE_HEIGHT + PADDING_BOTTOM;
@ -21,8 +24,10 @@ describe('Virtualization', () => {
let TWO_LINES_OF_CHARACTERS: number; let TWO_LINES_OF_CHARACTERS: number;
const defaultOptions: DisplayOptions = { const defaultOptions: DisplayOptions = {
detailsMode: 'sidebar',
wrap: false, wrap: false,
showTime: false, showTime: false,
showDetails: [],
showDuplicates: false, showDuplicates: false,
hasLogsWithErrors: false, hasLogsWithErrors: false,
hasSampledLogs: false, hasSampledLogs: false,
@ -50,6 +55,18 @@ describe('Virtualization', () => {
expect(size).toBe(SINGLE_LINE_HEIGHT); 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', () => { test('Returns the a single line if the line is not loaded yet', () => {
const logs = [log]; const logs = [log];
const size = getLogLineSize( const size = getLogLineSize(
@ -78,6 +95,21 @@ describe('Virtualization', () => {
expect(size).toBe((virtualization.getTruncationLineCount() + 1) * LINE_HEIGHT); 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) => { 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); const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true, showTime }, 0);
expect(size).toBe(SINGLE_LINE_HEIGHT); expect(size).toBe(SINGLE_LINE_HEIGHT);
@ -115,6 +147,24 @@ describe('Virtualization', () => {
expect(size).toBe(THREE_LINES_HEIGHT); 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', () => { test('Measures a multi-line log line with displayed fields', () => {
log = createLogLine( log = createLogLine(
{ {

@ -2,6 +2,7 @@ import ansicolor from 'ansicolor';
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
import { LOG_LINE_DETAILS_HEIGHT, LogLineDetailsMode } from './LogLineDetails';
import { LogListFontSize } from './LogList'; import { LogListFontSize } from './LogList';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
@ -232,8 +233,10 @@ export class LogLineVirtualization {
} }
export interface DisplayOptions { export interface DisplayOptions {
detailsMode: LogLineDetailsMode;
hasLogsWithErrors?: boolean; hasLogsWithErrors?: boolean;
hasSampledLogs?: boolean; hasSampledLogs?: boolean;
showDetails: LogListModel[];
showDuplicates: boolean; showDuplicates: boolean;
showTime: boolean; showTime: boolean;
wrap: boolean; wrap: boolean;
@ -244,20 +247,25 @@ export function getLogLineSize(
logs: LogListModel[], logs: LogListModel[],
container: HTMLDivElement | null, container: HTMLDivElement | null,
displayedFields: string[], displayedFields: string[],
{ hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, { detailsMode, hasLogsWithErrors, hasSampledLogs, showDuplicates, showDetails, showTime, wrap }: DisplayOptions,
index: number index: number
) { ) {
if (!container) { if (!container) {
return 0; 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 // !logs[index] means the line is not yet loaded by infinite scrolling
if (!wrap || !logs[index]) { 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 // If a long line is collapsed, we show the line count + an extra line for the expand/collapse control
logs[index].updateCollapsedState(displayedFields, container); logs[index].updateCollapsedState(displayedFields, container);
if (logs[index].collapsed) { 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); const storedSize = virtualization.retrieveLogLineSize(logs[index].uid, container);
@ -266,7 +274,6 @@ export function getLogLineSize(
} }
let textToMeasure = ''; let textToMeasure = '';
const gap = virtualization.getGridSize() * FIELD_GAP_MULTIPLIER;
const iconsGap = virtualization.getGridSize() * 0.5; const iconsGap = virtualization.getGridSize() * 0.5;
let optionsWidth = 0; let optionsWidth = 0;
if (showDuplicates) { if (showDuplicates) {
@ -296,7 +303,9 @@ export function getLogLineSize(
const { height } = virtualization.measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); const { height } = virtualization.measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth);
// When the log is collapsed, add an extra line for the expand/collapse control // 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 { export interface LogFieldDimension {
@ -313,14 +322,28 @@ export function hasUnderOrOverflow(
if (collapsed !== undefined && calculatedHeight) { if (collapsed !== undefined && calculatedHeight) {
calculatedHeight -= virtualization.getLineHeight(); 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; const height = calculatedHeight ?? element.clientHeight;
if (element.scrollHeight > height) { if (measuredHeight > height) {
return collapsed !== undefined ? element.scrollHeight + virtualization.getLineHeight() : element.scrollHeight; return collapsed !== undefined ? measuredHeight + virtualization.getLineHeight() : measuredHeight;
} }
// Line is smaller than container
const child = element.children[1]; const child = element.children[1];
if (child instanceof HTMLDivElement && child.clientHeight < height) { measuredHeight = child.clientHeight + detailsHeight;
return collapsed !== undefined ? child.clientHeight + virtualization.getLineHeight() : child.clientHeight; if (child instanceof HTMLDivElement && measuredHeight < height) {
return collapsed !== undefined ? measuredHeight + virtualization.getLineHeight() : measuredHeight;
} }
// No overflow or undermeasurement
return null; return null;
} }

@ -8739,6 +8739,7 @@
}, },
"fields-section": "Fields", "fields-section": "Fields",
"hide-log-line": "Hide log line", "hide-log-line": "Hide log line",
"inline-mode": "Display inline",
"links-section": "Links", "links-section": "Links",
"log-line-field": "Log line", "log-line-field": "Log line",
"log-line-section": "Log line", "log-line-section": "Log line",
@ -8751,6 +8752,7 @@
"search-placeholder": "Search field names and values", "search-placeholder": "Search field names and values",
"show-context": "Show context", "show-context": "Show context",
"show-log-line": "Show log line", "show-log-line": "Show log line",
"sidebar-mode": "Anchor to the right",
"unpin-line": "Unpin log" "unpin-line": "Unpin log"
}, },
"log-line-menu": { "log-line-menu": {

Loading…
Cancel
Save