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
This commit is contained in:
@@ -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,65 +135,76 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
|
||||
variant={logLineDisplayed ? 'primary' : undefined}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
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 && (
|
||||
<div className={styles.icons}>
|
||||
<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"
|
||||
size="md"
|
||||
name="share-alt"
|
||||
onClick={copyLinkToLogLine}
|
||||
name="copy"
|
||||
onClick={copyLogLine}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
{pinned && onUnpinLine && (
|
||||
{onPermalinkClick && log.rowId !== undefined && log.uid && (
|
||||
<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
|
||||
size="md"
|
||||
name="gf-pin"
|
||||
onClick={togglePinning}
|
||||
tooltip={t('logs.log-line-details.unpin-line', 'Unpin log')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
variant="primary"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{!pinned && onPinLine && (
|
||||
<IconButton
|
||||
size="md"
|
||||
name="gf-pin"
|
||||
onClick={togglePinning}
|
||||
tooltip={t('logs.log-line-details.pin-line', 'Pin log')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
name="times"
|
||||
aria-label={t('logs.log-line-details.close', 'Close log details')}
|
||||
onClick={closeDetails}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
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,12 @@ 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();
|
||||
@@ -86,6 +92,50 @@ describe('LogList', () => {
|
||||
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();
|
||||
|
||||
render(
|
||||
<LogList
|
||||
{...defaultProps}
|
||||
enableLogDetails={true}
|
||||
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": {
|
||||
|
||||
Reference in New Issue
Block a user