From db83b4ef17e600bc0b1f0b8625187f53d74d98ae Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 10 Jun 2025 11:59:01 +0200 Subject: [PATCH] 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 --- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 1 + .../logs/components/ControlledLogRows.tsx | 1 + .../app/features/logs/components/LogRows.tsx | 2 +- .../logs/components/panel/InfiniteScroll.tsx | 4 +- .../logs/components/panel/LogLine.test.tsx | 89 ++++++++++--- .../logs/components/panel/LogLine.tsx | 41 ++++-- .../logs/components/panel/LogLineDetails.tsx | 69 +++++----- .../logs/components/panel/LogList.test.tsx | 120 +++++++++++++++++- .../logs/components/panel/LogList.tsx | 97 ++++++++++++-- .../logs/components/panel/LogListContext.tsx | 80 ++++++++++-- .../components/panel/LogListControls.test.tsx | 22 ++++ .../logs/components/panel/LogListControls.tsx | 24 ++++ .../panel/__mocks__/LogListContext.tsx | 6 + .../logs/components/panel/processing.test.ts | 6 +- .../logs/components/panel/usePopoverMenu.ts | 115 +++++++++++++++++ .../components/panel/virtualization.test.ts | 53 ++++++-- .../logs/components/panel/virtualization.ts | 59 ++++++--- public/app/plugins/panel/logs/LogsPanel.tsx | 6 + public/app/plugins/panel/logs/module.tsx | 29 ++++- public/app/plugins/panel/logs/panelcfg.cue | 1 + public/app/plugins/panel/logs/panelcfg.gen.ts | 1 + public/locales/en-US/grafana.json | 2 + 22 files changed, 711 insertions(+), 117 deletions(-) create mode 100644 public/app/features/logs/components/panel/usePopoverMenu.ts diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 6b5361f74e1..9129f7c3d85 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -18,6 +18,7 @@ export interface Options { displayedFields?: Array; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; + fontSize?: ('default' | 'small'); isFilterLabelActive?: unknown; logLineMenuCustomItems?: unknown; logRowMenuIconsAfter?: unknown; diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index 240e96e6b17..4ddaeb13648 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -72,6 +72,7 @@ export const ControlledLogRows = forwardRef void; loadMore?: (range: AbsoluteTimeRange) => void; logs: LogListModel[]; - onClick: (log: LogListModel) => void; + onClick: (e: MouseEvent, log: LogListModel) => void; scrollElement: HTMLDivElement | null; setInitialScrollPosition: () => void; showTime: boolean; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index baa0a16e972..a7d22497d13 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -7,6 +7,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../__mocks__/logRow'; import { getStyles, LogLine, Props } from './LogLine'; +import { LogListFontSize } from './LogList'; import { LogListContextProvider } from './LogListContext'; import { defaultProps } from './__mocks__/LogListContext'; import { LogListModel } from './processing'; @@ -27,12 +28,14 @@ const contextProps = { sortOrder: LogsSortOrder.Ascending, wrapLogMessage: false, }; +const fontSizes: LogListFontSize[] = ['default', 'small']; -describe('LogLine', () => { +describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { let log: LogListModel, defaultProps: Props; beforeEach(() => { log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); contextProps.logs = [log]; + contextProps.fontSize = fontSize; defaultProps = { displayedFields: [], index: 0, @@ -46,26 +49,42 @@ describe('LogLine', () => { }); test('Renders a log line', () => { - render(); + render( + + + + ); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); expect(screen.getByText('log message 1')).toBeInTheDocument(); }); test('Renders a log line with no timestamp', () => { - render(); + render( + + + + ); expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument(); expect(screen.getByText('log message 1')).toBeInTheDocument(); }); test('Renders a log line with displayed fields', () => { - render(); + render( + + + + ); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); expect(screen.queryByText(log.body)).not.toBeInTheDocument(); expect(screen.getByText('luna')).toBeInTheDocument(); }); test('Renders a log line with body displayed fields', () => { - render(); + render( + + + + ); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); expect(screen.getByText('log message 1')).toBeInTheDocument(); expect(screen.getByText('luna')).toBeInTheDocument(); @@ -143,7 +162,11 @@ describe('LogLine', () => { describe('Log line menu', () => { test('Renders a log line menu', async () => { - render(); + render( + + + + ); expect(screen.queryByText('Copy log line')).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText('Log menu')); expect(screen.getByText('Copy log line')).toBeInTheDocument(); @@ -156,7 +179,11 @@ describe('LogLine', () => { }); test('Highlights relevant tokens in the log line', () => { - render(); + render( + + + + ); expect(screen.getByText('place')).toBeInTheDocument(); expect(screen.getByText('1ms')).toBeInTheDocument(); expect(screen.getByText('3 KB')).toBeInTheDocument(); @@ -196,7 +223,11 @@ describe('LogLine', () => { }); test('Logs are not collapsed by default', () => { - render(); + render( + + + + ); expect(screen.queryByText('show less')).not.toBeInTheDocument(); expect(screen.queryByText('show more')).not.toBeInTheDocument(); }); @@ -204,11 +235,13 @@ describe('LogLine', () => { test('Logs are not collapsible when unwrapped', () => { log.collapsed = true; render( - + + + ); expect(screen.queryByText('show less')).not.toBeInTheDocument(); expect(screen.queryByText('show more')).not.toBeInTheDocument(); @@ -216,7 +249,11 @@ describe('LogLine', () => { test('Long logs can be collapsed and expanded', async () => { log.collapsed = true; - render(); + render( + + + + ); expect(screen.getByText('show more')).toBeVisible(); await userEvent.click(screen.getByText('show more')); 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 () => { log.collapsed = true; const onOverflow = jest.fn(); - render(); + render( + + + + ); await userEvent.click(await screen.findByText('show more')); await userEvent.click(await screen.findByText('show less')); expect(onOverflow).toHaveBeenCalledTimes(2); @@ -239,7 +280,11 @@ describe('LogLine', () => { expect(screen.getByText('show more')).toBeVisible(); log.collapsed = undefined; - rerender(); + rerender( + + + + ); expect(screen.queryByText('show more')).not.toBeInTheDocument(); expect(screen.queryByText('show less')).not.toBeInTheDocument(); @@ -247,10 +292,18 @@ describe('LogLine', () => { test('Syncs the collapsed state with wrapping changes', async () => { log.collapsed = true; - const { rerender } = render(); + const { rerender } = render( + + + + ); expect(screen.getByText('show more')).toBeVisible(); - rerender(); + rerender( + + + + ); expect(screen.queryByText('show more')).not.toBeInTheDocument(); expect(screen.queryByText('show less')).not.toBeInTheDocument(); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index d0b69d952ad..5d7528458fe 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -1,5 +1,5 @@ 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 { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; @@ -17,7 +17,7 @@ import { hasUnderOrOverflow, getLineHeight, LogFieldDimension, - TRUNCATION_LINE_COUNT, + getTruncationLineCount, } from './virtualization'; export interface Props { @@ -27,7 +27,7 @@ export interface Props { showTime: boolean; style: CSSProperties; styles: LogLineStyles; - onClick: (log: LogListModel) => void; + onClick: (e: MouseEvent, log: LogListModel) => void; onOverflow?: (index: number, id: string, height?: number) => void; variant?: 'infinite-scroll'; wrapLogMessage: boolean; @@ -45,8 +45,15 @@ export const LogLine = ({ variant, wrapLogMessage, }: Props) => { - const { detailsDisplayed, dedupStrategy, enableLogDetails, hasLogsWithErrors, hasSampledLogs, onLogLineHover } = - useLogListContext(); + const { + detailsDisplayed, + dedupStrategy, + enableLogDetails, + fontSize, + hasLogsWithErrors, + hasSampledLogs, + onLogLineHover, + } = useLogListContext(); const [collapsed, setCollapsed] = useState( wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined ); @@ -85,16 +92,19 @@ export const LogLine = ({ }, [collapsed, index, log, onOverflow]); const { t } = useTranslate(); - const handleClick = useCallback(() => { - onClick(log); - }, [log, onClick]); + const handleClick = useCallback( + (e: MouseEvent) => { + onClick(e, log); + }, + [log, onClick] + ); const detailsShown = detailsDisplayed(log); return (
{ +const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { return ( <> {showTime && {log.timestamp}} @@ -217,7 +228,9 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp )} ); -}; +}); + +Log.displayName = 'Log'; const LogLineBody = ({ log }: { log: LogListModel }) => { const { syntaxHighlighting } = useLogListContext(); @@ -266,6 +279,7 @@ export const getStyles = (theme: GrafanaTheme2) => { flexDirection: 'row', fontFamily: theme.typography.fontFamilyMonospace, fontSize: theme.typography.fontSize, + lineHeight: theme.typography.body.lineHeight, wordBreak: 'break-all', '&:hover': { 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, }, }), + fontSizeSmall: css({ + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, + }), detailsDisplayed: css({ 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({ - maxHeight: `${TRUNCATION_LINE_COUNT * getLineHeight()}px`, overflow: 'hidden', }), expandCollapseControl: css({ diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 9e390ccafe8..48c969ba2ed 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -12,6 +12,7 @@ import { getLogRowStyles } from '../getLogRowStyles'; import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; +import { LOG_LIST_MIN_WIDTH } from './virtualization'; interface Props { containerElement: HTMLDivElement; @@ -51,44 +52,50 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize onResize(); }, [onResize, setDetailsWidth]); + const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; + return (
- - - - - -
+
+ + + + + +
+
); @@ -96,6 +103,10 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize const getStyles = (theme: GrafanaTheme2) => ({ container: css({ + overflow: 'auto', + height: '100%', + }), + scrollContainer: css({ overflow: 'auto', position: 'relative', height: '100%', diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 5521597710d..973cbf95ee2 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils'; import { createLogRow } from '../__mocks__/logRow'; import { LogList, Props } from './LogList'; @@ -11,9 +12,23 @@ jest.mock('@grafana/runtime', () => { return { ...jest.requireActual('@grafana/runtime'), 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', () => { let logs: LogRowModel[], defaultProps: Props; beforeEach(() => { @@ -26,7 +41,7 @@ describe('LogList', () => { containerElement: document.createElement('div'), dedupStrategy: LogsDedupStrategy.none, displayedFields: [], - enableLogDetails: false, + enableLogDetails: true, logs, showControls: false, showTime: false, @@ -114,4 +129,107 @@ describe('LogList', () => { spy.mockRestore(); }); + + describe('Popover menu', () => { + function setup(overrides: Partial = {}) { + return render( + + ); + } + 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(); + }); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index b84496e7f4d..b9aca3438ba 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { debounce } from 'lodash'; 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 { @@ -10,6 +10,7 @@ import { DataFrame, EventBus, EventBusSrv, + GrafanaTheme2, LogLevel, LogRowModel, LogsDedupStrategy, @@ -18,7 +19,9 @@ import { store, TimeRange, } 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 { InfiniteScroll } from './InfiniteScroll'; @@ -28,6 +31,7 @@ import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; import { LogListControls } from './LogListControls'; import { preProcessLogs, LogListModel } from './processing'; +import { usePopoverMenu } from './usePopoverMenu'; import { calculateFieldDimensions, getLogLineSize, @@ -46,6 +50,7 @@ export interface Props { enableLogDetails: boolean; eventBus?: EventBus; filterLevels?: LogLevel[]; + fontSize?: LogListFontSize; getFieldLinks?: GetFieldLinksFn; getRowContextQuery?: GetRowContextQueryFn; grammar?: Grammar; @@ -82,6 +87,8 @@ export interface Props { wrapLogMessage: boolean; } +export type LogListFontSize = 'default' | 'small'; + export type LogListControlOptions = LogListState; type LogListComponentProps = Omit< @@ -105,6 +112,8 @@ export const LogList = ({ enableLogDetails, eventBus, filterLevels, + logOptionsStorageKey, + fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default', getFieldLinks, getRowContextQuery, grammar, @@ -113,7 +122,6 @@ export const LogList = ({ loading, loadMore, logLineMenuCustomItems, - logOptionsStorageKey, logs, logsMeta, logSupportsContext, @@ -148,6 +156,7 @@ export const LogList = ({ displayedFields={displayedFields} enableLogDetails={enableLogDetails} filterLevels={filterLevels} + fontSize={fontSize} getRowContextQuery={getRowContextQuery} isLabelFilterActive={isLabelFilterActive} logs={logs} @@ -211,9 +220,12 @@ const LogListComponent = ({ displayedFields, dedupStrategy, filterLevels, + fontSize, forceEscape, hasLogsWithErrors, hasSampledLogs, + onClickFilterString, + onClickFilterOutString, permalinkedLogId, showDetails, showTime, @@ -236,8 +248,18 @@ const LogListComponent = ({ () => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)), [displayedFields, processedLogs, wrapLogMessage] ); - const styles = getStyles(dimensions, { showTime }); + const styles = getStyles(dimensions, { showTime }, theme); const widthContainer = wrapperRef.current ?? containerElement; + const { + closePopoverMenu, + handleTextSelection, + onDisableCancel, + onDisableConfirm, + onDisablePopoverMenu, + popoverState, + showDisablePopoverOptions, + } = usePopoverMenu(wrapperRef.current); + const { t } = useTranslate(); const debouncedResetAfterIndex = useMemo(() => { return debounce((index: number) => { @@ -247,8 +269,8 @@ const LogListComponent = ({ }, []); useEffect(() => { - initVirtualization(theme); - }, [theme]); + initVirtualization(theme, fontSize); + }, [fontSize, theme]); useEffect(() => { const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => @@ -299,12 +321,15 @@ const LogListComponent = ({ const handleOverflow = useCallback( (index: number, id: string, height?: number) => { 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; debouncedResetAfterIndex(overflowIndexRef.current); }, - [debouncedResetAfterIndex, widthContainer] + [debouncedResetAfterIndex, fontSize, widthContainer] ); const handleScrollPosition = useCallback(() => { @@ -324,14 +349,14 @@ const LogListComponent = ({ } const handleLogLineClick = useCallback( - (log: LogListModel) => { - // Let people select text - if (document.getSelection()?.toString()) { + (e: MouseEvent, log: LogListModel) => { + if (handleTextSelection(e, log)) { + // Event handled by the parent. return; } toggleDetails(log); }, - [toggleDetails] + [handleTextSelection, toggleDetails] ); const handleLogDetailsResize = useCallback(() => { @@ -347,6 +372,39 @@ const LogListComponent = ({ return (
+ {popoverState.selection && popoverState.selectedRow && ( + + )} + {showDisablePopoverOptions && ( + + + You are about to disable the logs filter menu. To re-enable it, select text in a log line while + holding the alt key. + +
+ + alt+select to enable again +
+ + } + confirmText={t('logs.log-rows.disable-popover.confirm', 'Confirm')} + icon="exclamation-triangle" + onConfirm={onDisableConfirm} + onDismiss={onDisableCancel} + /> + )} index > 0); return { logList: css({ @@ -411,9 +470,21 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo }), logListContainer: css({ display: 'flex', + // Minimum width to prevent rendering issues and a sausage-like logs panel. + minWidth: theme.spacing(35), }), logListWrapper: css({ 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), }), }; } diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 6c1f8b9c57f..5eb6d8a53bd 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -1,3 +1,4 @@ +import { debounce } from 'lodash'; import { createContext, Dispatch, @@ -26,7 +27,9 @@ import { PopoverContent } from '@grafana/ui'; import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; +import { LogListFontSize } from './LogList'; import { LogListModel } from './processing'; +import { LOG_LIST_MIN_WIDTH } from './virtualization'; export interface LogListContextData extends Omit { closeDetails: () => void; @@ -42,6 +45,7 @@ export interface LogListContextData extends Omit void; setDetailsWidth: (width: number) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; + setFontSize: (size: LogListFontSize) => void; setForceEscape: (forceEscape: boolean) => void; setLogListState: Dispatch>; setPinnedLogs: (pinnedlogs: string[]) => void; @@ -65,10 +69,12 @@ export const LogListContext = createContext({ downloadLogs: () => {}, enableLogDetails: false, filterLevels: [], + fontSize: 'default', hasUnescapedContent: false, setDedupStrategy: () => {}, setDetailsWidth: () => {}, setFilterLevels: () => {}, + setFontSize: () => {}, setForceEscape: () => {}, setLogListState: () => {}, setPinnedLogs: () => {}, @@ -108,6 +114,7 @@ export const useLogIsPermalinked = (log: LogListModel) => { export type LogListState = Pick< LogListContextData, | 'dedupStrategy' + | 'fontSize' | 'forceEscape' | 'filterLevels' | 'hasUnescapedContent' @@ -123,11 +130,13 @@ export type LogListState = Pick< export interface Props { app: CoreApp; children?: ReactNode; + // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx containerElement?: HTMLDivElement; dedupStrategy: LogsDedupStrategy; displayedFields: string[]; enableLogDetails: boolean; filterLevels?: LogLevel[]; + fontSize: LogListFontSize; forceEscape?: boolean; hasUnescapedContent?: boolean; getRowContextQuery?: GetRowContextQueryFn; @@ -169,6 +178,7 @@ export const LogListContextProvider = ({ dedupStrategy, displayedFields, filterLevels, + fontSize, forceEscape = false, hasUnescapedContent, isLabelFilterActive, @@ -205,6 +215,7 @@ export const LogListContextProvider = ({ dedupStrategy, filterLevels: filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), + fontSize, forceEscape, hasUnescapedContent, pinnedLogs, @@ -216,6 +227,7 @@ export const LogListContextProvider = ({ wrapLogMessage, }); const [showDetails, setShowDetails] = useState([]); + const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey)); useEffect(() => { // Props are updated in the context only of the panel is being externally controlled. @@ -254,6 +266,10 @@ export const LogListContextProvider = ({ } }, [filterLevels, logListState]); + useEffect(() => { + setLogListState((logListState) => ({ ...logListState, fontSize })); + }, [fontSize]); + useEffect(() => { if (logListState.hasUnescapedContent !== hasUnescapedContent) { setLogListState({ ...logListState, hasUnescapedContent }); @@ -278,6 +294,17 @@ export const LogListContextProvider = ({ } }, [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( (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), [showDetails] @@ -291,6 +318,16 @@ export const LogListContextProvider = ({ [logListState, onLogOptionsChange] ); + const setFontSize = useCallback( + (fontSize: LogListFontSize) => { + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.fontSize`, fontSize); + } + setLogListState((logListState) => ({ ...logListState, fontSize })); + }, + [logOptionsStorageKey] + ); + const setForceEscape = useCallback( (forceEscape: boolean) => { setLogListState({ ...logListState, forceEscape }); @@ -413,22 +450,24 @@ export const LogListContextProvider = ({ const setDetailsWidth = useCallback( (width: number) => { - if (!logOptionsStorageKey) { + if (!logOptionsStorageKey || !containerElement) { return; } + + const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; + if (width > maxWidth) { + return; + } + store.set(`${logOptionsStorageKey}.detailsWidth`, width); + setDetailsWidthState(width); }, - [logOptionsStorageKey] + [containerElement, logOptionsStorageKey] ); const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(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 ( = containerElement.clientWidth || detailsWidth > maxWidth) { + return currentWidth ?? defaultWidth; + } + return detailsWidth; +} diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index 63541e86c3a..d33754400ff 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -2,22 +2,26 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { downloadLogs } from '../../utils'; import { createLogRow } from '../__mocks__/logRow'; +import { LogListFontSize } from './LogList'; import { LogListContextProvider } from './LogListContext'; import { LogListControls } from './LogListControls'; import { ScrollToLogsEvent } from './virtualization'; jest.mock('../../utils'); +const fontSize: LogListFontSize = 'default'; const contextProps = { app: CoreApp.Unknown, containerElement: document.createElement('div'), dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], enableLogDetails: false, + fontSize, logs: [], showControls: true, showTime: false, @@ -237,6 +241,24 @@ describe('LogListControls', () => { expect(screen.getByLabelText('Collapse JSON logs')); }); + test('Controls font size', async () => { + const originalValue = config.featureToggles.newLogsPanel; + config.featureToggles.newLogsPanel = true; + + render( + + + + ); + 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([ ['txt', 'text'], ['json', 'json'], diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 56f56a8114d..1bd14a74162 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -43,11 +43,13 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) dedupStrategy, downloadLogs, filterLevels, + fontSize, forceEscape, hasUnescapedContent, prettifyJSON, setDedupStrategy, setFilterLevels, + setFontSize, setForceEscape, setPrettifyJSON, setShowTime, @@ -99,6 +101,14 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) [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(() => { reportInteraction('logs_log_list_controls_show_time_clicked', { show_time: !showTime, @@ -324,6 +334,20 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) size="lg" /> )} + {config.featureToggles.newLogsPanel && ( + + )} {hasUnescapedContent && ( ({ downloadLogs: () => {}, enableLogDetails: false, filterLevels: [], + fontSize: 'default', hasUnescapedContent: false, setDedupStrategy: () => {}, setDetailsWidth: () => {}, setFilterLevels: () => {}, + setFontSize: () => {}, setForceEscape: () => {}, setLogListState: () => {}, setPinnedLogs: () => {}, @@ -59,6 +61,7 @@ export const useLogIsPermalinked = (log: LogListModel) => { export const defaultValue: LogListContextData = { setDedupStrategy: jest.fn(), setFilterLevels: jest.fn(), + setFontSize: jest.fn(), setForceEscape: jest.fn(), setLogListState: jest.fn(), setPinnedLogs: jest.fn(), @@ -74,6 +77,7 @@ export const defaultValue: LogListContextData = { downloadLogs: jest.fn(), enableLogDetails: false, filterLevels: [], + fontSize: 'default', setDetailsWidth: jest.fn(), showDetails: [], toggleDetails: jest.fn(), @@ -92,6 +96,7 @@ export const defaultProps: Props = { displayedFields: [], enableLogDetails: false, filterLevels: [], + fontSize: 'default', getRowContextQuery: jest.fn(), logSupportsContext: jest.fn(), logs: [], @@ -157,6 +162,7 @@ export const LogListContextProvider = ({ pinnedLogs, setDedupStrategy: jest.fn(), setFilterLevels: jest.fn(), + setFontSize: jest.fn(), setForceEscape: jest.fn(), setLogListState: jest.fn(), setPinnedLogs: jest.fn(), diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 2a23a088570..0d1f7d607d4 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -3,12 +3,14 @@ import { createTheme, Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, to import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine, createLogRow } from '../__mocks__/logRow'; +import { LogListFontSize } from './LogList'; import { LogListModel, preProcessLogs } from './processing'; import { getTruncationLength, init } from './virtualization'; describe('preProcessLogs', () => { let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; let processedLogs: LogListModel[]; + const fontSizes: LogListFontSize[] = ['default', 'small']; beforeEach(() => { 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); }); - describe('Collapsible log lines', () => { + describe.each(fontSizes)('Collapsible log lines', (fontSize: LogListFontSize) => { let longLog: LogListModel, entry: string, container: HTMLDivElement; beforeEach(() => { - init(createTheme()); + init(createTheme(), fontSize); container = document.createElement('div'); jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); entry = new Array(2 * getTruncationLength(null)).fill('e').join(''); diff --git a/public/app/features/logs/components/panel/usePopoverMenu.ts b/public/app/features/logs/components/panel/usePopoverMenu.ts new file mode 100644 index 00000000000..df3ba12ae98 --- /dev/null +++ b/public/app/features/logs/components/panel/usePopoverMenu.ts @@ -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({ + 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, 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, + }; +}; diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts index 1c09c09a32a..575b6abeffa 100644 --- a/public/app/features/logs/components/panel/virtualization.test.ts +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -4,7 +4,14 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../__mocks__/logRow'; 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 LINE_HEIGHT = getLineHeight(); @@ -14,12 +21,13 @@ const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM; let LETTER_WIDTH: number; let CONTAINER_SIZE = 200; let TWO_LINES_OF_CHARACTERS: number; -const defaultOptions = { +const defaultOptions: DisplayOptions = { wrap: false, showTime: false, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false, + fontSize: 'default', }; describe('Virtualization', () => { @@ -28,7 +36,7 @@ describe('Virtualization', () => { log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); container = document.createElement('div'); jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); - init(createTheme()); + init(createTheme(), 'default'); LETTER_WIDTH = measureTextWidth('e'); TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; }); @@ -56,17 +64,11 @@ describe('Virtualization', () => { log.collapsed = true; jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); 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) => { - const size = getLogLineSize( - [log], - container, - [], - { wrap: true, showTime, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false }, - 0 - ); + const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime }, 0); expect(size).toBe(SINGLE_LINE_HEIGHT); }); @@ -147,4 +149,33 @@ describe('Virtualization', () => { 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); + }); + }); }); diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 76221b71a33..3e9c819f49b 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -1,7 +1,10 @@ +import ansicolor from 'ansicolor'; + import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { LogListFontSize } from './LogList'; import { LogListModel } from './processing'; let ctx: CanvasRenderingContext2D | null = null; @@ -11,13 +14,27 @@ let lineHeight = 22; let measurementMode: 'canvas' | 'dom' = 'canvas'; 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 export const FIELD_GAP_MULTIPLIER = 1.5; export const getLineHeight = () => lineHeight; -export function init(theme: GrafanaTheme2) { - const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`; +export function init(theme: GrafanaTheme2, fontSize: LogListFontSize) { + 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; initDOMmeasurement(font, letterSpacing); @@ -25,7 +42,6 @@ export function init(theme: GrafanaTheme2) { gridSize = theme.spacing.gridSize; paddingBottom = gridSize * 0.75; - lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight; widthMap = new Map(); resetLogLineSizes(); @@ -115,7 +131,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = if (textLines.length === 1 && text.length < firstLineCharsLength) { return { lines: 1, - height: lineHeight + paddingBottom, + height: getLineHeight() + paddingBottom, }; } @@ -127,7 +143,11 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = let delta = 0; do { testLogLine = textLine.substring(start, start + logLineCharsLength - delta); - width = measureTextWidth(testLogLine); + let measuredLine = testLogLine; + if (logLines > 0) { + measuredLine.trimStart(); + } + width = measureTextWidth(measuredLine); delta += 1; } while (width >= availableWidth); 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 { lines: logLines, @@ -146,7 +166,8 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = }; } -interface DisplayOptions { +export interface DisplayOptions { + fontSize: LogListFontSize; hasLogsWithErrors?: boolean; hasSampledLogs?: boolean; showDuplicates: boolean; @@ -158,7 +179,7 @@ export function getLogLineSize( logs: LogListModel[], container: HTMLDivElement | null, displayedFields: string[], - { hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, + { fontSize, hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, index: number ) { if (!container) { @@ -166,15 +187,15 @@ export function getLogLineSize( } // !logs[index] means the line is not yet loaded by infinite scrolling 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 logs[index].updateCollapsedState(displayedFields, container); 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) { return storedSize; } @@ -205,12 +226,12 @@ export function getLogLineSize( textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure; } if (!displayedFields.length) { - textToMeasure += logs[index].body; + textToMeasure += ansicolor.strip(logs[index].body); } const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); // When the log is collapsed, add an extra line for the expand/collapse control - return logs[index].collapsed === false ? height + lineHeight : height; + return logs[index].collapsed === false ? height + getLineHeight() : height; } export interface LogFieldDimension { @@ -263,10 +284,10 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: }; // 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) { const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth; - return (availableWidth / measureTextWidth('e')) * TRUNCATION_LINE_COUNT; + return (availableWidth / measureTextWidth('e')) * getTruncationLineCount(); } export function hasUnderOrOverflow( @@ -315,13 +336,13 @@ export function resetLogLineSizes() { logLineSizesMap = new Map(); } -export function storeLogLineSize(id: string, container: HTMLDivElement, height: number) { - const key = `${id}_${getLogContainerWidth(container)}`; +export function storeLogLineSize(id: string, container: HTMLDivElement, height: number, fontSize: LogListFontSize) { + const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`; logLineSizesMap.set(key, height); } -export function retrieveLogLineSize(id: string, container: HTMLDivElement) { - const key = `${id}_${getLogContainerWidth(container)}`; +export function retrieveLogLineSize(id: string, container: HTMLDivElement, fontSize: LogListFontSize) { + const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`; return logLineSizesMap.get(key); } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 6690f392ca9..b1b6a1a8285 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -151,6 +151,7 @@ export const LogsPanel = ({ logLineMenuCustomItems, enableInfiniteScrolling, onNewLogsReceived, + fontSize, ...options }, id, @@ -535,6 +536,7 @@ export const LogsPanel = ({ dedupStrategy={dedupStrategy} displayedFields={displayedFields} enableLogDetails={enableLogDetails} + fontSize={fontSize} getFieldLinks={getFieldLinks} isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} initialScrollPosition={initialScrollPosition} @@ -551,6 +553,10 @@ export const LogsPanel = ({ onClickFilterOutLabel={ isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel } + onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined} + onClickFilterOutString={ + isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined + } onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} onLogLineHover={onLogRowHover} diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index c07595ae0bf..5ee555bb09a 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -54,7 +54,34 @@ export const plugin = new PanelPlugin(LogsPanel) name: 'Enable infinite scrolling', description: 'Experimental. Request more results by scrolling to the bottom of the logs list.', 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({ path: 'dedupStrategy', name: 'Deduplication', diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index c235f43abcf..821bc66321a 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -38,6 +38,7 @@ composableKinds: PanelCfg: { sortOrder: common.LogsSortOrder dedupStrategy: common.LogsDedupStrategy enableInfiniteScrolling?: bool + fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small") // TODO: figure out how to define callbacks onClickFilterLabel?: _ onClickFilterOutLabel?: _ diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index 0866170944a..9a9a47b82d1 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -16,6 +16,7 @@ export interface Options { displayedFields?: Array; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; + fontSize?: ('default' | 'small'); isFilterLabelActive?: unknown; logLineMenuCustomItems?: unknown; logRowMenuIconsAfter?: unknown; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 21766b55b13..279b5f77268 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7715,6 +7715,8 @@ }, "enable-highlighting": "Enable highlighting", "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-unique-labels": "Hide unique labels", "newest-first": "Sorted by newest logs first - Click to show oldest first",