grafana/public/app/features/logs/components/panel/LogLine.tsx

562 lines
17 KiB
TypeScript

import { css } from '@emotion/css';
import { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, MouseEvent } from 'react';
import Highlighter from 'react-highlight-words';
import tinycolor from 'tinycolor2';
import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { LogLineMenu } from './LogLineMenu';
import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext';
import { useLogListSearchContext } from './LogListSearchContext';
import { LogListModel } from './processing';
import {
FIELD_GAP_MULTIPLIER,
hasUnderOrOverflow,
LogFieldDimension,
LogLineVirtualization,
DEFAULT_LINE_HEIGHT,
} from './virtualization';
export interface Props {
displayedFields: string[];
index: number;
log: LogListModel;
showTime: boolean;
style: CSSProperties;
styles: LogLineStyles;
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
onOverflow?: (index: number, id: string, height?: number) => void;
variant?: 'infinite-scroll';
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
}
export const LogLine = ({
displayedFields,
index,
log,
style,
styles,
onClick,
onOverflow,
showTime,
variant,
virtualization,
wrapLogMessage,
}: Props) => {
return (
<div style={style}>
<LogLineComponent
displayedFields={displayedFields}
height={style.height}
index={index}
log={log}
styles={styles}
onClick={onClick}
onOverflow={onOverflow}
showTime={showTime}
variant={variant}
virtualization={virtualization}
wrapLogMessage={wrapLogMessage}
/>
</div>
);
};
interface LogLineComponentProps extends Omit<Props, 'style'> {
height?: number | string;
}
const LogLineComponent = memo(
({
displayedFields,
height,
index,
log,
styles,
onClick,
onOverflow,
showTime,
variant,
virtualization,
wrapLogMessage,
}: LogLineComponentProps) => {
const {
detailsDisplayed,
dedupStrategy,
enableLogDetails,
fontSize,
hasLogsWithErrors,
hasSampledLogs,
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 permalinked = useLogIsPermalinked(log);
useEffect(() => {
if (!onOverflow || !logLineRef.current || !virtualization || !height) {
return;
}
const calculatedHeight = typeof height === 'number' ? height : undefined;
const actualHeight = hasUnderOrOverflow(virtualization, logLineRef.current, calculatedHeight, log.collapsed);
if (actualHeight) {
onOverflow(index, log.uid, actualHeight);
}
});
useEffect(() => {
if (!wrapLogMessage) {
setCollapsed(undefined);
} else if (collapsed === undefined && log.collapsed !== undefined) {
setCollapsed(log.collapsed);
} else if (collapsed !== undefined && log.collapsed === undefined) {
setCollapsed(log.collapsed);
}
}, [collapsed, log.collapsed, wrapLogMessage]);
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]);
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
onClick(e, log);
},
[log, onClick]
);
const detailsShown = detailsDisplayed(log);
return (
<>
<div
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''}`}
ref={onOverflow ? logLineRef : undefined}
onMouseEnter={handleMouseOver}
onFocus={handleMouseOver}
>
<LogLineMenu styles={styles} log={log} />
{dedupStrategy !== LogsDedupStrategy.none && (
<div className={`${styles.duplicates}`}>
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
</div>
)}
{hasLogsWithErrors && (
<div className={`${styles.hasError}`}>
{log.hasError && (
<Tooltip
content={t('logs.log-line.tooltip-error', 'Error: {{errorMessage}}', {
errorMessage: log.errorMessage,
})}
placement="right"
theme="error"
>
<Icon
className={styles.logIconError}
name="exclamation-triangle"
aria-label={t('logs.log-line.has-error', 'Has errors')}
size="xs"
/>
</Tooltip>
)}
</div>
)}
{hasSampledLogs && (
<div className={`${styles.isSampled}`}>
{log.isSampled && (
<Tooltip content={log.sampledMessage ?? ''} placement="right" theme="info">
<Icon
className={styles.logIconInfo}
name="info-circle"
size="xs"
aria-label={t('logs.log-line.is-sampled', 'Is sampled')}
/>
</Tooltip>
)}
</div>
)}
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={`${styles.fieldsWrapper} ${detailsShown ? styles.detailsDisplayed : ''} ${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
style={
collapsed && virtualization
? { maxHeight: `${virtualization.getTruncationLineCount() * virtualization.getLineHeight()}px` }
: undefined
}
onClick={handleClick}
>
<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>
)}
</>
);
}
);
LogLineComponent.displayName = 'LogLineComponent';
interface LogProps {
displayedFields: string[];
log: LogListModel;
showTime: boolean;
styles: LogLineStyles;
wrapLogMessage: boolean;
}
const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => {
return (
<>
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
{
// When logs are unwrapped, we want an empty column space to align with other log lines.
}
{(log.displayLevel || !wrapLogMessage) && (
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
)}
{displayedFields.length > 0 ? (
<DisplayedFields displayedFields={displayedFields} log={log} styles={styles} />
) : (
<LogLineBody log={log} styles={styles} />
)}
</>
);
});
Log.displayName = 'Log';
const DisplayedFields = ({
displayedFields,
log,
styles,
}: {
displayedFields: string[];
log: LogListModel;
styles: LogLineStyles;
}) => {
const { matchingUids, search } = useLogListSearchContext();
const searchWords = useMemo(() => {
const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords.slice() : [];
if (search && matchingUids?.includes(log.uid)) {
searchWords.push(search);
}
if (!searchWords.length) {
return undefined;
}
return searchWords;
}, [log.searchWords, log.uid, matchingUids, search]);
return displayedFields.map((field) =>
field === LOG_LINE_BODY_FIELD_NAME ? (
<LogLineBody log={log} key={field} styles={styles} />
) : (
<span className="field" title={field} key={field}>
{searchWords ? (
<Highlighter
textToHighlight={log.getDisplayedFieldValue(field)}
searchWords={searchWords}
findChunks={findHighlightChunksInText}
highlightClassName={styles.matchHighLight}
/>
) : (
log.getDisplayedFieldValue(field)
)}
</span>
)
);
};
const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => {
const { syntaxHighlighting } = useLogListContext();
const { matchingUids, search } = useLogListSearchContext();
const highlight = useMemo(() => {
const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords.slice() : [];
if (search && matchingUids?.includes(log.uid)) {
searchWords.push(search);
}
if (!searchWords.length) {
return undefined;
}
return { searchWords, highlightClassName: styles.matchHighLight };
}, [log.searchWords, log.uid, matchingUids, search, styles.matchHighLight]);
if (log.hasAnsi) {
return (
<span className="field no-highlighting">
<LogMessageAnsi value={log.body} highlight={highlight} />
</span>
);
}
if (!syntaxHighlighting) {
return highlight ? (
<Highlighter
textToHighlight={log.body}
searchWords={highlight.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName={styles.matchHighLight}
/>
) : (
<span className="field no-highlighting">{log.body}</span>
);
}
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
};
export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) {
const columns = dimensions.map((dimension) => dimension.width).join('px ');
const logLineWidth = displayedFields.length > 0 ? '' : ' 1fr';
return `${columns}px${logLineWidth}`;
}
export type LogLineStyles = ReturnType<typeof getStyles>;
export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtualization) => {
const colors = {
critical: '#B877D9',
error: theme.colors.error.text,
warning: '#FBAD37',
debug: '#6E9FFF',
trace: '#6ed0e0',
info: '#6CCF8E',
metadata: theme.colors.text.primary,
parsedField: theme.colors.text.primary,
};
const hoverColor = tinycolor(theme.colors.background.canvas).darken(4).toRgbString();
return {
logLine: css({
color: tinycolor(theme.colors.text.secondary).setAlpha(0.75).toRgbString(),
display: 'flex',
gap: theme.spacing(0.5),
flexDirection: 'row',
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.fontSize,
lineHeight: theme.typography.body.lineHeight,
wordBreak: 'break-all',
'&:hover': {
background: hoverColor,
},
'&.infinite-scroll': {
'&::before': {
borderTop: `solid 1px ${theme.colors.border.strong}`,
content: '""',
height: 0,
left: 0,
position: 'absolute',
top: -3,
width: '100%',
},
},
'& .log-syntax-highlight': {
'.log-token-string': {
color: tinycolor(theme.colors.text.secondary).setAlpha(0.75).toRgbString(),
},
'.log-token-duration': {
color: theme.colors.success.text,
},
'.log-token-size': {
color: theme.colors.success.text,
},
'.log-token-uuid': {
color: theme.colors.success.text,
},
'.log-token-key': {
color: colors.parsedField,
opacity: 0.9,
fontWeight: theme.typography.fontWeightMedium,
},
'.log-token-json-key': {
color: colors.parsedField,
opacity: 0.9,
fontWeight: theme.typography.fontWeightMedium,
},
'.log-token-label': {
color: colors.metadata,
fontWeight: theme.typography.fontWeightBold,
},
'.log-token-method': {
color: theme.colors.info.shade,
},
'.log-search-match': {
color: theme.components.textHighlight.text,
backgroundColor: theme.components.textHighlight.background,
},
},
'& .no-highlighting': {
color: theme.colors.text.primary,
},
}),
matchHighLight: css({
color: theme.components.textHighlight.text,
backgroundColor: theme.components.textHighlight.background,
}),
fontSizeSmall: css({
fontSize: theme.typography.bodySmall.fontSize,
lineHeight: theme.typography.bodySmall.lineHeight,
}),
detailsDisplayed: css({
background: hoverColor,
}),
pinnedLogLine: css({
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
}),
permalinkedLogLine: css({
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
}),
menuIcon: css({
height: virtualization?.getLineHeight() ?? DEFAULT_LINE_HEIGHT,
margin: 0,
padding: theme.spacing(0, 0, 0, 0.5),
}),
logLineMessage: css({
fontFamily: theme.typography.fontFamily,
justifyContent: 'center',
}),
timestamp: css({
color: theme.colors.text.disabled,
display: 'inline-block',
}),
duplicates: css({
flexShrink: 0,
textAlign: 'center',
width: theme.spacing(4.5),
}),
hasError: css({
flexShrink: 0,
width: theme.spacing(2),
'& svg': {
position: 'relative',
top: -1,
},
}),
isSampled: css({
flexShrink: 0,
width: theme.spacing(2),
'& svg': {
position: 'relative',
top: -1,
},
}),
logIconError: css({
color: theme.colors.warning.main,
}),
logIconInfo: css({
color: theme.colors.info.main,
}),
level: css({
color: theme.colors.text.secondary,
fontWeight: theme.typography.fontWeightBold,
textTransform: 'uppercase',
display: 'inline-block',
'&.level-critical': {
color: colors.critical,
},
'&.level-error': {
color: colors.error,
},
'&.level-warning': {
color: colors.warning,
},
'&.level-info': {
color: colors.info,
},
'&.level-debug': {
color: colors.debug,
},
}),
loadMoreButton: css({
background: 'transparent',
border: 'none',
display: 'inline',
}),
overflows: css({
outline: 'solid 1px red',
}),
clickable: css({
cursor: 'pointer',
}),
unwrappedLogLine: css({
display: 'grid',
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
whiteSpace: 'pre',
paddingBottom: theme.spacing(0.75),
}),
wrappedLogLine: css({
alignSelf: 'flex-start',
paddingBottom: theme.spacing(0.75),
whiteSpace: 'pre-wrap',
'& .field': {
marginRight: theme.spacing(FIELD_GAP_MULTIPLIER),
},
'& .field:last-child': {
marginRight: 0,
},
}),
fieldsWrapper: css({
'&:hover': {
background: hoverColor,
},
}),
collapsedLogLine: css({
overflow: 'hidden',
}),
expandCollapseControl: css({
display: 'flex',
justifyContent: 'center',
}),
expandCollapseControlButton: css({
fontWeight: theme.typography.fontWeightLight,
height: virtualization?.getLineHeight() ?? DEFAULT_LINE_HEIGHT,
margin: 0,
}),
};
};