The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/logs/components/panel/LogLine.tsx

564 lines
17 KiB

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),
'& .field': {
overflow: 'hidden',
},
}),
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,
}),
};
};