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

563 lines
16 KiB
TypeScript

import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { parse, stringify } from 'lossless-json';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { ClipboardButton, DataLinkButton, IconButton, useStyles2 } from '@grafana/ui';
import { logRowToSingleRowDataFrame } from '../../logsModel';
import { calculateLogsLabelStats, calculateStats } from '../../utils';
import { LogLabelStats } from '../LogLabelStats';
import { FieldDef } from '../logParser';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
import { useLogListContext } from './LogListContext';
import { LogListModel, getNormalizedFieldName } from './processing';
interface LogLineDetailsFieldsProps {
disableActions?: boolean;
fields: FieldDef[];
log: LogListModel;
logs: LogListModel[];
search?: string;
}
export const LogLineDetailsFields = memo(({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => {
const styles = useStyles2(getFieldsStyles);
const getLogs = useCallback(() => logs, [logs]);
const filteredFields = useMemo(() => (search ? filterFields(fields, search) : fields), [fields, search]);
if (!fields.length) {
return null;
} else if (filteredFields.length === 0) {
return t('logs.log-line-details.search.no-results', 'No results to display.');
}
return (
<div className={disableActions ? styles.fieldsTableNoActions : styles.fieldsTable}>
{filteredFields.map((field, i) => (
<LogLineDetailsField
key={`${field.keys[0]}=${field.values[0]}-${i}`}
disableActions={disableActions}
getLogs={getLogs}
fieldIndex={field.fieldIndex}
keys={field.keys}
links={field.links}
log={log}
values={field.values}
/>
))}
</div>
);
});
LogLineDetailsFields.displayName = 'LogLineDetailsFields';
interface LinkModelWithIcon extends LinkModel<Field> {
icon?: IconName;
}
export interface LabelWithLinks {
key: string;
value: string;
links?: LinkModelWithIcon[];
}
interface LogLineDetailsLabelFieldsProps {
fields: LabelWithLinks[];
log: LogListModel;
logs: LogListModel[];
search?: string;
}
export const LogLineDetailsLabelFields = ({ fields, log, logs, search }: LogLineDetailsLabelFieldsProps) => {
const styles = useStyles2(getFieldsStyles);
const getLogs = useCallback(() => logs, [logs]);
const filteredFields = useMemo(() => (search ? filterLabels(fields, search) : fields), [fields, search]);
if (!fields.length) {
return null;
} else if (filteredFields.length === 0) {
return t('logs.log-line-details.search.no-results', 'No results to display.');
}
return (
<div className={styles.fieldsTable}>
{filteredFields.map((field, i) => (
<LogLineDetailsField
key={`${field.key}=${field.value}-${i}`}
getLogs={getLogs}
isLabel
keys={[field.key]}
links={field.links}
log={log}
values={[field.value]}
/>
))}
</div>
);
};
const getFieldsStyles = (theme: GrafanaTheme2) => ({
fieldsTable: css({
display: 'grid',
gap: theme.spacing(1),
gridTemplateColumns: `${theme.spacing(11.5)} fit-content(30%) 1fr`,
}),
fieldsTableNoActions: css({
display: 'grid',
gap: theme.spacing(1),
gridTemplateColumns: `auto 1fr`,
}),
});
interface LogLineDetailsFieldProps {
keys: string[];
values: string[];
disableActions?: boolean;
fieldIndex?: number;
getLogs(): LogListModel[];
isLabel?: boolean;
links?: LinkModelWithIcon[];
log: LogListModel;
}
export const LogLineDetailsField = ({
disableActions = false,
fieldIndex,
getLogs,
isLabel,
links,
log,
keys,
values,
}: LogLineDetailsFieldProps) => {
const [showFieldsStats, setShowFieldStats] = useState(false);
const [fieldCount, setFieldCount] = useState(0);
const [fieldStats, setFieldStats] = useState<LogLabelStatsModel[] | null>(null);
const {
app,
closeDetails,
displayedFields,
isLabelFilterActive,
noInteractions,
onClickFilterLabel,
onClickFilterOutLabel,
onClickShowField,
onClickHideField,
onPinLine,
pinLineButtonTooltipTitle,
prettifyJSON,
} = useLogListContext();
const styles = useStyles2(getFieldStyles);
const getStats = useCallback(() => {
if (isLabel) {
return calculateLogsLabelStats(getLogs(), keys[0]);
}
if (fieldIndex !== undefined) {
return calculateStats(log.dataFrame.fields[fieldIndex].values);
}
return [];
}, [fieldIndex, getLogs, isLabel, keys, log.dataFrame.fields]);
const updateStats = useCallback(() => {
const newStats = getStats();
const newCount = newStats.reduce((sum, stat) => sum + stat.count, 0);
if (!isEqual(fieldStats, newStats) || fieldCount !== newCount) {
setFieldStats(newStats);
setFieldCount(newCount);
}
}, [fieldCount, fieldStats, getStats]);
useEffect(() => {
if (showFieldsStats) {
updateStats();
}
}, [showFieldsStats, updateStats]);
const reportInteractionWrapper = useCallback(
(interactionName: string, properties?: Record<string, unknown>) => {
if (noInteractions) {
return;
}
reportInteraction(interactionName, properties);
},
[noInteractions]
);
const showField = useCallback(() => {
if (onClickShowField) {
onClickShowField(keys[0]);
}
reportInteractionWrapper('logs_log_line_details_show_field_clicked', {
datasourceType: log.datasourceType,
});
}, [onClickShowField, reportInteractionWrapper, log.datasourceType, keys]);
const hideField = useCallback(() => {
if (onClickHideField) {
onClickHideField(keys[0]);
}
reportInteractionWrapper('logs_log_line_details_hide_field_clicked', {
datasourceType: log.datasourceType,
});
}, [onClickHideField, reportInteractionWrapper, log.datasourceType, keys]);
const filterLabel = useCallback(() => {
if (onClickFilterLabel) {
onClickFilterLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
}
reportInteractionWrapper('logs_log_line_details_filter_clicked', {
datasourceType: log.datasourceType,
filterType: 'include',
logRowUid: log.uid,
});
}, [onClickFilterLabel, reportInteractionWrapper, log, keys, values]);
const filterOutLabel = useCallback(() => {
if (onClickFilterOutLabel) {
onClickFilterOutLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
}
reportInteractionWrapper('logs_log_line_details_filter_clicked', {
datasourceType: log.datasourceType,
filterType: 'exclude',
logRowUid: log.uid,
});
}, [onClickFilterOutLabel, reportInteractionWrapper, log, keys, values]);
const labelFilterActive = useCallback(async () => {
if (isLabelFilterActive) {
return await isLabelFilterActive(keys[0], values[0], log.dataFrame?.refId);
}
return false;
}, [isLabelFilterActive, keys, values, log.dataFrame?.refId]);
const showStats = useCallback(() => {
setShowFieldStats((showFieldStats: boolean) => !showFieldStats);
reportInteractionWrapper('logs_log_line_details_stats_clicked', {
dataSourceType: log.datasourceType,
fieldType: isLabel ? 'label' : 'field',
type: showFieldsStats ? 'close' : 'open',
logRowUid: log.uid,
app,
});
}, [app, isLabel, log.datasourceType, log.uid, reportInteractionWrapper, showFieldsStats]);
const refIdTooltip = useMemo(
() => (app === CoreApp.Explore && log.dataFrame?.refId ? ` in query ${log.dataFrame?.refId}` : ''),
[app, log.dataFrame?.refId]
);
const singleKey = keys.length === 1;
const singleValue = values.length === 1;
const fieldSupportsFilters = keys[0] !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME;
return (
<>
<div className={styles.row}>
{!disableActions && (
<div className={styles.actions}>
{onClickFilterLabel && fieldSupportsFilters && (
<AsyncIconButton
name="search-plus"
onClick={filterLabel}
// We purposely want to pass a new function on every render to allow the active state to be updated when log details remains open between updates.
isActive={labelFilterActive}
tooltipSuffix={refIdTooltip}
/>
)}
{onClickFilterOutLabel && fieldSupportsFilters && (
<IconButton
name="search-minus"
tooltip={
app === CoreApp.Explore && log.dataFrame?.refId
? t('logs.log-line-details.fields.filter-out-query', 'Filter out value in query {{query}}', {
query: log.dataFrame?.refId,
})
: t('logs.log-line-details.fields.filter-out', 'Filter out value')
}
onClick={filterOutLabel}
/>
)}
{singleKey && displayedFields.includes(keys[0]) && (
<IconButton
variant="primary"
tooltip={t('logs.log-line-details.fields.toggle-field-button.hide-this-field', 'Hide this field')}
name="eye"
onClick={hideField}
/>
)}
{singleKey && !displayedFields.includes(keys[0]) && (
<IconButton
tooltip={t(
'logs.log-line-details.fields.toggle-field-button.field-instead-message',
'Show this field instead of the message'
)}
name="eye"
onClick={showField}
/>
)}
<IconButton
variant={showFieldsStats ? 'primary' : 'secondary'}
name="signal"
tooltip={t('logs.log-line-details.fields.adhoc-statistics', 'Ad-hoc statistics')}
className="stats-button"
disabled={!singleKey}
onClick={showStats}
/>
</div>
)}
<div className={styles.label}>
{singleKey ? getNormalizedFieldName(keys[0]) : <MultipleValue values={keys} />}
</div>
<div className={styles.value}>
<div className={styles.valueContainer}>
{singleValue ? (
<SingleValue value={values[0]} prettifyJSON={prettifyJSON} />
) : (
<MultipleValue showCopy={true} values={values} />
)}
</div>
</div>
</div>
{links?.map((link, i) => {
if (link.onClick && onPinLine) {
const originalOnClick = link.onClick;
link.onClick = (e, origin) => {
// Pin the line
onPinLine(log);
// Execute the link onClick function
originalOnClick(e, origin);
closeDetails();
};
}
return (
<div className={styles.row} key={`${link.title}-${i}`}>
<div className={disableActions ? styles.linkNoActions : styles.link}>
<DataLinkButton
buttonProps={{
// Show tooltip message if max number of pinned lines has been reached
tooltip:
typeof pinLineButtonTooltipTitle === 'object' && link.onClick
? pinLineButtonTooltipTitle
: undefined,
variant: 'secondary',
fill: 'outline',
...(link.icon && { icon: link.icon }),
}}
link={link}
/>
</div>
</div>
);
})}
{showFieldsStats && fieldStats && (
<div className={styles.row}>
<div className={disableActions ? undefined : styles.statsColumn}>
<LogLabelStats
className={styles.stats}
stats={fieldStats}
label={keys[0]}
value={values[0]}
rowCount={fieldCount}
isLabel={isLabel}
/>
</div>
</div>
)}
</>
);
};
const getFieldStyles = (theme: GrafanaTheme2) => ({
row: css({
display: 'contents',
}),
actions: css({
whiteSpace: 'nowrap',
}),
label: css({
paddingRight: theme.spacing(1),
overflowWrap: 'break-word',
wordBreak: 'break-word',
}),
value: css({
overflowWrap: 'break-word',
wordBreak: 'break-word',
button: {
visibility: 'hidden',
},
'&:hover': {
button: {
visibility: 'visible',
},
},
}),
link: css({
gridColumn: '2 / 4',
}),
linkNoActions: css({
gridColumn: 'span 2',
paddingBottom: theme.spacing(0.5),
}),
stats: css({
paddingRight: theme.spacing(1),
wordBreak: 'break-all',
width: '100%',
maxWidth: '50vh',
}),
statsColumn: css({
gridColumn: '2 / 4',
}),
valueContainer: css({
display: 'flex',
lineHeight: theme.typography.body.lineHeight,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '50vh',
overflow: 'auto',
}),
});
const ClipboardButtonWrapper = ({ value }: { value: string }) => {
const styles = useStyles2(getClipboardButtonStyles);
return (
<div className={styles.button}>
<ClipboardButton
getText={() => value}
aria-label={t('logs.log-line-details.fields.copy-value-to-clipboard', 'Copy value to clipboard')}
fill="text"
variant="secondary"
icon="copy"
size="md"
/>
</div>
);
};
const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({
button: css({
'& > button': {
color: theme.colors.text.secondary,
gap: 0,
padding: 0,
justifyContent: 'center',
borderRadius: theme.shape.radius.circle,
height: theme.spacing(theme.components.height.sm),
width: theme.spacing(theme.components.height.sm),
svg: {
margin: 0,
},
'span > div': {
top: '-5px',
'& button': {
color: theme.colors.success.main,
},
},
},
}),
});
export const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values: string[] }) => {
if (values.every((val) => val === '')) {
return null;
}
return (
<table>
<tbody>
{values.map((val, i) => {
return (
<tr key={`${val}-${i}`}>
<td>{val}</td>
<td>{showCopy && val !== '' && <ClipboardButtonWrapper value={val} />}</td>
</tr>
);
})}
</tbody>
</table>
);
};
export const SingleValue = ({ value: originalValue, prettifyJSON }: { value: string; prettifyJSON?: boolean }) => {
const value = useMemo(() => {
if (!prettifyJSON) {
return originalValue;
}
try {
const parsed = stringify(parse(originalValue), undefined, 2);
if (parsed) {
return parsed;
}
} catch (error) {}
return originalValue;
}, [originalValue, prettifyJSON]);
return (
<>
{value}
<ClipboardButtonWrapper value={value} />
</>
);
};
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
name: IconName;
isActive(): Promise<boolean>;
tooltipSuffix: string;
}
const AsyncIconButton = ({ isActive, tooltipSuffix, ...rest }: AsyncIconButtonProps) => {
const [active, setActive] = useState(false);
const tooltip = active ? 'Remove filter' : 'Filter for value';
useEffect(() => {
isActive().then(setActive);
}, [isActive]);
return <IconButton {...rest} variant={active ? 'primary' : undefined} tooltip={tooltip + tooltipSuffix} />;
};
export function filterFields(fields: FieldDef[], search: string) {
const keys = fields.map((field) => field.keys.join(' '));
const keysIdx = fuzzySearch(keys, search);
const values = fields.map((field) => field.values.join(' '));
const valuesIdx = fuzzySearch(values, search);
const results = keysIdx.map((index) => fields[index]);
valuesIdx.forEach((index) => {
if (!results.includes(fields[index])) {
results.push(fields[index]);
}
});
return results;
}
function filterLabels(labels: LabelWithLinks[], search: string) {
const keys = labels.map((field) => field.key);
const keysIdx = fuzzySearch(keys, search);
const values = labels.map((field) => field.value);
const valuesIdx = fuzzySearch(values, search);
const results = keysIdx.map((index) => labels[index]);
valuesIdx.forEach((index) => {
if (!results.includes(labels[index])) {
results.push(labels[index]);
}
});
return results;
}