New Logs Panel: Handle long lines (#103171)
* LogLine: introduce truncation limit * Processing: turn LogListModel into a class * LogLine: introduce collapsed/expanded state and read from model * Virtualization: correct extra line for controls in size * LogLine: fix collapsed state initialization * Add new criteria to define long lines to truncate * Virtualization: better truncation limit * Check collapsed state based on container size * Update function name * Extract translations * LogLine: update unit test * virtualization: add unit test * processing: update unit test * Fix focused test * processing: process fields on demand * Logs: consider loading state from explore * LogList: improve resize and recalculation * chore: build?
This commit is contained in:
@@ -1151,6 +1151,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
filterLevels={filterLevels}
|
||||
getFieldLinks={getFieldLinks}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
loading={loading}
|
||||
loadMore={loadMoreLogs}
|
||||
logOptionsStorageKey={SETTING_KEY_ROOT}
|
||||
logs={dedupedRows}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<LogListContextProvider {...contextProps} onLogLineHover={onLogLineHover}>
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
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(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={true}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
// Unwrapped logs
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={true}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
onOverflow={onOverflow}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={true}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(await screen.findByText('show more'));
|
||||
await userEvent.click(await screen.findByText('show less'));
|
||||
expect(onOverflow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean | undefined>(
|
||||
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
|
||||
);
|
||||
const logLineRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
style={style}
|
||||
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
|
||||
ref={onOverflow ? logLineRef : undefined}
|
||||
onMouseOver={handleMouseOver}
|
||||
>
|
||||
<LogLineMenu styles={styles} log={log} />
|
||||
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}>
|
||||
<Log
|
||||
displayedFields={displayedFields}
|
||||
log={log}
|
||||
showTime={showTime}
|
||||
styles={styles}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
<div style={style}>
|
||||
<div
|
||||
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
|
||||
ref={onOverflow ? logLineRef : undefined}
|
||||
onMouseEnter={handleMouseOver}
|
||||
>
|
||||
<LogLineMenu styles={styles} log={log} />
|
||||
<div
|
||||
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
|
||||
>
|
||||
<Log
|
||||
displayedFields={displayedFields}
|
||||
log={log}
|
||||
showTime={showTime}
|
||||
styles={styles}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed === true && (
|
||||
<div className={styles.expandCollapseControl}>
|
||||
<Button
|
||||
variant="primary"
|
||||
fill="text"
|
||||
size="sm"
|
||||
className={styles.expandCollapseControlButton}
|
||||
onClick={handleExpandCollapse}
|
||||
>
|
||||
{t('logs.log-line.show-more', 'show more')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{collapsed === false && (
|
||||
<div className={styles.expandCollapseControl}>
|
||||
<Button
|
||||
variant="primary"
|
||||
fill="text"
|
||||
size="sm"
|
||||
className={styles.expandCollapseControlButton}
|
||||
onClick={handleExpandCollapse}
|
||||
>
|
||||
{t('logs.log-line.show-less', 'show less')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -99,7 +144,7 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp
|
||||
<LogLineBody log={log} key={field} />
|
||||
) : (
|
||||
<span className="field" title={field} key={field}>
|
||||
{getDisplayedFieldValue(field, log)}
|
||||
{log.getDisplayedFieldValue(field)}
|
||||
</span>
|
||||
)
|
||||
)
|
||||
@@ -131,20 +176,6 @@ const LogLineBody = ({ log }: { log: LogListModel }) => {
|
||||
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
useEffect(() => {
|
||||
resetLogLineSizes();
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
}, [wrapLogMessage, processedLogs]);
|
||||
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
}, [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]
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = '') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}}</2>",
|
||||
"failed-to-load-rules-state": "Failed to load rules state from <2>{{dataSource}}</2>",
|
||||
"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<person />",
|
||||
"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.</1> The following dashboard using the panel will be affected:",
|
||||
"affected-dashboards_one": "This update will affect <1>{{count}} dashboards.</1> The following dashboards using the panel will be affected:",
|
||||
"affected-dashboards_other": "This update will affect <1>{{count}} dashboards.</1> 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<person />",
|
||||
"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.</1>",
|
||||
"num-affected_one": "This update will affect <1>{{count}} dashboards.</1>",
|
||||
"num-affected_other": "This update will affect <1>{{count}} dashboards.</1>",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user