diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index b20b0652c3b..9bafaab5116 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1151,6 +1151,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { filterLevels={filterLevels} getFieldLinks={getFieldLinks} getRowContextQuery={getRowContextQuery} + loading={loading} loadMore={loadMoreLogs} logOptionsStorageKey={SETTING_KEY_ROOT} logs={dedupedRows} diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index e09d67577e4..1113944e867 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -23,7 +23,7 @@ interface ChildrenProps { interface Props { children: (props: ChildrenProps) => ReactNode; displayedFields: string[]; - handleOverflow: (index: number, id: string, height: number) => void; + handleOverflow: (index: number, id: string, height?: number) => void; loadMore?: (range: AbsoluteTimeRange) => void; logs: LogListModel[]; scrollElement: HTMLDivElement | null; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index be0b99a2791..a080d3a50df 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -9,8 +9,10 @@ import { createLogLine } from '../__mocks__/logRow'; import { getStyles, LogLine } from './LogLine'; import { LogListContextProvider } from './LogListContext'; import { LogListModel } from './processing'; +import { getTruncationLength } from './virtualization'; jest.mock('./LogListContext'); +jest.mock('./virtualization'); const theme = createTheme(); const styles = getStyles(theme); @@ -97,6 +99,25 @@ describe('LogLine', () => { expect(screen.getByText('luna')).toBeInTheDocument(); }); + test('Reports mouse over events', async () => { + const onLogLineHover = jest.fn(); + render( + + + + ); + await userEvent.hover(screen.getByText('log message 1')); + expect(onLogLineHover).toHaveBeenCalledTimes(1); + }); + describe('Log line menu', () => { test('Renders a log line menu', async () => { render( @@ -180,4 +201,85 @@ describe('LogLine', () => { expect(screen.queryByText(log.entry)).not.toBeInTheDocument(); }); }); + + describe('Collapsible log lines', () => { + beforeEach(() => { + log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); + jest.mocked(getTruncationLength).mockReturnValue(5); + }); + + test('Logs are not collapsed by default', () => { + render( + + ); + expect(screen.queryByText('show less')).not.toBeInTheDocument(); + expect(screen.queryByText('show more')).not.toBeInTheDocument(); + }); + + 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(); + }); + + test('Long logs can be collapsed and expanded', async () => { + log.collapsed = true; + render( + + ); + expect(screen.getByText('show more')).toBeVisible(); + await userEvent.click(screen.getByText('show more')); + expect(await screen.findByText('show less')).toBeInTheDocument(); + await userEvent.click(screen.getByText('show less')); + expect(await screen.findByText('show more')).toBeInTheDocument(); + }); + + test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => { + log.collapsed = true; + const onOverflow = jest.fn(); + render( + + ); + await userEvent.click(await screen.findByText('show more')); + await userEvent.click(await screen.findByText('show less')); + expect(onOverflow).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index db563f050bf..53972f6ff7f 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -1,8 +1,10 @@ import { css } from '@emotion/css'; -import { CSSProperties, useCallback, useEffect, useRef } from 'react'; +import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; +import { Button } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LogMessageAnsi } from '../LogMessageAnsi'; @@ -10,7 +12,13 @@ import { LogMessageAnsi } from '../LogMessageAnsi'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; -import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight, LogFieldDimension } from './virtualization'; +import { + FIELD_GAP_MULTIPLIER, + hasUnderOrOverflow, + getLineHeight, + LogFieldDimension, + TRUNCATION_LINE_COUNT, +} from './virtualization'; interface Props { displayedFields: string[]; @@ -19,7 +27,7 @@ interface Props { showTime: boolean; style: CSSProperties; styles: LogLineStyles; - onOverflow?: (index: number, id: string, height: number) => void; + onOverflow?: (index: number, id: string, height?: number) => void; variant?: 'infinite-scroll'; wrapLogMessage: boolean; } @@ -36,41 +44,78 @@ export const LogLine = ({ wrapLogMessage, }: Props) => { const { onLogLineHover } = useLogListContext(); + const [collapsed, setCollapsed] = useState( + wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined + ); const logLineRef = useRef(null); const pinned = useLogIsPinned(log); - const handleMouseOver = useCallback(() => { - onLogLineHover?.(log); - }, [log, onLogLineHover]); - useEffect(() => { if (!onOverflow || !logLineRef.current) { return; } const calculatedHeight = typeof style.height === 'number' ? style.height : undefined; - const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight); + const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight, log.collapsed); if (actualHeight) { onOverflow(index, log.uid, actualHeight); } - }, [index, log.uid, onOverflow, style.height]); + }, [index, log.collapsed, log.uid, onOverflow, style.height]); + + const handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]); + + const handleExpandCollapse = useCallback(() => { + const newState = !collapsed; + setCollapsed(newState); + log.setCollapsedState(newState); + onOverflow?.(index, log.uid); + }, [collapsed, index, log, onOverflow]); return ( -
- -
- +
+
+ +
+ +
+ {collapsed === true && ( +
+ +
+ )} + {collapsed === false && ( +
+ +
+ )}
); }; @@ -99,7 +144,7 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp ) : ( - {getDisplayedFieldValue(field, log)} + {log.getDisplayedFieldValue(field)} ) ) @@ -131,20 +176,6 @@ const LogLineBody = ({ log }: { log: LogListModel }) => { return ; }; -export function getDisplayedFieldValue(fieldName: string, log: LogListModel): string { - if (fieldName === LOG_LINE_BODY_FIELD_NAME) { - return log.body; - } - if (log.labels[fieldName] != null) { - return log.labels[fieldName]; - } - const field = log.fields.find((field) => { - return field.keys[0] === fieldName; - }); - - return field ? field.values.toString() : ''; -} - export function getGridTemplateColumns(dimensions: LogFieldDimension[]) { const columns = dimensions.map((dimension) => dimension.width).join('px '); return `${columns}px 1fr`; @@ -172,6 +203,7 @@ export const getStyles = (theme: GrafanaTheme2) => { fontFamily: theme.typography.fontFamilyMonospace, fontSize: theme.typography.fontSize, wordBreak: 'break-all', + cursor: 'pointer', '&:hover': { background: `hsla(0, 0%, 0%, 0.2)`, }, @@ -283,5 +315,18 @@ export const getStyles = (theme: GrafanaTheme2) => { marginRight: 0, }, }), + collapsedLogLine: css({ + maxHeight: `${TRUNCATION_LINE_COUNT * getLineHeight()}px`, + overflow: 'hidden', + }), + expandCollapseControl: css({ + display: 'flex', + justifyContent: 'center', + }), + expandCollapseControlButton: css({ + fontWeight: theme.typography.fontWeightLight, + height: getLineHeight(), + margin: 0, + }), }; }; diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index e93fd9ad1f1..7efcea74a96 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -47,6 +47,7 @@ interface Props { getRowContextQuery?: GetRowContextQueryFn; grammar?: Grammar; initialScrollPosition?: 'top' | 'bottom'; + loading?: boolean; loadMore?: (range: AbsoluteTimeRange) => void; logOptionsStorageKey?: string; logs: LogRowModel[]; @@ -88,6 +89,7 @@ export const LogList = ({ getRowContextQuery, grammar, initialScrollPosition = 'top', + loading, loadMore, logOptionsStorageKey, logs, @@ -140,6 +142,7 @@ export const LogList = ({ getFieldLinks={getFieldLinks} grammar={grammar} initialScrollPosition={initialScrollPosition} + loading={loading} loadMore={loadMore} logs={logs} showControls={showControls} @@ -156,6 +159,7 @@ const LogListComponent = ({ getFieldLinks, grammar, initialScrollPosition = 'top', + loading, loadMore, logs, showControls, @@ -189,15 +193,19 @@ const LogListComponent = ({ }, [eventBus, logs.length]); useEffect(() => { + if (loading) { + return; + } setProcessedLogs( preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar) ); - }, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]); + resetLogLineSizes(); + listRef.current?.resetAfterIndex(0); + }, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone]); useEffect(() => { - resetLogLineSizes(); listRef.current?.resetAfterIndex(0); - }, [wrapLogMessage, processedLogs]); + }, [wrapLogMessage]); useEffect(() => { const handleResize = debounce(() => { @@ -214,17 +222,16 @@ const LogListComponent = ({ if (widthRef.current === containerElement.clientWidth) { return; } - resetLogLineSizes(); - listRef.current?.resetAfterIndex(0); widthRef.current = containerElement.clientWidth; + listRef.current?.resetAfterIndex(0); }); const handleOverflow = useCallback( - (index: number, id: string, height: number) => { - if (containerElement) { + (index: number, id: string, height?: number) => { + if (containerElement && height !== undefined) { storeLogLineSize(id, containerElement, height); - listRef.current?.resetAfterIndex(index); } + listRef.current?.resetAfterIndex(index); }, [containerElement] ); diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index 9cff7a3367b..aef5d840b80 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -82,6 +82,7 @@ export const LogListContextProvider = ({ filterLevels = [], getRowContextQuery = jest.fn(), logSupportsContext = jest.fn(), + onLogLineHover, onPermalinkClick = jest.fn(), onPinLine = jest.fn(), onOpenContext = jest.fn(), @@ -102,6 +103,7 @@ export const LogListContextProvider = ({ filterLevels, getRowContextQuery, logSupportsContext, + onLogLineHover, onPermalinkClick, onPinLine, onOpenContext, diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index e8f9071c4e3..9d4587c269d 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -1,8 +1,10 @@ -import { Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data'; +import { createTheme, Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data'; -import { createLogRow } from '../__mocks__/logRow'; +import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { createLogLine, createLogRow } from '../__mocks__/logRow'; import { LogListModel, preProcessLogs } from './processing'; +import { getTruncationLength, init } from './virtualization'; describe('preProcessLogs', () => { let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; @@ -138,4 +140,58 @@ describe('preProcessLogs', () => { expect(processedLogs[2].highlightedBody).toContain('log-token-string'); expect(processedLogs[2].highlightedBody).not.toContain('log-token-method'); }); + + test('Returns displayed field values', () => { + expect(processedLogs[0].getDisplayedFieldValue('logger')).toBe('interceptor'); + expect(processedLogs[1].getDisplayedFieldValue('method')).toBe('POST'); + expect(processedLogs[2].getDisplayedFieldValue('kind')).toBe('Event'); + expect(processedLogs[0].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[0].body); + expect(processedLogs[1].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[1].body); + expect(processedLogs[2].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[2].body); + }); + + describe('Collapsible log lines', () => { + let longLog: LogListModel, entry: string, container: HTMLDivElement; + beforeEach(() => { + init(createTheme()); + container = document.createElement('div'); + jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); + entry = new Array(2 * getTruncationLength(null)).fill('e').join(''); + longLog = createLogLine({ entry }); + }); + + test('Long lines that are not truncated are not modified', () => { + expect(longLog.body).toBe(entry); + expect(longLog.highlightedBody).toBe(entry); + }); + + test('Sets the collapsed state based on the container size', () => { + // Make container half of the size + jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100); + + expect(longLog.collapsed).toBeUndefined(); + + longLog.updateCollapsedState([], container); + + expect(longLog.collapsed).toBe(true); + expect(longLog.body).not.toBe(entry); + expect(entry).toContain(longLog.body); + }); + + test('Updates the body based on the collapsed state', () => { + expect(longLog.collapsed).toBeUndefined(); + expect(longLog.body).toBe(entry); + + longLog.setCollapsedState(true); + + expect(longLog.collapsed).toBe(true); + expect(longLog.body).not.toBe(entry); + expect(entry).toContain(longLog.body); + + longLog.setCollapsedState(false); + + expect(longLog.collapsed).toBe(false); + expect(longLog.body).toBe(entry); + }); + }); }); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 455148fd5fa..a316ba21f14 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -1,20 +1,139 @@ import Prism, { Grammar } from 'prismjs'; -import { dateTimeFormat, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; +import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { escapeUnescapedString, sortLogRows } from '../../utils'; +import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { FieldDef, getAllFields } from '../logParser'; import { generateLogGrammar } from './grammar'; +import { getTruncationLength } from './virtualization'; -export interface LogListModel extends LogRowModel { - body: string; - _highlightedBody: string; - highlightedBody: string; +export class LogListModel implements LogRowModel { + collapsed: boolean | undefined = undefined; + datasourceType: string | undefined; + dataFrame: DataFrame; displayLevel: string; - fields: FieldDef[]; + duplicates: number | undefined; + entry: string; + entryFieldIndex: number; + hasAnsi: boolean; + hasUnescapedContent: boolean; + labels: Labels; + logLevel: LogLevel; + raw: string; + rowIndex: number; + rowId?: string | undefined; + searchWords: string[] | undefined; timestamp: string; + timeFromNow: string; + timeEpochMs: number; + timeEpochNs: string; + timeLocal: string; + timeUtc: string; + uid: string; + uniqueLabels: Labels | undefined; + + private _body: string | undefined = undefined; + private _grammar?: Grammar; + private _highlightedBody: string | undefined = undefined; + private _fields: FieldDef[] | undefined = undefined; + private _getFieldLinks: GetFieldLinksFn | undefined = undefined; + + constructor(log: LogRowModel, { escape, getFieldLinks, grammar, timeZone }: PreProcessLogOptions) { + // LogRowModel + this.datasourceType = log.datasourceType; + this.dataFrame = log.dataFrame; + this.duplicates = log.duplicates; + this.entry = log.entry; + this.entryFieldIndex = log.entryFieldIndex; + this.hasAnsi = log.hasAnsi; + this.hasUnescapedContent = log.hasUnescapedContent; + this.labels = log.labels; + this.logLevel = log.logLevel; + this.rowIndex = log.rowIndex; + this.rowId = log.rowId; + this.searchWords = log.searchWords; + this.timeFromNow = log.timeFromNow; + this.timeEpochMs = log.timeEpochMs; + this.timeEpochNs = log.timeEpochNs; + this.timeLocal = log.timeLocal; + this.timeUtc = log.timeUtc; + this.uid = log.uid; + this.uniqueLabels = log.uniqueLabels; + + // LogListModel + this.displayLevel = logLevelToDisplayLevel(log.logLevel); + this._getFieldLinks = getFieldLinks; + this._grammar = grammar; + this.timestamp = dateTimeFormat(log.timeEpochMs, { + timeZone, + defaultWithMS: true, + }); + + let raw = log.raw; + if (escape && log.hasUnescapedContent) { + raw = escapeUnescapedString(raw); + } + this.raw = raw; + } + + get body(): string { + if (this._body === undefined) { + let body = this.collapsed ? this.raw.substring(0, getTruncationLength(null)) : this.raw; + // Turn it into a single-line log entry for the list + this._body = body.replace(/(\r\n|\n|\r)/g, ''); + } + return this._body; + } + + get fields(): FieldDef[] { + if (this._fields === undefined) { + this._fields = getAllFields(this, this._getFieldLinks); + } + return this._fields; + } + + get highlightedBody() { + if (this._highlightedBody === undefined) { + this._grammar = this._grammar ?? generateLogGrammar(this); + this._highlightedBody = Prism.highlight(this.body, this._grammar, 'lokiql'); + } + return this._highlightedBody; + } + + getDisplayedFieldValue(fieldName: string): string { + if (fieldName === LOG_LINE_BODY_FIELD_NAME) { + return this.body; + } + if (this.labels[fieldName] != null) { + return this.labels[fieldName]; + } + const field = this.fields.find((field) => { + return field.keys[0] === fieldName; + }); + + return field ? field.values.toString() : ''; + } + + updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) { + const lineLength = + displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length + this.raw.length; + const collapsed = lineLength >= getTruncationLength(container) ? true : undefined; + if (this.collapsed === undefined || collapsed === undefined) { + this.collapsed = collapsed; + } + return this.collapsed; + } + + setCollapsedState(collapsed: boolean) { + if (this.collapsed !== collapsed) { + this._body = undefined; + this._highlightedBody = undefined; + } + this.collapsed = collapsed; + } } export interface PreProcessOptions { @@ -30,45 +149,17 @@ export const preProcessLogs = ( grammar?: Grammar ): LogListModel[] => { const orderedLogs = sortLogRows(logs, order); - return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone }, grammar)); + return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, grammar, timeZone })); }; interface PreProcessLogOptions { escape: boolean; getFieldLinks?: GetFieldLinksFn; + grammar?: Grammar; timeZone: string; } -const preProcessLog = ( - log: LogRowModel, - { escape, getFieldLinks, timeZone }: PreProcessLogOptions, - grammar?: Grammar -): LogListModel => { - let body = log.raw; - const timestamp = dateTimeFormat(log.timeEpochMs, { - timeZone, - defaultWithMS: true, - }); - - if (escape && log.hasUnescapedContent) { - body = escapeUnescapedString(body); - } - // Turn it into a single-line log entry for the list - body = body.replace(/(\r\n|\n|\r)/g, ''); - - return { - ...log, - body, - _highlightedBody: '', - get highlightedBody() { - if (!this._highlightedBody) { - this._highlightedBody = Prism.highlight(body, grammar ? grammar : generateLogGrammar(this), 'lokiql'); - } - return this._highlightedBody; - }, - displayLevel: logLevelToDisplayLevel(log.logLevel), - fields: getAllFields(log, getFieldLinks), - timestamp, - }; +const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => { + return new LogListModel(log, options); }; function logLevelToDisplayLevel(level = '') { diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts new file mode 100644 index 00000000000..0fb2e06f09a --- /dev/null +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -0,0 +1,119 @@ +import { createTheme } from '@grafana/data'; + +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'; + +const PADDING_BOTTOM = 6; +const LINE_HEIGHT = getLineHeight(); +const SINGLE_LINE_HEIGHT = LINE_HEIGHT + PADDING_BOTTOM; +const TWO_LINES_HEIGHT = 2 * LINE_HEIGHT + PADDING_BOTTOM; +const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM; +let LETTER_WIDTH: number; +let CONTAINER_SIZE = 200; + +describe('Virtualization', () => { + let log: LogListModel, container: HTMLDivElement; + beforeEach(() => { + log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); + container = document.createElement('div'); + jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); + init(createTheme()); + LETTER_WIDTH = measureTextWidth('e'); + }); + + describe('getLogLineSize', () => { + test('Returns the a single line if the display mode is unwrapped', () => { + const size = getLogLineSize([log], container, [], { wrap: false, showControls: true, showTime: true }, 0); + expect(size).toBe(SINGLE_LINE_HEIGHT); + }); + + test('Returns the a single line if the line is not loaded yet', () => { + const logs = [log]; + const size = getLogLineSize( + logs, + container, + [], + { wrap: true, showControls: true, showTime: true }, + logs.length + 1 + ); + expect(size).toBe(SINGLE_LINE_HEIGHT); + }); + + test('Returns the size of a truncated long line', () => { + // Very small container + log.collapsed = true; + jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); + const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0); + expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT); + }); + + test.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])('Measures a log line with controls %s and displayed time %s', (showControls: boolean, showTime: boolean) => { + const size = getLogLineSize([log], container, [], { wrap: true, showControls, showTime }, 0); + expect(size).toBe(SINGLE_LINE_HEIGHT); + }); + + test('Measures a multi-line log line with no controls and no displayed time', () => { + const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; + log = createLogLine({ + labels: { place: 'luna' }, + entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''), + logLevel: undefined, + }); + + const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0); + expect(size).toBe(TWO_LINES_HEIGHT); + }); + + test('Measures a multi-line log line with level, controls, and displayed time', () => { + const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; + log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); + + const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0); + // Two lines for the log and one extra for level and time + expect(size).toBe(THREE_LINES_HEIGHT); + }); + + test('Measures a multi-line log line with displayed fields', () => { + const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; + 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], + { wrap: true, showControls: false, showTime: false }, + 0 + ); + // Two lines for the log and one extra for the displayed fields + expect(size).toBe(THREE_LINES_HEIGHT); + }); + + test('Measures displayed fields in a log line with level, controls, and displayed time', () => { + const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 2; + log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); + + const size = getLogLineSize([log], container, ['place'], { wrap: true, showControls: true, showTime: true }, 0); + // Only renders a short displayed field, so a single line + expect(size).toBe(SINGLE_LINE_HEIGHT); + }); + + test('Adds an extra line for the expand/collapse controls if present', () => { + jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined); + log.collapsed = false; + const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0); + expect(size).toBe(TWO_LINES_HEIGHT); + }); + }); +}); diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index d7e79638bb4..18ffaeb358a 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -2,7 +2,6 @@ import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; -import { getDisplayedFieldValue } from './LogLine'; import { LogListModel } from './processing'; let ctx: CanvasRenderingContext2D | null = null; @@ -168,6 +167,13 @@ export function getLogLineSize( if (!wrap || !logs[index]) { return lineHeight + 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; + } + const storedSize = retrieveLogLineSize(logs[index].uid, container); if (storedSize) { return storedSize; @@ -189,14 +195,15 @@ export function getLogLineSize( textToMeasure += logs[index].displayLevel ?? ''; } for (const field of displayedFields) { - textToMeasure = getDisplayedFieldValue(field, logs[index]) + textToMeasure; + textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure; } if (!displayedFields.length) { textToMeasure += logs[index].body; } const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); - return height; + // When the log is collapsed, add an extra line for the expand/collapse control + return logs[index].collapsed === false ? height + lineHeight : height; } export interface LogFieldDimension { @@ -221,7 +228,7 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: levelWidth = Math.round(width); } for (const field of displayedFields) { - width = measureTextWidth(getDisplayedFieldValue(field, logs[i])); + width = measureTextWidth(logs[i].getDisplayedFieldValue(field)); fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field]; } } @@ -248,14 +255,28 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: return dimensions; }; -export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: number): number | null { +// 2/3 of the viewport height +export const TRUNCATION_LINE_COUNT = 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; +} + +export function hasUnderOrOverflow( + element: HTMLDivElement, + calculatedHeight?: number, + collapsed?: boolean +): number | null { + if (collapsed !== undefined && calculatedHeight) { + calculatedHeight -= getLineHeight(); + } const height = calculatedHeight ?? element.clientHeight; if (element.scrollHeight > height) { - return element.scrollHeight; + return collapsed !== undefined ? element.scrollHeight + getLineHeight() : element.scrollHeight; } const child = element.children[1]; if (child instanceof HTMLDivElement && child.clientHeight < height) { - return child.clientHeight; + return collapsed !== undefined ? child.clientHeight + getLineHeight() : child.clientHeight; } return null; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d08c5babfc9..180c38b6cbd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -404,7 +404,7 @@ }, "alert-labels": { "aria-label-labels": "Labels", - "common-labels-count_one": "+{{count}} common label", + "common-labels-count_one": "+{{count}} common labels", "common-labels-count_other": "+{{count}} common labels" }, "alert-manager-manual-routing": { @@ -1384,7 +1384,7 @@ "new-recording-rule": "New recording rule", "title": "Grafana-managed" }, - "loading-rules_one": "Loading rules from {{count}} source", + "loading-rules_one": "Loading rules from {{count}} sources", "loading-rules_other": "Loading rules from {{count}} sources" } }, @@ -2065,14 +2065,14 @@ "unknown-rule-type": "Unknown rule type" }, "rule-list-errors": { - "button-errors_one": "{{count}} error", + "button-errors_one": "{{count}} errors", "button-errors_other": "{{count}} errors", "cloud-rulessource-errors-title-errors-loading-rules": "Errors loading rules", "failed-to-load-grafana-rules-config": "Failed to load Grafana rules config:", "failed-to-load-grafana-rules-state": "Failed to load Grafana rules state:", "failed-to-load-rules-config": "Failed to load rules config from <2>{{dataSource}}", "failed-to-load-rules-state": "Failed to load rules state from <2>{{dataSource}}", - "more-errors_one": "{{count}} more error", + "more-errors_one": "{{count}} more errors", "more-errors_other": "{{count}} more errors", "unknown-error": "Unknown error." }, @@ -2099,7 +2099,7 @@ } }, "rule-stats": { - "error_one": "{{count}} error", + "error_one": "{{count}} errors", "error_other": "{{count}} errors", "firing": "{{alertingStats}} firing", "inactive": "{{normalStats}} normal", @@ -4033,7 +4033,7 @@ }, "library-viz-panel-info": { "last-edited": "{{timeAgo}} by", - "usage-count_one": "Used on {{count}} dashboard", + "usage-count_one": "Used on {{count}} dashboards", "usage-count_other": "Used on {{count}} dashboards" }, "name-already-exists-error": { @@ -4152,7 +4152,7 @@ "save-variables-label-update-default-variable-values": "Update default variable values" }, "save-library-viz-panel-modal": { - "affected-dashboards_one": "This update will affect <1>{{count}} dashboard. The following dashboard using the panel will be affected:", + "affected-dashboards_one": "This update will affect <1>{{count}} dashboards. The following dashboards using the panel will be affected:", "affected-dashboards_other": "This update will affect <1>{{count}} dashboards. The following dashboards using the panel will be affected:", "cancel": "Cancel", "dashboard-name": "Dashboard name", @@ -5666,9 +5666,9 @@ "refresh": "Refresh" }, "query-inspector": { - "count-frames_one": "{{count}} frame, ", + "count-frames_one": "{{count}} frames, ", "count-frames_other": "{{count}} frames, ", - "count-rows_one": "{{count}} row", + "count-rows_one": "{{count}} rows", "count-rows_other": "{{count}} rows", "query-inspector": "Query inspector", "text-loading-query-inspector": "Loading query inspector..." @@ -5885,7 +5885,7 @@ }, "library-panel-info": { "last-edited": "Last edited on {{timeAgo}} by", - "usage-count_one": "Used on {{count}} dashboard", + "usage-count_one": "Used on {{count}} dashboards", "usage-count_other": "Used on {{count}} dashboards" }, "library-panels-search": { @@ -5919,7 +5919,7 @@ "dashboard-name": "Dashboard name", "discard": "Discard", "loading-connected-dashboards": "Loading connected dashboards...", - "num-affected_one": "This update will affect <1>{{count}} dashboard.", + "num-affected_one": "This update will affect <1>{{count}} dashboards.", "num-affected_other": "This update will affect <1>{{count}} dashboards.", "placeholder-search-affected-dashboards": "Search affected dashboards", "update-all": "Update all" @@ -6029,6 +6029,10 @@ "log-labels-list": { "log-line": "log line" }, + "log-line": { + "show-less": "show less", + "show-more": "show more" + }, "log-line-menu": { "copy-link": "Copy link to log line", "copy-log": "Copy log line",