mirror of https://github.com/grafana/grafana.git
New Logs Panel: font size selector and Log Details size improvments (#106376)
* LogList: create font size option * LogList: prevent option fontSize bouncing * LogListContext: fix stored container size bigger than container * LogList: render smaller font size * virtualization: adjust to variable font size * virtualization: strip white characters of at the start successive long lines * LogList: add font size to log size cache * LogList: use getters instead of fixed constants * LogLine: prevent unnecessary overflow calls * virtualization: strip ansi color codes before measuring * LogListDetails: adjust size on resize and give logs panel a min width * LogsPanel: add showControls as a dashboard option * virtualization: update test * virtualization: add small test case * processing: update font size * LogListControls: update test * Extract translations * Logs Panel: enable controls by default * LogListContext: update mock * ControlledLogRows: add missing prop * LogLine: remove height ref * LogList: dont touch the debounced function on successive calls * LogLine: update test * LogsPanel: make controls default to false again * LogsPanel: make controls default to false again * LogLineDetails: fix height resizing and make close button sticky * LogLine: memo log component * LogLineDetails: fix close button position * New Logs Panel: Add Popover Menu support (#106394) * LogList: add popover menu support * LogList: test popover menu * Chore: remove unnecessary optional chain op * LogLinedDetails: fix close button position with and without scroll
This commit is contained in:
parent
7c3f7b9e8b
commit
db83b4ef17
|
|
@ -18,6 +18,7 @@ export interface Options {
|
||||||
displayedFields?: Array<string>;
|
displayedFields?: Array<string>;
|
||||||
enableInfiniteScrolling?: boolean;
|
enableInfiniteScrolling?: boolean;
|
||||||
enableLogDetails: boolean;
|
enableLogDetails: boolean;
|
||||||
|
fontSize?: ('default' | 'small');
|
||||||
isFilterLabelActive?: unknown;
|
isFilterLabelActive?: unknown;
|
||||||
logLineMenuCustomItems?: unknown;
|
logLineMenuCustomItems?: unknown;
|
||||||
logRowMenuIconsAfter?: unknown;
|
logRowMenuIconsAfter?: unknown;
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
|
||||||
displayedFields={[]}
|
displayedFields={[]}
|
||||||
dedupStrategy={dedupStrategy}
|
dedupStrategy={dedupStrategy}
|
||||||
enableLogDetails={false}
|
enableLogDetails={false}
|
||||||
|
fontSize="default"
|
||||||
hasUnescapedContent={hasUnescapedContent}
|
hasUnescapedContent={hasUnescapedContent}
|
||||||
logOptionsStorageKey={logOptionsStorageKey}
|
logOptionsStorageKey={logOptionsStorageKey}
|
||||||
logs={deduplicatedRows ?? []}
|
logs={deduplicatedRows ?? []}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export interface Props {
|
||||||
renderPreview?: boolean;
|
renderPreview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PopoverStateType = {
|
export type PopoverStateType = {
|
||||||
selection: string;
|
selection: string;
|
||||||
selectedRow: LogRowModel | null;
|
selectedRow: LogRowModel | null;
|
||||||
popoverMenuCoordinates: { x: number; y: number };
|
popoverMenuCoordinates: { x: number; y: number };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
import { ReactNode, useCallback, useEffect, useRef, useState, MouseEvent } from 'react';
|
||||||
import { usePrevious } from 'react-use';
|
import { usePrevious } from 'react-use';
|
||||||
import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window';
|
import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window';
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ interface Props {
|
||||||
handleOverflow: (index: number, id: string, height?: number) => void;
|
handleOverflow: (index: number, id: string, height?: number) => void;
|
||||||
loadMore?: (range: AbsoluteTimeRange) => void;
|
loadMore?: (range: AbsoluteTimeRange) => void;
|
||||||
logs: LogListModel[];
|
logs: LogListModel[];
|
||||||
onClick: (log: LogListModel) => void;
|
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
|
||||||
scrollElement: HTMLDivElement | null;
|
scrollElement: HTMLDivElement | null;
|
||||||
setInitialScrollPosition: () => void;
|
setInitialScrollPosition: () => void;
|
||||||
showTime: boolean;
|
showTime: boolean;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||||
import { createLogLine } from '../__mocks__/logRow';
|
import { createLogLine } from '../__mocks__/logRow';
|
||||||
|
|
||||||
import { getStyles, LogLine, Props } from './LogLine';
|
import { getStyles, LogLine, Props } from './LogLine';
|
||||||
|
import { LogListFontSize } from './LogList';
|
||||||
import { LogListContextProvider } from './LogListContext';
|
import { LogListContextProvider } from './LogListContext';
|
||||||
import { defaultProps } from './__mocks__/LogListContext';
|
import { defaultProps } from './__mocks__/LogListContext';
|
||||||
import { LogListModel } from './processing';
|
import { LogListModel } from './processing';
|
||||||
|
|
@ -27,12 +28,14 @@ const contextProps = {
|
||||||
sortOrder: LogsSortOrder.Ascending,
|
sortOrder: LogsSortOrder.Ascending,
|
||||||
wrapLogMessage: false,
|
wrapLogMessage: false,
|
||||||
};
|
};
|
||||||
|
const fontSizes: LogListFontSize[] = ['default', 'small'];
|
||||||
|
|
||||||
describe('LogLine', () => {
|
describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
|
||||||
let log: LogListModel, defaultProps: Props;
|
let log: LogListModel, defaultProps: Props;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
|
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
|
||||||
contextProps.logs = [log];
|
contextProps.logs = [log];
|
||||||
|
contextProps.fontSize = fontSize;
|
||||||
defaultProps = {
|
defaultProps = {
|
||||||
displayedFields: [],
|
displayedFields: [],
|
||||||
index: 0,
|
index: 0,
|
||||||
|
|
@ -46,26 +49,42 @@ describe('LogLine', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Renders a log line', () => {
|
test('Renders a log line', () => {
|
||||||
render(<LogLine {...defaultProps} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Renders a log line with no timestamp', () => {
|
test('Renders a log line with no timestamp', () => {
|
||||||
render(<LogLine {...defaultProps} showTime={false} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} showTime={false} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument();
|
expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Renders a log line with displayed fields', () => {
|
test('Renders a log line with displayed fields', () => {
|
||||||
render(<LogLine {...defaultProps} displayedFields={['place']} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} displayedFields={['place']} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(log.body)).not.toBeInTheDocument();
|
expect(screen.queryByText(log.body)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('luna')).toBeInTheDocument();
|
expect(screen.getByText('luna')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Renders a log line with body displayed fields', () => {
|
test('Renders a log line with body displayed fields', () => {
|
||||||
render(<LogLine {...defaultProps} displayedFields={['place', LOG_LINE_BODY_FIELD_NAME]} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} displayedFields={['place', LOG_LINE_BODY_FIELD_NAME]} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('luna')).toBeInTheDocument();
|
expect(screen.getByText('luna')).toBeInTheDocument();
|
||||||
|
|
@ -143,7 +162,11 @@ describe('LogLine', () => {
|
||||||
|
|
||||||
describe('Log line menu', () => {
|
describe('Log line menu', () => {
|
||||||
test('Renders a log line menu', async () => {
|
test('Renders a log line menu', async () => {
|
||||||
render(<LogLine {...defaultProps} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.queryByText('Copy log line')).not.toBeInTheDocument();
|
expect(screen.queryByText('Copy log line')).not.toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||||
expect(screen.getByText('Copy log line')).toBeInTheDocument();
|
expect(screen.getByText('Copy log line')).toBeInTheDocument();
|
||||||
|
|
@ -156,7 +179,11 @@ describe('LogLine', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Highlights relevant tokens in the log line', () => {
|
test('Highlights relevant tokens in the log line', () => {
|
||||||
render(<LogLine {...defaultProps} log={log} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} log={log} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.getByText('place')).toBeInTheDocument();
|
expect(screen.getByText('place')).toBeInTheDocument();
|
||||||
expect(screen.getByText('1ms')).toBeInTheDocument();
|
expect(screen.getByText('1ms')).toBeInTheDocument();
|
||||||
expect(screen.getByText('3 KB')).toBeInTheDocument();
|
expect(screen.getByText('3 KB')).toBeInTheDocument();
|
||||||
|
|
@ -196,7 +223,11 @@ describe('LogLine', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Logs are not collapsed by default', () => {
|
test('Logs are not collapsed by default', () => {
|
||||||
render(<LogLine {...defaultProps} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -204,11 +235,13 @@ describe('LogLine', () => {
|
||||||
test('Logs are not collapsible when unwrapped', () => {
|
test('Logs are not collapsible when unwrapped', () => {
|
||||||
log.collapsed = true;
|
log.collapsed = true;
|
||||||
render(
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
<LogLine
|
<LogLine
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
// Unwrapped logs
|
// Unwrapped logs
|
||||||
wrapLogMessage={false}
|
wrapLogMessage={false}
|
||||||
/>
|
/>
|
||||||
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||||
|
|
@ -216,7 +249,11 @@ describe('LogLine', () => {
|
||||||
|
|
||||||
test('Long logs can be collapsed and expanded', async () => {
|
test('Long logs can be collapsed and expanded', async () => {
|
||||||
log.collapsed = true;
|
log.collapsed = true;
|
||||||
render(<LogLine {...defaultProps} log={log} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} log={log} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.getByText('show more')).toBeVisible();
|
expect(screen.getByText('show more')).toBeVisible();
|
||||||
await userEvent.click(screen.getByText('show more'));
|
await userEvent.click(screen.getByText('show more'));
|
||||||
expect(await screen.findByText('show less')).toBeInTheDocument();
|
expect(await screen.findByText('show less')).toBeInTheDocument();
|
||||||
|
|
@ -227,7 +264,11 @@ describe('LogLine', () => {
|
||||||
test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => {
|
test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => {
|
||||||
log.collapsed = true;
|
log.collapsed = true;
|
||||||
const onOverflow = jest.fn();
|
const onOverflow = jest.fn();
|
||||||
render(<LogLine {...defaultProps} onOverflow={onOverflow} log={log} />);
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} onOverflow={onOverflow} log={log} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
await userEvent.click(await screen.findByText('show more'));
|
await userEvent.click(await screen.findByText('show more'));
|
||||||
await userEvent.click(await screen.findByText('show less'));
|
await userEvent.click(await screen.findByText('show less'));
|
||||||
expect(onOverflow).toHaveBeenCalledTimes(2);
|
expect(onOverflow).toHaveBeenCalledTimes(2);
|
||||||
|
|
@ -239,7 +280,11 @@ describe('LogLine', () => {
|
||||||
expect(screen.getByText('show more')).toBeVisible();
|
expect(screen.getByText('show more')).toBeVisible();
|
||||||
|
|
||||||
log.collapsed = undefined;
|
log.collapsed = undefined;
|
||||||
rerender(<LogLine {...defaultProps} log={log} />);
|
rerender(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} log={log} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||||
|
|
@ -247,10 +292,18 @@ describe('LogLine', () => {
|
||||||
|
|
||||||
test('Syncs the collapsed state with wrapping changes', async () => {
|
test('Syncs the collapsed state with wrapping changes', async () => {
|
||||||
log.collapsed = true;
|
log.collapsed = true;
|
||||||
const { rerender } = render(<LogLine {...defaultProps} log={log} />);
|
const { rerender } = render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} log={log} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
expect(screen.getByText('show more')).toBeVisible();
|
expect(screen.getByText('show more')).toBeVisible();
|
||||||
|
|
||||||
rerender(<LogLine {...defaultProps} log={log} wrapLogMessage={false} />);
|
rerender(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogLine {...defaultProps} log={log} wrapLogMessage={false} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
import { CSSProperties, memo, useCallback, useEffect, useRef, useState, MouseEvent } from 'react';
|
||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data';
|
import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data';
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
hasUnderOrOverflow,
|
hasUnderOrOverflow,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
LogFieldDimension,
|
LogFieldDimension,
|
||||||
TRUNCATION_LINE_COUNT,
|
getTruncationLineCount,
|
||||||
} from './virtualization';
|
} from './virtualization';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
|
@ -27,7 +27,7 @@ export interface Props {
|
||||||
showTime: boolean;
|
showTime: boolean;
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
styles: LogLineStyles;
|
styles: LogLineStyles;
|
||||||
onClick: (log: LogListModel) => void;
|
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
|
||||||
onOverflow?: (index: number, id: string, height?: number) => void;
|
onOverflow?: (index: number, id: string, height?: number) => void;
|
||||||
variant?: 'infinite-scroll';
|
variant?: 'infinite-scroll';
|
||||||
wrapLogMessage: boolean;
|
wrapLogMessage: boolean;
|
||||||
|
|
@ -45,8 +45,15 @@ export const LogLine = ({
|
||||||
variant,
|
variant,
|
||||||
wrapLogMessage,
|
wrapLogMessage,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { detailsDisplayed, dedupStrategy, enableLogDetails, hasLogsWithErrors, hasSampledLogs, onLogLineHover } =
|
const {
|
||||||
useLogListContext();
|
detailsDisplayed,
|
||||||
|
dedupStrategy,
|
||||||
|
enableLogDetails,
|
||||||
|
fontSize,
|
||||||
|
hasLogsWithErrors,
|
||||||
|
hasSampledLogs,
|
||||||
|
onLogLineHover,
|
||||||
|
} = useLogListContext();
|
||||||
const [collapsed, setCollapsed] = useState<boolean | undefined>(
|
const [collapsed, setCollapsed] = useState<boolean | undefined>(
|
||||||
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
|
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
|
||||||
);
|
);
|
||||||
|
|
@ -85,16 +92,19 @@ export const LogLine = ({
|
||||||
}, [collapsed, index, log, onOverflow]);
|
}, [collapsed, index, log, onOverflow]);
|
||||||
|
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(
|
||||||
onClick(log);
|
(e: MouseEvent<HTMLElement>) => {
|
||||||
}, [log, onClick]);
|
onClick(e, log);
|
||||||
|
},
|
||||||
|
[log, onClick]
|
||||||
|
);
|
||||||
|
|
||||||
const detailsShown = detailsDisplayed(log);
|
const detailsShown = detailsDisplayed(log);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={style}>
|
<div style={style}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''}`}
|
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''}`}
|
||||||
ref={onOverflow ? logLineRef : undefined}
|
ref={onOverflow ? logLineRef : undefined}
|
||||||
onMouseEnter={handleMouseOver}
|
onMouseEnter={handleMouseOver}
|
||||||
onFocus={handleMouseOver}
|
onFocus={handleMouseOver}
|
||||||
|
|
@ -143,6 +153,7 @@ export const LogLine = ({
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
||||||
<div
|
<div
|
||||||
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
|
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
|
||||||
|
style={collapsed ? { maxHeight: `${getTruncationLineCount() * getLineHeight()}px` } : undefined}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Log
|
<Log
|
||||||
|
|
@ -192,7 +203,7 @@ interface LogProps {
|
||||||
wrapLogMessage: boolean;
|
wrapLogMessage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => {
|
const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
|
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
|
||||||
|
|
@ -217,7 +228,9 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
Log.displayName = 'Log';
|
||||||
|
|
||||||
const LogLineBody = ({ log }: { log: LogListModel }) => {
|
const LogLineBody = ({ log }: { log: LogListModel }) => {
|
||||||
const { syntaxHighlighting } = useLogListContext();
|
const { syntaxHighlighting } = useLogListContext();
|
||||||
|
|
@ -266,6 +279,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
fontFamily: theme.typography.fontFamilyMonospace,
|
fontFamily: theme.typography.fontFamilyMonospace,
|
||||||
fontSize: theme.typography.fontSize,
|
fontSize: theme.typography.fontSize,
|
||||||
|
lineHeight: theme.typography.body.lineHeight,
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
background: theme.isDark ? `hsla(0, 0%, 0%, 0.3)` : `hsla(0, 0%, 0%, 0.1)`,
|
background: theme.isDark ? `hsla(0, 0%, 0%, 0.3)` : `hsla(0, 0%, 0%, 0.1)`,
|
||||||
|
|
@ -316,6 +330,10 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
color: theme.colors.text.primary,
|
color: theme.colors.text.primary,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
fontSizeSmall: css({
|
||||||
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
|
lineHeight: theme.typography.bodySmall.lineHeight,
|
||||||
|
}),
|
||||||
detailsDisplayed: css({
|
detailsDisplayed: css({
|
||||||
background: theme.isDark ? `hsla(0, 0%, 0%, 0.5)` : `hsla(0, 0%, 0%, 0.1)`,
|
background: theme.isDark ? `hsla(0, 0%, 0%, 0.5)` : `hsla(0, 0%, 0%, 0.1)`,
|
||||||
}),
|
}),
|
||||||
|
|
@ -415,7 +433,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
collapsedLogLine: css({
|
collapsedLogLine: css({
|
||||||
maxHeight: `${TRUNCATION_LINE_COUNT * getLineHeight()}px`,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}),
|
}),
|
||||||
expandCollapseControl: css({
|
expandCollapseControl: css({
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { getLogRowStyles } from '../getLogRowStyles';
|
||||||
|
|
||||||
import { useLogListContext } from './LogListContext';
|
import { useLogListContext } from './LogListContext';
|
||||||
import { LogListModel } from './processing';
|
import { LogListModel } from './processing';
|
||||||
|
import { LOG_LIST_MIN_WIDTH } from './virtualization';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
containerElement: HTMLDivElement;
|
containerElement: HTMLDivElement;
|
||||||
|
|
@ -51,15 +52,20 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
|
||||||
onResize();
|
onResize();
|
||||||
}, [onResize, setDetailsWidth]);
|
}, [onResize, setDetailsWidth]);
|
||||||
|
|
||||||
|
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Resizable
|
<Resizable
|
||||||
onResize={handleResize}
|
onResize={handleResize}
|
||||||
handleClasses={{ left: dragStyles.dragHandleVertical }}
|
handleClasses={{ left: dragStyles.dragHandleVertical }}
|
||||||
defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }}
|
defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }}
|
||||||
|
size={{ width: detailsWidth, height: containerElement.clientHeight }}
|
||||||
enable={{ left: true }}
|
enable={{ left: true }}
|
||||||
minWidth={40}
|
minWidth={40}
|
||||||
|
maxWidth={maxWidth}
|
||||||
>
|
>
|
||||||
<div className={styles.container} ref={containerRef}>
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<div className={styles.scrollContainer}>
|
||||||
<IconButton
|
<IconButton
|
||||||
name="times"
|
name="times"
|
||||||
className={styles.closeIcon}
|
className={styles.closeIcon}
|
||||||
|
|
@ -90,12 +96,17 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Resizable>
|
</Resizable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
container: css({
|
container: css({
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
}),
|
||||||
|
scrollContainer: css({
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||||
|
|
||||||
|
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
|
||||||
import { createLogRow } from '../__mocks__/logRow';
|
import { createLogRow } from '../__mocks__/logRow';
|
||||||
|
|
||||||
import { LogList, Props } from './LogList';
|
import { LogList, Props } from './LogList';
|
||||||
|
|
@ -11,9 +12,23 @@ jest.mock('@grafana/runtime', () => {
|
||||||
return {
|
return {
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
|
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
|
||||||
|
config: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config,
|
||||||
|
featureToggles: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config.featureToggles,
|
||||||
|
logRowsPopoverMenu: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('../../utils', () => ({
|
||||||
|
...jest.requireActual('../../utils'),
|
||||||
|
isPopoverMenuDisabled: jest.fn(),
|
||||||
|
disablePopoverMenu: jest.fn(),
|
||||||
|
enablePopoverMenu: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('LogList', () => {
|
describe('LogList', () => {
|
||||||
let logs: LogRowModel[], defaultProps: Props;
|
let logs: LogRowModel[], defaultProps: Props;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -26,7 +41,7 @@ describe('LogList', () => {
|
||||||
containerElement: document.createElement('div'),
|
containerElement: document.createElement('div'),
|
||||||
dedupStrategy: LogsDedupStrategy.none,
|
dedupStrategy: LogsDedupStrategy.none,
|
||||||
displayedFields: [],
|
displayedFields: [],
|
||||||
enableLogDetails: false,
|
enableLogDetails: true,
|
||||||
logs,
|
logs,
|
||||||
showControls: false,
|
showControls: false,
|
||||||
showTime: false,
|
showTime: false,
|
||||||
|
|
@ -114,4 +129,107 @@ describe('LogList', () => {
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Popover menu', () => {
|
||||||
|
function setup(overrides: Partial<Props> = {}) {
|
||||||
|
return render(
|
||||||
|
<LogList {...defaultProps} onClickFilterString={jest.fn()} onClickFilterOutString={jest.fn()} {...overrides} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let orgGetSelection: () => Selection | null;
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(isPopoverMenuDisabled).mockReturnValue(false);
|
||||||
|
});
|
||||||
|
beforeAll(() => {
|
||||||
|
orgGetSelection = document.getSelection;
|
||||||
|
jest.spyOn(document, 'getSelection').mockReturnValue({
|
||||||
|
toString: () => 'selected log line',
|
||||||
|
removeAllRanges: () => {},
|
||||||
|
addRange: (range: Range) => {},
|
||||||
|
} as Selection);
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
document.getSelection = orgGetSelection;
|
||||||
|
});
|
||||||
|
it('Does not appear in the document', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('Appears after selecting text', async () => {
|
||||||
|
setup();
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.getByText('Copy selection')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Add as line contains filter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('Can be disabled', async () => {
|
||||||
|
setup();
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
await userEvent.click(screen.getByText('Disable menu'));
|
||||||
|
await userEvent.click(screen.getByText('Confirm'));
|
||||||
|
expect(disablePopoverMenu).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('Does not appear when disabled', async () => {
|
||||||
|
jest.mocked(isPopoverMenuDisabled).mockReturnValue(true);
|
||||||
|
setup();
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('Can be re-enabled', async () => {
|
||||||
|
jest.mocked(isPopoverMenuDisabled).mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup();
|
||||||
|
await user.keyboard('[AltLeft>]'); // Press Alt (without releasing it)
|
||||||
|
await user.click(screen.getByText('log message 1'));
|
||||||
|
expect(enablePopoverMenu).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('Does not appear when the props are not defined', async () => {
|
||||||
|
setup({
|
||||||
|
onClickFilterOutString: undefined,
|
||||||
|
onClickFilterString: undefined,
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('Appears after selecting text', async () => {
|
||||||
|
const onClickFilterOutString = jest.fn();
|
||||||
|
const onClickFilterString = jest.fn();
|
||||||
|
setup({
|
||||||
|
onClickFilterOutString,
|
||||||
|
onClickFilterString,
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.getByText('Copy selection')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText('Add as line contains filter'));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.getByText('Copy selection')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText('Add as line does not contain filter'));
|
||||||
|
|
||||||
|
expect(onClickFilterOutString).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClickFilterString).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
describe('Interacting with log details', () => {
|
||||||
|
it('Allows text selection even if the popover menu is not available', async () => {
|
||||||
|
setup({
|
||||||
|
onClickFilterOutString: undefined,
|
||||||
|
onClickFilterString: undefined,
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/details/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Displays Log Details if there is no text selection', async () => {
|
||||||
|
jest.spyOn(document, 'getSelection').mockReturnValue(null);
|
||||||
|
setup({
|
||||||
|
onClickFilterOutString: undefined,
|
||||||
|
onClickFilterString: undefined,
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByText('log message 1'));
|
||||||
|
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Fields/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { Grammar } from 'prismjs';
|
import { Grammar } from 'prismjs';
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, MouseEvent } from 'react';
|
||||||
import { VariableSizeList } from 'react-window';
|
import { VariableSizeList } from 'react-window';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
EventBus,
|
EventBus,
|
||||||
EventBusSrv,
|
EventBusSrv,
|
||||||
|
GrafanaTheme2,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
LogRowModel,
|
LogRowModel,
|
||||||
LogsDedupStrategy,
|
LogsDedupStrategy,
|
||||||
|
|
@ -18,7 +19,9 @@ import {
|
||||||
store,
|
store,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { PopoverContent, useTheme2 } from '@grafana/ui';
|
import { Trans, useTranslate } from '@grafana/i18n';
|
||||||
|
import { ConfirmModal, Icon, PopoverContent, useTheme2 } from '@grafana/ui';
|
||||||
|
import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu';
|
||||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||||
|
|
||||||
import { InfiniteScroll } from './InfiniteScroll';
|
import { InfiniteScroll } from './InfiniteScroll';
|
||||||
|
|
@ -28,6 +31,7 @@ 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';
|
||||||
import { preProcessLogs, LogListModel } from './processing';
|
import { preProcessLogs, LogListModel } from './processing';
|
||||||
|
import { usePopoverMenu } from './usePopoverMenu';
|
||||||
import {
|
import {
|
||||||
calculateFieldDimensions,
|
calculateFieldDimensions,
|
||||||
getLogLineSize,
|
getLogLineSize,
|
||||||
|
|
@ -46,6 +50,7 @@ export interface Props {
|
||||||
enableLogDetails: boolean;
|
enableLogDetails: boolean;
|
||||||
eventBus?: EventBus;
|
eventBus?: EventBus;
|
||||||
filterLevels?: LogLevel[];
|
filterLevels?: LogLevel[];
|
||||||
|
fontSize?: LogListFontSize;
|
||||||
getFieldLinks?: GetFieldLinksFn;
|
getFieldLinks?: GetFieldLinksFn;
|
||||||
getRowContextQuery?: GetRowContextQueryFn;
|
getRowContextQuery?: GetRowContextQueryFn;
|
||||||
grammar?: Grammar;
|
grammar?: Grammar;
|
||||||
|
|
@ -82,6 +87,8 @@ export interface Props {
|
||||||
wrapLogMessage: boolean;
|
wrapLogMessage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LogListFontSize = 'default' | 'small';
|
||||||
|
|
||||||
export type LogListControlOptions = LogListState;
|
export type LogListControlOptions = LogListState;
|
||||||
|
|
||||||
type LogListComponentProps = Omit<
|
type LogListComponentProps = Omit<
|
||||||
|
|
@ -105,6 +112,8 @@ export const LogList = ({
|
||||||
enableLogDetails,
|
enableLogDetails,
|
||||||
eventBus,
|
eventBus,
|
||||||
filterLevels,
|
filterLevels,
|
||||||
|
logOptionsStorageKey,
|
||||||
|
fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default',
|
||||||
getFieldLinks,
|
getFieldLinks,
|
||||||
getRowContextQuery,
|
getRowContextQuery,
|
||||||
grammar,
|
grammar,
|
||||||
|
|
@ -113,7 +122,6 @@ export const LogList = ({
|
||||||
loading,
|
loading,
|
||||||
loadMore,
|
loadMore,
|
||||||
logLineMenuCustomItems,
|
logLineMenuCustomItems,
|
||||||
logOptionsStorageKey,
|
|
||||||
logs,
|
logs,
|
||||||
logsMeta,
|
logsMeta,
|
||||||
logSupportsContext,
|
logSupportsContext,
|
||||||
|
|
@ -148,6 +156,7 @@ export const LogList = ({
|
||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
enableLogDetails={enableLogDetails}
|
enableLogDetails={enableLogDetails}
|
||||||
filterLevels={filterLevels}
|
filterLevels={filterLevels}
|
||||||
|
fontSize={fontSize}
|
||||||
getRowContextQuery={getRowContextQuery}
|
getRowContextQuery={getRowContextQuery}
|
||||||
isLabelFilterActive={isLabelFilterActive}
|
isLabelFilterActive={isLabelFilterActive}
|
||||||
logs={logs}
|
logs={logs}
|
||||||
|
|
@ -211,9 +220,12 @@ const LogListComponent = ({
|
||||||
displayedFields,
|
displayedFields,
|
||||||
dedupStrategy,
|
dedupStrategy,
|
||||||
filterLevels,
|
filterLevels,
|
||||||
|
fontSize,
|
||||||
forceEscape,
|
forceEscape,
|
||||||
hasLogsWithErrors,
|
hasLogsWithErrors,
|
||||||
hasSampledLogs,
|
hasSampledLogs,
|
||||||
|
onClickFilterString,
|
||||||
|
onClickFilterOutString,
|
||||||
permalinkedLogId,
|
permalinkedLogId,
|
||||||
showDetails,
|
showDetails,
|
||||||
showTime,
|
showTime,
|
||||||
|
|
@ -236,8 +248,18 @@ const LogListComponent = ({
|
||||||
() => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)),
|
() => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)),
|
||||||
[displayedFields, processedLogs, wrapLogMessage]
|
[displayedFields, processedLogs, wrapLogMessage]
|
||||||
);
|
);
|
||||||
const styles = getStyles(dimensions, { showTime });
|
const styles = getStyles(dimensions, { showTime }, theme);
|
||||||
const widthContainer = wrapperRef.current ?? containerElement;
|
const widthContainer = wrapperRef.current ?? containerElement;
|
||||||
|
const {
|
||||||
|
closePopoverMenu,
|
||||||
|
handleTextSelection,
|
||||||
|
onDisableCancel,
|
||||||
|
onDisableConfirm,
|
||||||
|
onDisablePopoverMenu,
|
||||||
|
popoverState,
|
||||||
|
showDisablePopoverOptions,
|
||||||
|
} = usePopoverMenu(wrapperRef.current);
|
||||||
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const debouncedResetAfterIndex = useMemo(() => {
|
const debouncedResetAfterIndex = useMemo(() => {
|
||||||
return debounce((index: number) => {
|
return debounce((index: number) => {
|
||||||
|
|
@ -247,8 +269,8 @@ const LogListComponent = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initVirtualization(theme);
|
initVirtualization(theme, fontSize);
|
||||||
}, [theme]);
|
}, [fontSize, theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
|
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
|
||||||
|
|
@ -299,12 +321,15 @@ const LogListComponent = ({
|
||||||
const handleOverflow = useCallback(
|
const handleOverflow = useCallback(
|
||||||
(index: number, id: string, height?: number) => {
|
(index: number, id: string, height?: number) => {
|
||||||
if (height !== undefined) {
|
if (height !== undefined) {
|
||||||
storeLogLineSize(id, widthContainer, height);
|
storeLogLineSize(id, widthContainer, height, fontSize);
|
||||||
|
}
|
||||||
|
if (index === overflowIndexRef.current) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current;
|
overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current;
|
||||||
debouncedResetAfterIndex(overflowIndexRef.current);
|
debouncedResetAfterIndex(overflowIndexRef.current);
|
||||||
},
|
},
|
||||||
[debouncedResetAfterIndex, widthContainer]
|
[debouncedResetAfterIndex, fontSize, widthContainer]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrollPosition = useCallback(() => {
|
const handleScrollPosition = useCallback(() => {
|
||||||
|
|
@ -324,14 +349,14 @@ const LogListComponent = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogLineClick = useCallback(
|
const handleLogLineClick = useCallback(
|
||||||
(log: LogListModel) => {
|
(e: MouseEvent<HTMLElement>, log: LogListModel) => {
|
||||||
// Let people select text
|
if (handleTextSelection(e, log)) {
|
||||||
if (document.getSelection()?.toString()) {
|
// Event handled by the parent.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleDetails(log);
|
toggleDetails(log);
|
||||||
},
|
},
|
||||||
[toggleDetails]
|
[handleTextSelection, toggleDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLogDetailsResize = useCallback(() => {
|
const handleLogDetailsResize = useCallback(() => {
|
||||||
|
|
@ -347,6 +372,39 @@ const LogListComponent = ({
|
||||||
return (
|
return (
|
||||||
<div className={styles.logListContainer}>
|
<div className={styles.logListContainer}>
|
||||||
<div className={styles.logListWrapper} ref={wrapperRef}>
|
<div className={styles.logListWrapper} ref={wrapperRef}>
|
||||||
|
{popoverState.selection && popoverState.selectedRow && (
|
||||||
|
<PopoverMenu
|
||||||
|
close={closePopoverMenu}
|
||||||
|
row={popoverState.selectedRow}
|
||||||
|
selection={popoverState.selection}
|
||||||
|
{...popoverState.popoverMenuCoordinates}
|
||||||
|
onClickFilterString={onClickFilterString}
|
||||||
|
onClickFilterOutString={onClickFilterOutString}
|
||||||
|
onDisable={onDisablePopoverMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDisablePopoverOptions && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen
|
||||||
|
title={t('logs.log-rows.disable-popover.title', 'Disable menu')}
|
||||||
|
body={
|
||||||
|
<>
|
||||||
|
<Trans i18nKey="logs.log-rows.disable-popover.message">
|
||||||
|
You are about to disable the logs filter menu. To re-enable it, select text in a log line while
|
||||||
|
holding the alt key.
|
||||||
|
</Trans>
|
||||||
|
<div className={styles.shortcut}>
|
||||||
|
<Icon name="keyboard" />
|
||||||
|
<Trans i18nKey="logs.log-rows.disable-popover-message.shortcut">alt+select to enable again</Trans>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText={t('logs.log-rows.disable-popover.confirm', 'Confirm')}
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
onConfirm={onDisableConfirm}
|
||||||
|
onDismiss={onDisableCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
handleOverflow={handleOverflow}
|
handleOverflow={handleOverflow}
|
||||||
|
|
@ -367,6 +425,7 @@ const LogListComponent = ({
|
||||||
height={listHeight}
|
height={listHeight}
|
||||||
itemCount={itemCount}
|
itemCount={itemCount}
|
||||||
itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, {
|
itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, {
|
||||||
|
fontSize,
|
||||||
hasLogsWithErrors,
|
hasLogsWithErrors,
|
||||||
hasSampledLogs,
|
hasSampledLogs,
|
||||||
showDuplicates: dedupStrategy !== LogsDedupStrategy.none,
|
showDuplicates: dedupStrategy !== LogsDedupStrategy.none,
|
||||||
|
|
@ -400,7 +459,7 @@ const LogListComponent = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }) {
|
function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }, theme: GrafanaTheme2) {
|
||||||
const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0);
|
const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0);
|
||||||
return {
|
return {
|
||||||
logList: css({
|
logList: css({
|
||||||
|
|
@ -411,9 +470,21 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo
|
||||||
}),
|
}),
|
||||||
logListContainer: css({
|
logListContainer: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
// Minimum width to prevent rendering issues and a sausage-like logs panel.
|
||||||
|
minWidth: theme.spacing(35),
|
||||||
}),
|
}),
|
||||||
logListWrapper: css({
|
logListWrapper: css({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}),
|
||||||
|
shortcut: css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { debounce } from 'lodash';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
Dispatch,
|
Dispatch,
|
||||||
|
|
@ -26,7 +27,9 @@ import { PopoverContent } from '@grafana/ui';
|
||||||
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
|
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
|
||||||
|
|
||||||
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
||||||
|
import { LogListFontSize } from './LogList';
|
||||||
import { LogListModel } from './processing';
|
import { LogListModel } from './processing';
|
||||||
|
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;
|
||||||
|
|
@ -42,6 +45,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
|
||||||
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
|
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
|
||||||
setDetailsWidth: (width: number) => void;
|
setDetailsWidth: (width: number) => void;
|
||||||
setFilterLevels: (filterLevels: LogLevel[]) => void;
|
setFilterLevels: (filterLevels: LogLevel[]) => void;
|
||||||
|
setFontSize: (size: LogListFontSize) => void;
|
||||||
setForceEscape: (forceEscape: boolean) => void;
|
setForceEscape: (forceEscape: boolean) => void;
|
||||||
setLogListState: Dispatch<SetStateAction<LogListState>>;
|
setLogListState: Dispatch<SetStateAction<LogListState>>;
|
||||||
setPinnedLogs: (pinnedlogs: string[]) => void;
|
setPinnedLogs: (pinnedlogs: string[]) => void;
|
||||||
|
|
@ -65,10 +69,12 @@ export const LogListContext = createContext<LogListContextData>({
|
||||||
downloadLogs: () => {},
|
downloadLogs: () => {},
|
||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
filterLevels: [],
|
filterLevels: [],
|
||||||
|
fontSize: 'default',
|
||||||
hasUnescapedContent: false,
|
hasUnescapedContent: false,
|
||||||
setDedupStrategy: () => {},
|
setDedupStrategy: () => {},
|
||||||
setDetailsWidth: () => {},
|
setDetailsWidth: () => {},
|
||||||
setFilterLevels: () => {},
|
setFilterLevels: () => {},
|
||||||
|
setFontSize: () => {},
|
||||||
setForceEscape: () => {},
|
setForceEscape: () => {},
|
||||||
setLogListState: () => {},
|
setLogListState: () => {},
|
||||||
setPinnedLogs: () => {},
|
setPinnedLogs: () => {},
|
||||||
|
|
@ -108,6 +114,7 @@ export const useLogIsPermalinked = (log: LogListModel) => {
|
||||||
export type LogListState = Pick<
|
export type LogListState = Pick<
|
||||||
LogListContextData,
|
LogListContextData,
|
||||||
| 'dedupStrategy'
|
| 'dedupStrategy'
|
||||||
|
| 'fontSize'
|
||||||
| 'forceEscape'
|
| 'forceEscape'
|
||||||
| 'filterLevels'
|
| 'filterLevels'
|
||||||
| 'hasUnescapedContent'
|
| 'hasUnescapedContent'
|
||||||
|
|
@ -123,11 +130,13 @@ export type LogListState = Pick<
|
||||||
export interface Props {
|
export interface Props {
|
||||||
app: CoreApp;
|
app: CoreApp;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
// Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
|
||||||
containerElement?: HTMLDivElement;
|
containerElement?: HTMLDivElement;
|
||||||
dedupStrategy: LogsDedupStrategy;
|
dedupStrategy: LogsDedupStrategy;
|
||||||
displayedFields: string[];
|
displayedFields: string[];
|
||||||
enableLogDetails: boolean;
|
enableLogDetails: boolean;
|
||||||
filterLevels?: LogLevel[];
|
filterLevels?: LogLevel[];
|
||||||
|
fontSize: LogListFontSize;
|
||||||
forceEscape?: boolean;
|
forceEscape?: boolean;
|
||||||
hasUnescapedContent?: boolean;
|
hasUnescapedContent?: boolean;
|
||||||
getRowContextQuery?: GetRowContextQueryFn;
|
getRowContextQuery?: GetRowContextQueryFn;
|
||||||
|
|
@ -169,6 +178,7 @@ export const LogListContextProvider = ({
|
||||||
dedupStrategy,
|
dedupStrategy,
|
||||||
displayedFields,
|
displayedFields,
|
||||||
filterLevels,
|
filterLevels,
|
||||||
|
fontSize,
|
||||||
forceEscape = false,
|
forceEscape = false,
|
||||||
hasUnescapedContent,
|
hasUnescapedContent,
|
||||||
isLabelFilterActive,
|
isLabelFilterActive,
|
||||||
|
|
@ -205,6 +215,7 @@ export const LogListContextProvider = ({
|
||||||
dedupStrategy,
|
dedupStrategy,
|
||||||
filterLevels:
|
filterLevels:
|
||||||
filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []),
|
filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []),
|
||||||
|
fontSize,
|
||||||
forceEscape,
|
forceEscape,
|
||||||
hasUnescapedContent,
|
hasUnescapedContent,
|
||||||
pinnedLogs,
|
pinnedLogs,
|
||||||
|
|
@ -216,6 +227,7 @@ export const LogListContextProvider = ({
|
||||||
wrapLogMessage,
|
wrapLogMessage,
|
||||||
});
|
});
|
||||||
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
|
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
|
||||||
|
const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey));
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -254,6 +266,10 @@ export const LogListContextProvider = ({
|
||||||
}
|
}
|
||||||
}, [filterLevels, logListState]);
|
}, [filterLevels, logListState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogListState((logListState) => ({ ...logListState, fontSize }));
|
||||||
|
}, [fontSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logListState.hasUnescapedContent !== hasUnescapedContent) {
|
if (logListState.hasUnescapedContent !== hasUnescapedContent) {
|
||||||
setLogListState({ ...logListState, hasUnescapedContent });
|
setLogListState({ ...logListState, hasUnescapedContent });
|
||||||
|
|
@ -278,6 +294,17 @@ export const LogListContextProvider = ({
|
||||||
}
|
}
|
||||||
}, [logs, showDetails]);
|
}, [logs, showDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = debounce(() => {
|
||||||
|
setDetailsWidthState((detailsWidth) => getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth));
|
||||||
|
}, 50);
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [containerElement, logOptionsStorageKey]);
|
||||||
|
|
||||||
const detailsDisplayed = useCallback(
|
const detailsDisplayed = useCallback(
|
||||||
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
|
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
|
||||||
[showDetails]
|
[showDetails]
|
||||||
|
|
@ -291,6 +318,16 @@ export const LogListContextProvider = ({
|
||||||
[logListState, onLogOptionsChange]
|
[logListState, onLogOptionsChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setFontSize = useCallback(
|
||||||
|
(fontSize: LogListFontSize) => {
|
||||||
|
if (logOptionsStorageKey) {
|
||||||
|
store.set(`${logOptionsStorageKey}.fontSize`, fontSize);
|
||||||
|
}
|
||||||
|
setLogListState((logListState) => ({ ...logListState, fontSize }));
|
||||||
|
},
|
||||||
|
[logOptionsStorageKey]
|
||||||
|
);
|
||||||
|
|
||||||
const setForceEscape = useCallback(
|
const setForceEscape = useCallback(
|
||||||
(forceEscape: boolean) => {
|
(forceEscape: boolean) => {
|
||||||
setLogListState({ ...logListState, forceEscape });
|
setLogListState({ ...logListState, forceEscape });
|
||||||
|
|
@ -413,22 +450,24 @@ export const LogListContextProvider = ({
|
||||||
|
|
||||||
const setDetailsWidth = useCallback(
|
const setDetailsWidth = useCallback(
|
||||||
(width: number) => {
|
(width: number) => {
|
||||||
if (!logOptionsStorageKey) {
|
if (!logOptionsStorageKey || !containerElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
|
||||||
|
if (width > maxWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
store.set(`${logOptionsStorageKey}.detailsWidth`, width);
|
store.set(`${logOptionsStorageKey}.detailsWidth`, width);
|
||||||
|
setDetailsWidthState(width);
|
||||||
},
|
},
|
||||||
[logOptionsStorageKey]
|
[containerElement, logOptionsStorageKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]);
|
const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]);
|
||||||
const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]);
|
const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]);
|
||||||
|
|
||||||
const defaultWidth = (containerElement?.clientWidth ?? 0) * 0.4;
|
|
||||||
const detailsWidth = logOptionsStorageKey
|
|
||||||
? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10)
|
|
||||||
: defaultWidth;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LogListContext.Provider
|
<LogListContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
@ -436,11 +475,12 @@ export const LogListContextProvider = ({
|
||||||
closeDetails,
|
closeDetails,
|
||||||
detailsDisplayed,
|
detailsDisplayed,
|
||||||
dedupStrategy: logListState.dedupStrategy,
|
dedupStrategy: logListState.dedupStrategy,
|
||||||
detailsWidth: detailsWidth || defaultWidth,
|
detailsWidth,
|
||||||
displayedFields,
|
displayedFields,
|
||||||
downloadLogs,
|
downloadLogs,
|
||||||
enableLogDetails,
|
enableLogDetails,
|
||||||
filterLevels: logListState.filterLevels,
|
filterLevels: logListState.filterLevels,
|
||||||
|
fontSize: logListState.fontSize,
|
||||||
forceEscape: logListState.forceEscape,
|
forceEscape: logListState.forceEscape,
|
||||||
hasLogsWithErrors,
|
hasLogsWithErrors,
|
||||||
hasSampledLogs,
|
hasSampledLogs,
|
||||||
|
|
@ -467,6 +507,7 @@ export const LogListContextProvider = ({
|
||||||
setDedupStrategy,
|
setDedupStrategy,
|
||||||
setDetailsWidth,
|
setDetailsWidth,
|
||||||
setFilterLevels,
|
setFilterLevels,
|
||||||
|
setFontSize,
|
||||||
setForceEscape,
|
setForceEscape,
|
||||||
setLogListState,
|
setLogListState,
|
||||||
setPinnedLogs,
|
setPinnedLogs,
|
||||||
|
|
@ -502,3 +543,26 @@ export function isDedupStrategy(value: unknown): value is LogsDedupStrategy {
|
||||||
value === LogsDedupStrategy.signature
|
value === LogsDedupStrategy.signature
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
|
||||||
|
function getDetailsWidth(
|
||||||
|
containerElement: HTMLDivElement | undefined,
|
||||||
|
logOptionsStorageKey?: string,
|
||||||
|
currentWidth?: number
|
||||||
|
) {
|
||||||
|
if (!containerElement) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const defaultWidth = containerElement.clientWidth * 0.4;
|
||||||
|
const detailsWidth =
|
||||||
|
currentWidth ||
|
||||||
|
(logOptionsStorageKey ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10) : defaultWidth);
|
||||||
|
|
||||||
|
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
|
||||||
|
|
||||||
|
// The user might have resized the screen.
|
||||||
|
if (detailsWidth >= containerElement.clientWidth || detailsWidth > maxWidth) {
|
||||||
|
return currentWidth ?? defaultWidth;
|
||||||
|
}
|
||||||
|
return detailsWidth;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,26 @@ import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
import { downloadLogs } from '../../utils';
|
import { downloadLogs } from '../../utils';
|
||||||
import { createLogRow } from '../__mocks__/logRow';
|
import { createLogRow } from '../__mocks__/logRow';
|
||||||
|
|
||||||
|
import { LogListFontSize } from './LogList';
|
||||||
import { LogListContextProvider } from './LogListContext';
|
import { LogListContextProvider } from './LogListContext';
|
||||||
import { LogListControls } from './LogListControls';
|
import { LogListControls } from './LogListControls';
|
||||||
import { ScrollToLogsEvent } from './virtualization';
|
import { ScrollToLogsEvent } from './virtualization';
|
||||||
|
|
||||||
jest.mock('../../utils');
|
jest.mock('../../utils');
|
||||||
|
|
||||||
|
const fontSize: LogListFontSize = 'default';
|
||||||
const contextProps = {
|
const contextProps = {
|
||||||
app: CoreApp.Unknown,
|
app: CoreApp.Unknown,
|
||||||
containerElement: document.createElement('div'),
|
containerElement: document.createElement('div'),
|
||||||
dedupStrategy: LogsDedupStrategy.exact,
|
dedupStrategy: LogsDedupStrategy.exact,
|
||||||
displayedFields: [],
|
displayedFields: [],
|
||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
|
fontSize,
|
||||||
logs: [],
|
logs: [],
|
||||||
showControls: true,
|
showControls: true,
|
||||||
showTime: false,
|
showTime: false,
|
||||||
|
|
@ -237,6 +241,24 @@ describe('LogListControls', () => {
|
||||||
expect(screen.getByLabelText('Collapse JSON logs'));
|
expect(screen.getByLabelText('Collapse JSON logs'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Controls font size', async () => {
|
||||||
|
const originalValue = config.featureToggles.newLogsPanel;
|
||||||
|
config.featureToggles.newLogsPanel = true;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LogListContextProvider {...contextProps}>
|
||||||
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByLabelText('Use small font size'));
|
||||||
|
await screen.findByLabelText('Use default font size');
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByLabelText('Use default font size'));
|
||||||
|
await screen.findByLabelText('Use small font size');
|
||||||
|
|
||||||
|
config.featureToggles.newLogsPanel = originalValue;
|
||||||
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
['txt', 'text'],
|
['txt', 'text'],
|
||||||
['json', 'json'],
|
['json', 'json'],
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
||||||
dedupStrategy,
|
dedupStrategy,
|
||||||
downloadLogs,
|
downloadLogs,
|
||||||
filterLevels,
|
filterLevels,
|
||||||
|
fontSize,
|
||||||
forceEscape,
|
forceEscape,
|
||||||
hasUnescapedContent,
|
hasUnescapedContent,
|
||||||
prettifyJSON,
|
prettifyJSON,
|
||||||
setDedupStrategy,
|
setDedupStrategy,
|
||||||
setFilterLevels,
|
setFilterLevels,
|
||||||
|
setFontSize,
|
||||||
setForceEscape,
|
setForceEscape,
|
||||||
setPrettifyJSON,
|
setPrettifyJSON,
|
||||||
setShowTime,
|
setShowTime,
|
||||||
|
|
@ -99,6 +101,14 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
||||||
[filterLevels, setFilterLevels]
|
[filterLevels, setFilterLevels]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onFontSizeClick = useCallback(() => {
|
||||||
|
const newSize = fontSize === 'default' ? 'small' : 'default';
|
||||||
|
reportInteraction('logs_log_list_controls_font_size_clicked', {
|
||||||
|
size: newSize,
|
||||||
|
});
|
||||||
|
setFontSize(newSize);
|
||||||
|
}, [fontSize, setFontSize]);
|
||||||
|
|
||||||
const onShowTimestampsClick = useCallback(() => {
|
const onShowTimestampsClick = useCallback(() => {
|
||||||
reportInteraction('logs_log_list_controls_show_time_clicked', {
|
reportInteraction('logs_log_list_controls_show_time_clicked', {
|
||||||
show_time: !showTime,
|
show_time: !showTime,
|
||||||
|
|
@ -324,6 +334,20 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{config.featureToggles.newLogsPanel && (
|
||||||
|
<IconButton
|
||||||
|
name="text-fields"
|
||||||
|
className={fontSize === 'small' ? styles.controlButtonActive : styles.controlButton}
|
||||||
|
aria-pressed={Boolean(fontSize)}
|
||||||
|
onClick={onFontSizeClick}
|
||||||
|
tooltip={
|
||||||
|
fontSize === 'default'
|
||||||
|
? t('logs.logs-controls.font-size-default', 'Use small font size')
|
||||||
|
: t('logs.logs-controls.font-size-small', 'Use default font size')
|
||||||
|
}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasUnescapedContent && (
|
{hasUnescapedContent && (
|
||||||
<IconButton
|
<IconButton
|
||||||
name="enter"
|
name="enter"
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ export const LogListContext = createContext<LogListContextData>({
|
||||||
downloadLogs: () => {},
|
downloadLogs: () => {},
|
||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
filterLevels: [],
|
filterLevels: [],
|
||||||
|
fontSize: 'default',
|
||||||
hasUnescapedContent: false,
|
hasUnescapedContent: false,
|
||||||
setDedupStrategy: () => {},
|
setDedupStrategy: () => {},
|
||||||
setDetailsWidth: () => {},
|
setDetailsWidth: () => {},
|
||||||
setFilterLevels: () => {},
|
setFilterLevels: () => {},
|
||||||
|
setFontSize: () => {},
|
||||||
setForceEscape: () => {},
|
setForceEscape: () => {},
|
||||||
setLogListState: () => {},
|
setLogListState: () => {},
|
||||||
setPinnedLogs: () => {},
|
setPinnedLogs: () => {},
|
||||||
|
|
@ -59,6 +61,7 @@ export const useLogIsPermalinked = (log: LogListModel) => {
|
||||||
export const defaultValue: LogListContextData = {
|
export const defaultValue: LogListContextData = {
|
||||||
setDedupStrategy: jest.fn(),
|
setDedupStrategy: jest.fn(),
|
||||||
setFilterLevels: jest.fn(),
|
setFilterLevels: jest.fn(),
|
||||||
|
setFontSize: jest.fn(),
|
||||||
setForceEscape: jest.fn(),
|
setForceEscape: jest.fn(),
|
||||||
setLogListState: jest.fn(),
|
setLogListState: jest.fn(),
|
||||||
setPinnedLogs: jest.fn(),
|
setPinnedLogs: jest.fn(),
|
||||||
|
|
@ -74,6 +77,7 @@ export const defaultValue: LogListContextData = {
|
||||||
downloadLogs: jest.fn(),
|
downloadLogs: jest.fn(),
|
||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
filterLevels: [],
|
filterLevels: [],
|
||||||
|
fontSize: 'default',
|
||||||
setDetailsWidth: jest.fn(),
|
setDetailsWidth: jest.fn(),
|
||||||
showDetails: [],
|
showDetails: [],
|
||||||
toggleDetails: jest.fn(),
|
toggleDetails: jest.fn(),
|
||||||
|
|
@ -92,6 +96,7 @@ export const defaultProps: Props = {
|
||||||
displayedFields: [],
|
displayedFields: [],
|
||||||
enableLogDetails: false,
|
enableLogDetails: false,
|
||||||
filterLevels: [],
|
filterLevels: [],
|
||||||
|
fontSize: 'default',
|
||||||
getRowContextQuery: jest.fn(),
|
getRowContextQuery: jest.fn(),
|
||||||
logSupportsContext: jest.fn(),
|
logSupportsContext: jest.fn(),
|
||||||
logs: [],
|
logs: [],
|
||||||
|
|
@ -157,6 +162,7 @@ export const LogListContextProvider = ({
|
||||||
pinnedLogs,
|
pinnedLogs,
|
||||||
setDedupStrategy: jest.fn(),
|
setDedupStrategy: jest.fn(),
|
||||||
setFilterLevels: jest.fn(),
|
setFilterLevels: jest.fn(),
|
||||||
|
setFontSize: jest.fn(),
|
||||||
setForceEscape: jest.fn(),
|
setForceEscape: jest.fn(),
|
||||||
setLogListState: jest.fn(),
|
setLogListState: jest.fn(),
|
||||||
setPinnedLogs: jest.fn(),
|
setPinnedLogs: jest.fn(),
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import { createTheme, Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, to
|
||||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||||
import { createLogLine, createLogRow } from '../__mocks__/logRow';
|
import { createLogLine, createLogRow } from '../__mocks__/logRow';
|
||||||
|
|
||||||
|
import { LogListFontSize } from './LogList';
|
||||||
import { LogListModel, preProcessLogs } from './processing';
|
import { LogListModel, preProcessLogs } from './processing';
|
||||||
import { getTruncationLength, init } from './virtualization';
|
import { getTruncationLength, init } from './virtualization';
|
||||||
|
|
||||||
describe('preProcessLogs', () => {
|
describe('preProcessLogs', () => {
|
||||||
let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel;
|
let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel;
|
||||||
let processedLogs: LogListModel[];
|
let processedLogs: LogListModel[];
|
||||||
|
const fontSizes: LogListFontSize[] = ['default', 'small'];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const getFieldLinks = jest.fn().mockImplementationOnce((field: Field) => ({
|
const getFieldLinks = jest.fn().mockImplementationOnce((field: Field) => ({
|
||||||
|
|
@ -165,10 +167,10 @@ describe('preProcessLogs', () => {
|
||||||
expect(processedLogs[2].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[2].body);
|
expect(processedLogs[2].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[2].body);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Collapsible log lines', () => {
|
describe.each(fontSizes)('Collapsible log lines', (fontSize: LogListFontSize) => {
|
||||||
let longLog: LogListModel, entry: string, container: HTMLDivElement;
|
let longLog: LogListModel, entry: string, container: HTMLDivElement;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
init(createTheme());
|
init(createTheme(), fontSize);
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
|
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
|
||||||
entry = new Array(2 * getTruncationLength(null)).fill('e').join('');
|
entry = new Array(2 * getTruncationLength(null)).fill('e').join('');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useCallback, useRef, useState, MouseEvent } from 'react';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled, targetIsElement } from '../../utils';
|
||||||
|
import { PopoverStateType } from '../LogRows';
|
||||||
|
|
||||||
|
import { useLogListContext } from './LogListContext';
|
||||||
|
import { LogListModel } from './processing';
|
||||||
|
|
||||||
|
export const usePopoverMenu = (containerElement: HTMLDivElement | null) => {
|
||||||
|
const [popoverState, setPopoverState] = useState<PopoverStateType>({
|
||||||
|
selection: '',
|
||||||
|
selectedRow: null,
|
||||||
|
popoverMenuCoordinates: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const [showDisablePopoverOptions, setShowDisablePopoverOptions] = useState(false);
|
||||||
|
const handleDeselectionRef = useRef<((e: Event) => void) | null>(null);
|
||||||
|
const { onClickFilterOutString, onClickFilterString } = useLogListContext();
|
||||||
|
|
||||||
|
const popoverMenuSupported = useCallback(() => {
|
||||||
|
if (!config.featureToggles.logRowsPopoverMenu || isPopoverMenuDisabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(onClickFilterOutString || onClickFilterString);
|
||||||
|
}, [onClickFilterOutString, onClickFilterString]);
|
||||||
|
|
||||||
|
const closePopoverMenu = useCallback(() => {
|
||||||
|
if (handleDeselectionRef.current) {
|
||||||
|
document.removeEventListener('click', handleDeselectionRef.current);
|
||||||
|
document.removeEventListener('contextmenu', handleDeselectionRef.current);
|
||||||
|
handleDeselectionRef.current = null;
|
||||||
|
}
|
||||||
|
setPopoverState({
|
||||||
|
selection: '',
|
||||||
|
popoverMenuCoordinates: { x: 0, y: 0 },
|
||||||
|
selectedRow: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeselection = useCallback(
|
||||||
|
(e: Event) => {
|
||||||
|
if (targetIsElement(e.target) && !containerElement?.contains(e.target)) {
|
||||||
|
// The mouseup event comes from outside the log rows, close the menu.
|
||||||
|
closePopoverMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.getSelection()?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closePopoverMenu();
|
||||||
|
},
|
||||||
|
[closePopoverMenu, containerElement]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTextSelection = useCallback(
|
||||||
|
(e: MouseEvent<HTMLElement>, row: LogListModel): boolean => {
|
||||||
|
const selection = document.getSelection()?.toString();
|
||||||
|
if (!selection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.altKey) {
|
||||||
|
enablePopoverMenu();
|
||||||
|
}
|
||||||
|
if (popoverMenuSupported() === false) {
|
||||||
|
// This signals onRowClick inside LogRow to skip the event because the user is selecting text
|
||||||
|
return selection ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containerElement) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_WIDTH = 270;
|
||||||
|
const MENU_HEIGHT = 105;
|
||||||
|
const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX;
|
||||||
|
const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY;
|
||||||
|
|
||||||
|
setPopoverState({
|
||||||
|
selection,
|
||||||
|
popoverMenuCoordinates: { x, y },
|
||||||
|
selectedRow: row,
|
||||||
|
});
|
||||||
|
handleDeselectionRef.current = handleDeselection;
|
||||||
|
document.addEventListener('click', handleDeselection);
|
||||||
|
document.addEventListener('contextmenu', handleDeselection);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[containerElement, handleDeselection, popoverMenuSupported]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDisablePopoverMenu = useCallback(() => {
|
||||||
|
closePopoverMenu();
|
||||||
|
setShowDisablePopoverOptions(true);
|
||||||
|
}, [closePopoverMenu]);
|
||||||
|
|
||||||
|
const onDisableCancel = useCallback(() => {
|
||||||
|
setShowDisablePopoverOptions(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDisableConfirm = useCallback(() => {
|
||||||
|
disablePopoverMenu();
|
||||||
|
setShowDisablePopoverOptions(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
closePopoverMenu,
|
||||||
|
handleTextSelection,
|
||||||
|
onDisableCancel,
|
||||||
|
onDisableConfirm,
|
||||||
|
onDisablePopoverMenu,
|
||||||
|
popoverState,
|
||||||
|
showDisablePopoverOptions,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,14 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||||
import { createLogLine } from '../__mocks__/logRow';
|
import { createLogLine } from '../__mocks__/logRow';
|
||||||
|
|
||||||
import { LogListModel } from './processing';
|
import { LogListModel } from './processing';
|
||||||
import { getLineHeight, getLogLineSize, init, measureTextWidth, TRUNCATION_LINE_COUNT } from './virtualization';
|
import {
|
||||||
|
getLineHeight,
|
||||||
|
getLogLineSize,
|
||||||
|
init,
|
||||||
|
measureTextWidth,
|
||||||
|
getTruncationLineCount,
|
||||||
|
DisplayOptions,
|
||||||
|
} from './virtualization';
|
||||||
|
|
||||||
const PADDING_BOTTOM = 6;
|
const PADDING_BOTTOM = 6;
|
||||||
const LINE_HEIGHT = getLineHeight();
|
const LINE_HEIGHT = getLineHeight();
|
||||||
|
|
@ -14,12 +21,13 @@ const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM;
|
||||||
let LETTER_WIDTH: number;
|
let LETTER_WIDTH: number;
|
||||||
let CONTAINER_SIZE = 200;
|
let CONTAINER_SIZE = 200;
|
||||||
let TWO_LINES_OF_CHARACTERS: number;
|
let TWO_LINES_OF_CHARACTERS: number;
|
||||||
const defaultOptions = {
|
const defaultOptions: DisplayOptions = {
|
||||||
wrap: false,
|
wrap: false,
|
||||||
showTime: false,
|
showTime: false,
|
||||||
showDuplicates: false,
|
showDuplicates: false,
|
||||||
hasLogsWithErrors: false,
|
hasLogsWithErrors: false,
|
||||||
hasSampledLogs: false,
|
hasSampledLogs: false,
|
||||||
|
fontSize: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Virtualization', () => {
|
describe('Virtualization', () => {
|
||||||
|
|
@ -28,7 +36,7 @@ describe('Virtualization', () => {
|
||||||
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
|
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
|
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
|
||||||
init(createTheme());
|
init(createTheme(), 'default');
|
||||||
LETTER_WIDTH = measureTextWidth('e');
|
LETTER_WIDTH = measureTextWidth('e');
|
||||||
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
||||||
});
|
});
|
||||||
|
|
@ -56,17 +64,11 @@ describe('Virtualization', () => {
|
||||||
log.collapsed = true;
|
log.collapsed = true;
|
||||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
|
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
|
||||||
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0);
|
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0);
|
||||||
expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT);
|
expect(size).toBe((getTruncationLineCount() + 1) * LINE_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(
|
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime }, 0);
|
||||||
[log],
|
|
||||||
container,
|
|
||||||
[],
|
|
||||||
{ wrap: true, showTime, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false },
|
|
||||||
0
|
|
||||||
);
|
|
||||||
expect(size).toBe(SINGLE_LINE_HEIGHT);
|
expect(size).toBe(SINGLE_LINE_HEIGHT);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -147,4 +149,33 @@ describe('Virtualization', () => {
|
||||||
expect(size).toBe(TWO_LINES_HEIGHT);
|
expect(size).toBe(TWO_LINES_HEIGHT);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('With small font size', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
init(createTheme(), 'small');
|
||||||
|
LETTER_WIDTH = measureTextWidth('e');
|
||||||
|
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Measures a multi-line log line with displayed fields', () => {
|
||||||
|
const SMALL_LINE_HEIGHT = getLineHeight();
|
||||||
|
const SMALL_THREE_LINES_HEIGHT = 3 * SMALL_LINE_HEIGHT + PADDING_BOTTOM;
|
||||||
|
|
||||||
|
log = createLogLine({
|
||||||
|
labels: { place: 'very very long value for the displayed field that causes a new line' },
|
||||||
|
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
|
||||||
|
logLevel: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const size = getLogLineSize(
|
||||||
|
[log],
|
||||||
|
container,
|
||||||
|
['place', LOG_LINE_BODY_FIELD_NAME],
|
||||||
|
{ ...defaultOptions, wrap: true },
|
||||||
|
0
|
||||||
|
);
|
||||||
|
// Two lines for the log and one extra for the displayed fields
|
||||||
|
expect(size).toBe(SMALL_THREE_LINES_HEIGHT);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import ansicolor from 'ansicolor';
|
||||||
|
|
||||||
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
|
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||||
|
|
||||||
|
import { LogListFontSize } from './LogList';
|
||||||
import { LogListModel } from './processing';
|
import { LogListModel } from './processing';
|
||||||
|
|
||||||
let ctx: CanvasRenderingContext2D | null = null;
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
@ -11,13 +14,27 @@ let lineHeight = 22;
|
||||||
let measurementMode: 'canvas' | 'dom' = 'canvas';
|
let measurementMode: 'canvas' | 'dom' = 'canvas';
|
||||||
const iconWidth = 24;
|
const iconWidth = 24;
|
||||||
|
|
||||||
|
export const LOG_LIST_MIN_WIDTH = 35 * gridSize;
|
||||||
|
|
||||||
// Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body
|
// Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body
|
||||||
export const FIELD_GAP_MULTIPLIER = 1.5;
|
export const FIELD_GAP_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
export const getLineHeight = () => lineHeight;
|
export const getLineHeight = () => lineHeight;
|
||||||
|
|
||||||
export function init(theme: GrafanaTheme2) {
|
export function init(theme: GrafanaTheme2, fontSize: LogListFontSize) {
|
||||||
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`;
|
let fontSizePx = theme.typography.fontSize;
|
||||||
|
|
||||||
|
if (fontSize === 'default') {
|
||||||
|
lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
|
||||||
|
} else {
|
||||||
|
fontSizePx =
|
||||||
|
typeof theme.typography.bodySmall.fontSize === 'string' && theme.typography.bodySmall.fontSize.includes('rem')
|
||||||
|
? theme.typography.fontSize * parseFloat(theme.typography.bodySmall.fontSize)
|
||||||
|
: parseInt(theme.typography.bodySmall.fontSize, 10);
|
||||||
|
lineHeight = fontSizePx * theme.typography.bodySmall.lineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = `${fontSizePx}px ${theme.typography.fontFamilyMonospace}`;
|
||||||
const letterSpacing = theme.typography.body.letterSpacing;
|
const letterSpacing = theme.typography.body.letterSpacing;
|
||||||
|
|
||||||
initDOMmeasurement(font, letterSpacing);
|
initDOMmeasurement(font, letterSpacing);
|
||||||
|
|
@ -25,7 +42,6 @@ export function init(theme: GrafanaTheme2) {
|
||||||
|
|
||||||
gridSize = theme.spacing.gridSize;
|
gridSize = theme.spacing.gridSize;
|
||||||
paddingBottom = gridSize * 0.75;
|
paddingBottom = gridSize * 0.75;
|
||||||
lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
|
|
||||||
|
|
||||||
widthMap = new Map<number, number>();
|
widthMap = new Map<number, number>();
|
||||||
resetLogLineSizes();
|
resetLogLineSizes();
|
||||||
|
|
@ -115,7 +131,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
|
||||||
if (textLines.length === 1 && text.length < firstLineCharsLength) {
|
if (textLines.length === 1 && text.length < firstLineCharsLength) {
|
||||||
return {
|
return {
|
||||||
lines: 1,
|
lines: 1,
|
||||||
height: lineHeight + paddingBottom,
|
height: getLineHeight() + paddingBottom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +143,11 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
|
||||||
let delta = 0;
|
let delta = 0;
|
||||||
do {
|
do {
|
||||||
testLogLine = textLine.substring(start, start + logLineCharsLength - delta);
|
testLogLine = textLine.substring(start, start + logLineCharsLength - delta);
|
||||||
width = measureTextWidth(testLogLine);
|
let measuredLine = testLogLine;
|
||||||
|
if (logLines > 0) {
|
||||||
|
measuredLine.trimStart();
|
||||||
|
}
|
||||||
|
width = measureTextWidth(measuredLine);
|
||||||
delta += 1;
|
delta += 1;
|
||||||
} while (width >= availableWidth);
|
} while (width >= availableWidth);
|
||||||
if (beforeWidth) {
|
if (beforeWidth) {
|
||||||
|
|
@ -138,7 +158,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const height = logLines * lineHeight + paddingBottom;
|
const height = logLines * getLineHeight() + paddingBottom;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lines: logLines,
|
lines: logLines,
|
||||||
|
|
@ -146,7 +166,8 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DisplayOptions {
|
export interface DisplayOptions {
|
||||||
|
fontSize: LogListFontSize;
|
||||||
hasLogsWithErrors?: boolean;
|
hasLogsWithErrors?: boolean;
|
||||||
hasSampledLogs?: boolean;
|
hasSampledLogs?: boolean;
|
||||||
showDuplicates: boolean;
|
showDuplicates: boolean;
|
||||||
|
|
@ -158,7 +179,7 @@ export function getLogLineSize(
|
||||||
logs: LogListModel[],
|
logs: LogListModel[],
|
||||||
container: HTMLDivElement | null,
|
container: HTMLDivElement | null,
|
||||||
displayedFields: string[],
|
displayedFields: string[],
|
||||||
{ hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions,
|
{ fontSize, hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions,
|
||||||
index: number
|
index: number
|
||||||
) {
|
) {
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|
@ -166,15 +187,15 @@ export function getLogLineSize(
|
||||||
}
|
}
|
||||||
// !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 lineHeight + paddingBottom;
|
return getLineHeight() + paddingBottom;
|
||||||
}
|
}
|
||||||
// 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 (TRUNCATION_LINE_COUNT + 1) * lineHeight;
|
return (getTruncationLineCount() + 1) * getLineHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedSize = retrieveLogLineSize(logs[index].uid, container);
|
const storedSize = retrieveLogLineSize(logs[index].uid, container, fontSize);
|
||||||
if (storedSize) {
|
if (storedSize) {
|
||||||
return storedSize;
|
return storedSize;
|
||||||
}
|
}
|
||||||
|
|
@ -205,12 +226,12 @@ export function getLogLineSize(
|
||||||
textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure;
|
textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure;
|
||||||
}
|
}
|
||||||
if (!displayedFields.length) {
|
if (!displayedFields.length) {
|
||||||
textToMeasure += logs[index].body;
|
textToMeasure += ansicolor.strip(logs[index].body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth);
|
const { height } = 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 + lineHeight : height;
|
return logs[index].collapsed === false ? height + getLineHeight() : height;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogFieldDimension {
|
export interface LogFieldDimension {
|
||||||
|
|
@ -263,10 +284,10 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields:
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2/3 of the viewport height
|
// 2/3 of the viewport height
|
||||||
export const TRUNCATION_LINE_COUNT = Math.round(window.innerHeight / getLineHeight() / 1.5);
|
export const getTruncationLineCount = () => Math.round(window.innerHeight / getLineHeight() / 1.5);
|
||||||
export function getTruncationLength(container: HTMLDivElement | null) {
|
export function getTruncationLength(container: HTMLDivElement | null) {
|
||||||
const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth;
|
const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth;
|
||||||
return (availableWidth / measureTextWidth('e')) * TRUNCATION_LINE_COUNT;
|
return (availableWidth / measureTextWidth('e')) * getTruncationLineCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasUnderOrOverflow(
|
export function hasUnderOrOverflow(
|
||||||
|
|
@ -315,13 +336,13 @@ export function resetLogLineSizes() {
|
||||||
logLineSizesMap = new Map<string, number>();
|
logLineSizesMap = new Map<string, number>();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeLogLineSize(id: string, container: HTMLDivElement, height: number) {
|
export function storeLogLineSize(id: string, container: HTMLDivElement, height: number, fontSize: LogListFontSize) {
|
||||||
const key = `${id}_${getLogContainerWidth(container)}`;
|
const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`;
|
||||||
logLineSizesMap.set(key, height);
|
logLineSizesMap.set(key, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retrieveLogLineSize(id: string, container: HTMLDivElement) {
|
export function retrieveLogLineSize(id: string, container: HTMLDivElement, fontSize: LogListFontSize) {
|
||||||
const key = `${id}_${getLogContainerWidth(container)}`;
|
const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`;
|
||||||
return logLineSizesMap.get(key);
|
return logLineSizesMap.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export const LogsPanel = ({
|
||||||
logLineMenuCustomItems,
|
logLineMenuCustomItems,
|
||||||
enableInfiniteScrolling,
|
enableInfiniteScrolling,
|
||||||
onNewLogsReceived,
|
onNewLogsReceived,
|
||||||
|
fontSize,
|
||||||
...options
|
...options
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
|
|
@ -535,6 +536,7 @@ export const LogsPanel = ({
|
||||||
dedupStrategy={dedupStrategy}
|
dedupStrategy={dedupStrategy}
|
||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
enableLogDetails={enableLogDetails}
|
enableLogDetails={enableLogDetails}
|
||||||
|
fontSize={fontSize}
|
||||||
getFieldLinks={getFieldLinks}
|
getFieldLinks={getFieldLinks}
|
||||||
isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
|
isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
|
||||||
initialScrollPosition={initialScrollPosition}
|
initialScrollPosition={initialScrollPosition}
|
||||||
|
|
@ -551,6 +553,10 @@ export const LogsPanel = ({
|
||||||
onClickFilterOutLabel={
|
onClickFilterOutLabel={
|
||||||
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
|
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
|
||||||
}
|
}
|
||||||
|
onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined}
|
||||||
|
onClickFilterOutString={
|
||||||
|
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
|
||||||
|
}
|
||||||
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
|
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
|
||||||
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
|
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
|
||||||
onLogLineHover={onLogRowHover}
|
onLogLineHover={onLogRowHover}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,34 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||||
name: 'Enable infinite scrolling',
|
name: 'Enable infinite scrolling',
|
||||||
description: 'Experimental. Request more results by scrolling to the bottom of the logs list.',
|
description: 'Experimental. Request more results by scrolling to the bottom of the logs list.',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.featureToggles.newLogsPanel) {
|
||||||
|
builder
|
||||||
|
.addBooleanSwitch({
|
||||||
|
path: 'showControls',
|
||||||
|
name: 'Show controls',
|
||||||
|
description: 'Display controls to jump to the last or first log line, and filters by log level',
|
||||||
|
defaultValue: false,
|
||||||
})
|
})
|
||||||
|
.addRadio({
|
||||||
|
path: 'fontSize',
|
||||||
|
name: 'Font size',
|
||||||
|
description: '',
|
||||||
|
settings: {
|
||||||
|
options: [
|
||||||
|
{ value: 'default', label: 'Default' },
|
||||||
|
{
|
||||||
|
value: 'small',
|
||||||
|
label: 'Small',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
.addRadio({
|
.addRadio({
|
||||||
path: 'dedupStrategy',
|
path: 'dedupStrategy',
|
||||||
name: 'Deduplication',
|
name: 'Deduplication',
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ composableKinds: PanelCfg: {
|
||||||
sortOrder: common.LogsSortOrder
|
sortOrder: common.LogsSortOrder
|
||||||
dedupStrategy: common.LogsDedupStrategy
|
dedupStrategy: common.LogsDedupStrategy
|
||||||
enableInfiniteScrolling?: bool
|
enableInfiniteScrolling?: bool
|
||||||
|
fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
|
||||||
// TODO: figure out how to define callbacks
|
// TODO: figure out how to define callbacks
|
||||||
onClickFilterLabel?: _
|
onClickFilterLabel?: _
|
||||||
onClickFilterOutLabel?: _
|
onClickFilterOutLabel?: _
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface Options {
|
||||||
displayedFields?: Array<string>;
|
displayedFields?: Array<string>;
|
||||||
enableInfiniteScrolling?: boolean;
|
enableInfiniteScrolling?: boolean;
|
||||||
enableLogDetails: boolean;
|
enableLogDetails: boolean;
|
||||||
|
fontSize?: ('default' | 'small');
|
||||||
isFilterLabelActive?: unknown;
|
isFilterLabelActive?: unknown;
|
||||||
logLineMenuCustomItems?: unknown;
|
logLineMenuCustomItems?: unknown;
|
||||||
logRowMenuIconsAfter?: unknown;
|
logRowMenuIconsAfter?: unknown;
|
||||||
|
|
|
||||||
|
|
@ -7715,6 +7715,8 @@
|
||||||
},
|
},
|
||||||
"enable-highlighting": "Enable highlighting",
|
"enable-highlighting": "Enable highlighting",
|
||||||
"escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
|
"escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
|
||||||
|
"font-size-default": "Use small font size",
|
||||||
|
"font-size-small": "Use default font size",
|
||||||
"hide-timestamps": "Hide timestamps",
|
"hide-timestamps": "Hide timestamps",
|
||||||
"hide-unique-labels": "Hide unique labels",
|
"hide-unique-labels": "Hide unique labels",
|
||||||
"newest-first": "Sorted by newest logs first - Click to show oldest first",
|
"newest-first": "Sorted by newest logs first - Click to show oldest first",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue