Logs: Unify detected fields and labels in Log Details (#60448)

* removed js-fields

* added buttons

* rename detectedField to field

* removed unused things from `logParser.ts`

* improve comment

* wip

* better way for statistics

* better hide-stats button

* update tests

* updated tests and var names

* made props optional again

* fix padding

* fix unused import

* removed test

* close elements

* renamed `LogRowMessageDetectedFields` to `LogRowMessageDisplayedFields`

* add active style to menu button

* changed comment in logParser

* updated ToolbarButton colors

* rename `Data Links` to `Links`

* fix stats button being wrongly highlighted
This commit is contained in:
Sven Grossmann 2023-01-11 19:20:11 +01:00 committed by GitHub
parent 8bab6d3658
commit 5b2184c485
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 376 additions and 296 deletions

View File

@ -3986,9 +3986,6 @@ exports[`better eslint`] = {
"public/app/features/logs/components/LogRows.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/logs/components/logParser.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/logs/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

View File

@ -20,6 +20,8 @@ describe('Logs', () => {
splitOpen={() => undefined}
logsVolumeEnabled={true}
onSetLogsVolumeEnabled={() => null}
onClickFilterLabel={() => null}
onClickFilterOutLabel={() => null}
logsVolumeData={undefined}
loadLogsVolumeData={() => undefined}
logRows={rows}

View File

@ -74,8 +74,8 @@ interface Props extends Themeable2 {
loadLogsVolumeData: () => void;
showContextToggle?: (row?: LogRowModel) => boolean;
onChangeTime: (range: AbsoluteTimeRange) => void;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
onClickFilterLabel: (key: string, value: string) => void;
onClickFilterOutLabel: (key: string, value: string) => void;
onStartScanning?: () => void;
onStopScanning?: () => void;
getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise<any>;
@ -94,7 +94,7 @@ interface State {
hiddenLogLevels: LogLevel[];
logsSortOrder: LogsSortOrder | null;
isFlipping: boolean;
showDetectedFields: string[];
displayedFields: string[];
forceEscape: boolean;
}
@ -123,7 +123,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
hiddenLogLevels: [],
logsSortOrder: store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending,
isFlipping: false,
showDetectedFields: [],
displayedFields: [],
forceEscape: false,
};
@ -255,24 +255,24 @@ class UnthemedLogs extends PureComponent<Props, State> {
}
};
showDetectedField = (key: string) => {
const index = this.state.showDetectedFields.indexOf(key);
showField = (key: string) => {
const index = this.state.displayedFields.indexOf(key);
if (index === -1) {
this.setState((state) => {
return {
showDetectedFields: state.showDetectedFields.concat(key),
displayedFields: state.displayedFields.concat(key),
};
});
}
};
hideDetectedField = (key: string) => {
const index = this.state.showDetectedFields.indexOf(key);
hideField = (key: string) => {
const index = this.state.displayedFields.indexOf(key);
if (index > -1) {
this.setState((state) => {
return {
showDetectedFields: state.showDetectedFields.filter((k) => key !== k),
displayedFields: state.displayedFields.filter((k) => key !== k),
};
});
}
@ -281,7 +281,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
clearDetectedFields = () => {
this.setState((state) => {
return {
showDetectedFields: [],
displayedFields: [],
};
});
};
@ -355,7 +355,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
hiddenLogLevels,
logsSortOrder,
isFlipping,
showDetectedFields,
displayedFields,
forceEscape,
} = this.state;
@ -477,7 +477,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
dedupCount={dedupCount}
hasUnescapedContent={hasUnescapedContent}
forceEscape={forceEscape}
showDetectedFields={showDetectedFields}
displayedFields={displayedFields}
onEscapeNewlines={this.onEscapeNewlines}
clearDetectedFields={this.clearDetectedFields}
/>
@ -500,9 +500,9 @@ class UnthemedLogs extends PureComponent<Props, State> {
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
showDetectedFields={showDetectedFields}
onClickShowDetectedField={this.showDetectedField}
onClickHideDetectedField={this.hideDetectedField}
displayedFields={displayedFields}
onClickShowField={this.showField}
onClickHideField={this.hideField}
app={CoreApp.Explore}
scrollElement={scrollElement}
onLogRowHover={this.onLogRowHover}

View File

@ -33,8 +33,8 @@ interface LogsContainerProps extends PropsFromRedux {
syncedTimes: boolean;
loadingState: LoadingState;
scrollElement?: HTMLDivElement;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
onClickFilterLabel: (key: string, value: string) => void;
onClickFilterOutLabel: (key: string, value: string) => void;
onStartScanning: () => void;
onStopScanning: () => void;
eventBus: EventBus;

View File

@ -23,7 +23,7 @@ export type Props = {
meta: LogsMetaItem[];
dedupStrategy: LogsDedupStrategy;
dedupCount: number;
showDetectedFields: string[];
displayedFields: string[];
hasUnescapedContent: boolean;
forceEscape: boolean;
logRows: LogRowModel[];
@ -36,7 +36,7 @@ export const LogsMetaRow = React.memo(
meta,
dedupStrategy,
dedupCount,
showDetectedFields,
displayedFields,
clearDetectedFields,
hasUnescapedContent,
forceEscape,
@ -74,11 +74,11 @@ export const LogsMetaRow = React.memo(
}
// Add detected fields info
if (showDetectedFields?.length > 0) {
if (displayedFields?.length > 0) {
logsMetaItem.push(
{
label: 'Showing only detected fields',
value: renderMetaItem(showDetectedFields, LogsMetaKind.LabelsMap),
value: renderMetaItem(displayedFields, LogsMetaKind.LabelsMap),
},
{
label: '',

View File

@ -8,12 +8,15 @@ import { createLogRow } from './__mocks__/logRow';
const setup = (propOverrides?: Partial<Props>, rowOverrides?: Partial<LogRowModel>) => {
const props: Props = {
displayedFields: [],
showDuplicates: false,
wrapLogMessage: false,
row: createLogRow({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides }),
getRows: () => [],
onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {},
onClickShowField: () => {},
onClickHideField: () => {},
theme: createTheme(),
...(propOverrides || {}),
};
@ -31,7 +34,7 @@ describe('LogDetails', () => {
describe('when labels are present', () => {
it('should render heading', () => {
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
expect(screen.getAllByLabelText('Log labels')).toHaveLength(1);
expect(screen.getAllByLabelText('Fields')).toHaveLength(1);
});
it('should render labels', () => {
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
@ -48,29 +51,15 @@ describe('LogDetails', () => {
expect(screen.getByLabelText('Log level').classList.toString()).not.toContain('logs-row__level');
});
});
describe('when row entry has parsable fields', () => {
it('should render heading ', () => {
setup(undefined, { entry: 'test=successful' });
expect(screen.getAllByTitle('Ad-hoc statistics')).toHaveLength(1);
});
it('should render detected fields', () => {
setup(undefined, { entry: 'test=successful' });
expect(screen.getByRole('cell', { name: 'test' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'successful' })).toBeInTheDocument();
});
});
describe('when row entry have parsable fields and labels are present', () => {
it('should render all headings', () => {
setup(undefined, { entry: 'test=successful', labels: { key: 'label' } });
expect(screen.getAllByLabelText('Log labels')).toHaveLength(1);
expect(screen.getAllByLabelText('Detected fields')).toHaveLength(1);
expect(screen.getAllByLabelText('Fields')).toHaveLength(1);
});
it('should render all labels and detected fields', () => {
setup(undefined, { entry: 'test=successful', labels: { key: 'label' } });
expect(screen.getByRole('cell', { name: 'key' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'label' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'test' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'successful' })).toBeInTheDocument();
});
});
describe('when row entry and labels are not present', () => {

View File

@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import { Field, LinkModel, LogRowModel, GrafanaTheme2, CoreApp, DataFrame } from '@grafana/data';
import { withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui';
import { CoreApp, DataFrame, Field, GrafanaTheme2, LinkModel, LogRowModel } from '@grafana/data';
import { Themeable2, withTheme2 } from '@grafana/ui';
import { calculateFieldStats, calculateLogsLabelStats, calculateStats, getParser } from '../utils';
@ -25,9 +25,9 @@ export interface Props extends Themeable2 {
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
showDetectedFields?: string[];
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
displayedFields?: string[];
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
}
const getStyles = (theme: GrafanaTheme2) => {
@ -52,7 +52,7 @@ const getStyles = (theme: GrafanaTheme2) => {
class UnThemedLogDetails extends PureComponent<Props> {
getParser = memoizeOne(getParser);
getStatsForDetectedField = (key: string) => {
getStatsForField = (key: string) => {
const matcher = this.getParser(this.props.row.entry)!.buildMatcher(key);
return calculateFieldStats(this.props.getRows(), matcher);
};
@ -68,9 +68,9 @@ class UnThemedLogDetails extends PureComponent<Props> {
getRows,
showDuplicates,
className,
onClickShowDetectedField,
onClickHideDetectedField,
showDetectedFields,
onClickShowField,
onClickHideField,
displayedFields,
getFieldLinks,
wrapLogMessage,
} = this.props;
@ -78,8 +78,12 @@ class UnThemedLogDetails extends PureComponent<Props> {
const styles = getStyles(theme);
const labels = row.labels ? row.labels : {};
const labelsAvailable = Object.keys(labels).length > 0;
const fields = getAllFields(row, getFieldLinks);
const detectedFieldsAvailable = fields && fields.length > 0;
const fieldsAndLinks = getAllFields(row, getFieldLinks);
const links = fieldsAndLinks.filter((f) => f.links?.length).sort();
const fields = fieldsAndLinks.filter((f) => f.links?.length === 0).sort();
const fieldsAvailable = fields && fields.length > 0;
const linksAvailable = links && links.length > 0;
// If logs with error, we are not showing the level color
const levelClassName = cx(!hasError && [style.logsRowLevel, styles.logsRowLevelDetails]);
@ -91,10 +95,10 @@ class UnThemedLogDetails extends PureComponent<Props> {
<div className={style.logDetailsContainer}>
<table className={style.logDetailsTable}>
<tbody>
{labelsAvailable && (
{(labelsAvailable || fieldsAvailable) && (
<tr>
<td colSpan={5} className={style.logDetailsHeading} aria-label="Log labels">
Log labels
<td colSpan={100} className={style.logDetailsHeading} aria-label="Fields">
Fields
</td>
</tr>
)}
@ -111,29 +115,47 @@ class UnThemedLogDetails extends PureComponent<Props> {
getStats={() => calculateLogsLabelStats(getRows(), key)}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickFilterLabel={onClickFilterLabel}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
row={row}
app={app}
wrapLogMessage={wrapLogMessage}
displayedFields={displayedFields}
/>
);
})}
{fields.map((field) => {
const { key, value, fieldIndex } = field;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickFilterLabel={onClickFilterLabel}
getStats={() =>
fieldIndex === undefined
? this.getStatsForField(key)
: calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())
}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
row={row}
app={app}
/>
);
})}
{detectedFieldsAvailable && (
{linksAvailable && (
<tr>
<td colSpan={5} className={style.logDetailsHeading} aria-label="Detected fields">
Detected fields
<Tooltip content="Fields that are parsed from log message and detected by Grafana.">
<Icon
name="question-circle"
size="xs"
className={css`
margin-left: ${theme.spacing(0.5)};
`}
/>
</Tooltip>
<td colSpan={100} className={style.logDetailsHeading} aria-label="Data Links">
Links
</td>
</tr>
)}
{fields.sort().map((field) => {
{links.map((field) => {
const { key, value, links, fieldIndex } = field;
return (
<LogDetailsRow
@ -141,23 +163,23 @@ class UnThemedLogDetails extends PureComponent<Props> {
parsedKey={key}
parsedValue={value}
links={links}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getStats={() =>
fieldIndex === undefined
? this.getStatsForDetectedField(key)
? this.getStatsForField(key)
: calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())
}
showDetectedFields={showDetectedFields}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
row={row}
app={app}
/>
);
})}
{!detectedFieldsAvailable && !labelsAvailable && (
{!fieldsAvailable && !labelsAvailable && !linksAvailable && (
<tr>
<td colSpan={5} aria-label="No details">
<td colSpan={100} aria-label="No details">
No details available
</td>
</tr>

View File

@ -1,5 +1,4 @@
import { screen, render, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { LogRowModel } from '@grafana/data';
@ -17,9 +16,9 @@ const setup = (propOverrides?: Partial<Props>) => {
getStats: () => null,
onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {},
onClickShowDetectedField: () => {},
onClickHideDetectedField: () => {},
showDetectedFields: [],
onClickShowField: () => {},
onClickHideField: () => {},
displayedFields: [],
row: {} as LogRowModel,
};
@ -51,40 +50,24 @@ describe('LogDetailsRow', () => {
it('should render metrics button', () => {
setup();
expect(screen.getAllByTitle('Ad-hoc statistics')).toHaveLength(1);
expect(screen.getAllByRole('button', { name: 'Ad-hoc statistics' })).toHaveLength(1);
});
describe('if props is a label', () => {
it('should render filter label button', () => {
setup();
expect(screen.getAllByTitle('Filter for value')).toHaveLength(1);
expect(screen.getAllByRole('button', { name: 'Filter for value' })).toHaveLength(1);
});
it('should render filter out label button', () => {
setup();
expect(screen.getAllByTitle('Filter out value')).toHaveLength(1);
});
it('should not render filtering buttons if no filtering functions provided', () => {
setup({ onClickFilterLabel: undefined, onClickFilterOutLabel: undefined });
expect(screen.queryByTitle('Filter out value')).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Filter out value' })).toHaveLength(1);
});
});
describe('if props is not a label', () => {
it('should not render a filter label button', () => {
setup({ isLabel: false });
expect(screen.queryByTitle('Filter for value')).not.toBeInTheDocument();
});
it('should render a show toggleFieldButton button', () => {
setup({ isLabel: false });
expect(screen.getAllByTitle('Show this field instead of the message')).toHaveLength(1);
});
it('should not render a show toggleFieldButton button if no detected fields toggling functions provided', () => {
setup({
isLabel: false,
onClickShowDetectedField: undefined,
onClickHideDetectedField: undefined,
});
expect(screen.queryByTitle('Show this field instead of the message')).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Show this field instead of the message' })).toHaveLength(1);
});
});
@ -109,21 +92,9 @@ describe('LogDetailsRow', () => {
});
expect(screen.queryByTestId('logLabelStats')).not.toBeInTheDocument();
const adHocStatsButton = screen.getByTitle('Ad-hoc statistics');
const adHocStatsButton = screen.getByRole('button', { name: 'Ad-hoc statistics' });
fireEvent.click(adHocStatsButton);
expect(screen.getByTestId('logLabelStats')).toBeInTheDocument();
expect(screen.getByTestId('logLabelStats')).toHaveTextContent('another value');
});
it('should render clipboard button on hover of log row table value', async () => {
setup({ parsedKey: 'key', parsedValue: 'value' });
const valueCell = screen.getByRole('cell', { name: 'value' });
await userEvent.hover(valueCell);
expect(screen.getByRole('button', { name: 'Copy value to clipboard' })).toBeInTheDocument();
await userEvent.unhover(valueCell);
expect(screen.queryByRole('button', { name: 'Copy value to clipboard' })).not.toBeInTheDocument();
});
});

View File

@ -1,9 +1,10 @@
import { css, cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import { Field, LinkModel, LogLabelStatsModel, GrafanaTheme2, LogRowModel, CoreApp } from '@grafana/data';
import { CoreApp, Field, GrafanaTheme2, LinkModel, LogLabelStatsModel, LogRowModel } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { withTheme2, Themeable2, ClipboardButton, DataLinkButton, IconButton } from '@grafana/ui';
import { ClipboardButton, DataLinkButton, Themeable2, ToolbarButton, ToolbarButtonRow, withTheme2 } from '@grafana/ui';
import { LogLabelStats } from './LogLabelStats';
import { getLogRowStyles } from './getLogRowStyles';
@ -19,9 +20,9 @@ export interface Props extends Themeable2 {
onClickFilterOutLabel?: (key: string, value: string) => void;
links?: Array<LinkModel<Field>>;
getStats: () => LogLabelStatsModel[] | null;
showDetectedFields?: string[];
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
displayedFields?: string[];
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
row: LogRowModel;
app?: CoreApp;
}
@ -30,10 +31,43 @@ interface State {
showFieldsStats: boolean;
fieldCount: number;
fieldStats: LogLabelStatsModel[] | null;
mouseOver: boolean;
}
const getStyles = (theme: GrafanaTheme2) => {
const getStyles = memoizeOne((theme: GrafanaTheme2, activeButton: boolean) => {
// those styles come from ToolbarButton. Unfortunately this is needed because we can not control the variant of the menu-button in a ToolbarButtonRow.
const defaultOld = css`
color: ${theme.colors.text.secondary};
background-color: ${theme.colors.background.primary};
&:hover {
color: ${theme.colors.text.primary};
background: ${theme.colors.background.secondary};
}
`;
const defaultTopNav = css`
color: ${theme.colors.text.secondary};
background-color: transparent;
border-color: transparent;
&:hover {
color: ${theme.colors.text.primary};
background: ${theme.colors.background.secondary};
}
`;
const active = css`
color: ${theme.v1.palette.orangeDark};
border-color: ${theme.v1.palette.orangeDark};
background-color: transparent;
&:hover {
color: ${theme.colors.text.primary};
background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)};
}
`;
const defaultToolbarButtonStyle = theme.flags.topnav ? defaultTopNav : defaultOld;
return {
noHoverBackground: css`
label: noHoverBackground;
@ -52,32 +86,83 @@ const getStyles = (theme: GrafanaTheme2) => {
showingField: css`
color: ${theme.colors.primary.text};
`,
hoverValueCopy: css`
margin: ${theme.spacing(0, 0, 0, 1.2)};
position: absolute;
top: 0px;
justify-content: center;
border-radius: ${theme.shape.borderRadius(10)};
width: ${theme.spacing(3.25)};
height: ${theme.spacing(3.25)};
copyButton: css`
& > button {
color: ${theme.colors.text.secondary};
padding: 0;
justify-content: center;
border-radius: 50%;
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};
}
}
}
`,
wrapLine: css`
label: wrapLine;
white-space: pre-wrap;
`,
toolbarButtonRow: css`
label: toolbarButtonRow;
gap: ${theme.spacing(0.5)};
max-width: calc(3 * ${theme.spacing(theme.components.height.sm)});
& > div {
height: ${theme.spacing(theme.components.height.sm)};
width: ${theme.spacing(theme.components.height.sm)};
& > button {
border: 0;
background-color: transparent;
height: inherit;
&:hover {
box-shadow: none;
border-radius: 50%;
}
}
}
& div:last-child > button:not(.stats-button) {
${activeButton ? active : defaultToolbarButtonStyle};
}
`,
logDetailsStats: css`
padding: 0 ${theme.spacing(1)};
`,
logDetailsValue: css`
display: table-cell;
vertical-align: middle;
line-height: 22px;
.show-on-hover {
display: inline;
visibility: hidden;
}
&:hover {
.show-on-hover {
visibility: visible;
}
}
`,
};
};
});
class UnThemedLogDetailsRow extends PureComponent<Props, State> {
state: State = {
showFieldsStats: false,
fieldCount: 0,
fieldStats: null,
mouseOver: false,
};
showField = () => {
const { onClickShowDetectedField, parsedKey, row } = this.props;
const { onClickShowField: onClickShowDetectedField, parsedKey, row } = this.props;
if (onClickShowDetectedField) {
onClickShowDetectedField(parsedKey);
}
@ -90,7 +175,7 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
};
hideField = () => {
const { onClickHideDetectedField, parsedKey, row } = this.props;
const { onClickHideField: onClickHideDetectedField, parsedKey, row } = this.props;
if (onClickHideDetectedField) {
onClickHideDetectedField(parsedKey);
}
@ -155,11 +240,6 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
});
}
hoverValueCopy() {
const mouseOver = !this.state.mouseOver;
this.setState({ mouseOver });
}
render() {
const {
theme,
@ -167,87 +247,120 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
parsedValue,
isLabel,
links,
showDetectedFields,
displayedFields,
wrapLogMessage,
onClickShowDetectedField,
onClickHideDetectedField,
onClickFilterLabel,
onClickFilterOutLabel,
} = this.props;
const { showFieldsStats, fieldStats, fieldCount, mouseOver } = this.state;
const styles = getStyles(theme);
const { showFieldsStats, fieldStats, fieldCount } = this.state;
const activeButton = displayedFields?.includes(parsedKey) || showFieldsStats;
const styles = getStyles(theme, activeButton);
const style = getLogRowStyles(theme);
const hasDetectedFieldsFunctionality = onClickShowDetectedField && onClickHideDetectedField;
const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel;
const toggleFieldButton =
!isLabel && showDetectedFields && showDetectedFields.includes(parsedKey) ? (
<IconButton name="eye" className={styles.showingField} title="Hide this field" onClick={this.hideField} />
displayedFields && displayedFields.includes(parsedKey) ? (
<ToolbarButton variant="active" tooltip="Hide this field" iconOnly narrow icon="eye" onClick={this.hideField} />
) : (
<IconButton name="eye" title="Show this field instead of the message" onClick={this.showField} />
<ToolbarButton
tooltip="Show this field instead of the message"
iconOnly
narrow
icon="eye"
onClick={this.showField}
/>
);
return (
<tr className={cx(style.logDetailsValue, { [styles.noHoverBackground]: showFieldsStats })}>
{/* Action buttons - show stats/filter results */}
<td className={style.logsDetailsIcon}>
<IconButton name="signal" title={'Ad-hoc statistics'} onClick={this.showStats} />
</td>
{hasFilteringFunctionality && isLabel && (
<>
<td className={style.logsDetailsIcon}>
<IconButton name="search-plus" title="Filter for value" onClick={this.filterLabel} />
</td>
<td className={style.logsDetailsIcon}>
<IconButton name="search-minus" title="Filter out value" onClick={this.filterOutLabel} />
</td>
</>
)}
{hasDetectedFieldsFunctionality && !isLabel && (
<td className={style.logsDetailsIcon} colSpan={2}>
{toggleFieldButton}
<>
<tr className={cx(style.logDetailsValue)}>
<td className={style.logsDetailsIcon}>
<ToolbarButtonRow alignment="left" className={styles.toolbarButtonRow}>
{hasFilteringFunctionality && (
<ToolbarButton
iconOnly
narrow
icon="search-plus"
tooltip="Filter for value"
onClick={this.filterLabel}
/>
)}
{hasFilteringFunctionality && (
<ToolbarButton
iconOnly
narrow
icon="search-minus"
tooltip="Filter out value"
onClick={this.filterOutLabel}
/>
)}
{displayedFields && toggleFieldButton}
<ToolbarButton
iconOnly
variant={showFieldsStats ? 'active' : 'default'}
narrow
icon="signal"
tooltip="Ad-hoc statistics"
className="stats-button"
onClick={this.showStats}
/>
</ToolbarButtonRow>
</td>
)}
{/* Key - value columns */}
<td className={style.logDetailsLabel}>{parsedKey}</td>
<td
className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}
onMouseEnter={this.hoverValueCopy.bind(this)}
onMouseLeave={this.hoverValueCopy.bind(this)}
>
{parsedValue}
{mouseOver && (
<ClipboardButton
getText={() => parsedValue}
title="Copy value to clipboard"
fill="text"
variant="secondary"
icon="copy"
size="sm"
className={styles.hoverValueCopy}
/>
)}
{links?.map((link) => (
<span key={link.title}>
&nbsp;
<DataLinkButton link={link} />
</span>
))}
{showFieldsStats && (
<LogLabelStats
stats={fieldStats!}
label={parsedKey}
value={parsedValue}
rowCount={fieldCount}
isLabel={isLabel}
/>
)}
</td>
</tr>
{/* Key - value columns */}
<td className={style.logDetailsLabel}>{parsedKey}</td>
<td className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}>
<div className={styles.logDetailsValue}>
{parsedValue}
<div className={cx('show-on-hover', styles.copyButton)}>
<ClipboardButton
getText={() => parsedValue}
title="Copy value to clipboard"
fill="text"
variant="secondary"
icon="copy"
size="md"
/>
</div>
{links?.map((link) => (
<span key={link.title}>
&nbsp;
<DataLinkButton link={link} />
</span>
))}
</div>
</td>
</tr>
{showFieldsStats && (
<tr>
<td>
<ToolbarButtonRow alignment="left" className={styles.toolbarButtonRow}>
<ToolbarButton
iconOnly
variant={showFieldsStats ? 'active' : 'default'}
narrow
icon="signal"
tooltip="Hide ad-hoc statistics"
onClick={this.showStats}
/>
</ToolbarButtonRow>
</td>
<td colSpan={2}>
<div className={styles.logDetailsStats}>
<LogLabelStats
stats={fieldStats!}
label={parsedKey}
value={parsedValue}
rowCount={fieldCount}
isLabel={isLabel}
/>
</div>
</td>
</tr>
)}
</>
);
}
}

View File

@ -28,7 +28,7 @@ import {
RowContextOptions,
} from './LogRowContextProvider';
import { LogRowMessage } from './LogRowMessage';
import { LogRowMessageDetectedFields } from './LogRowMessageDetectedFields';
import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields';
import { getLogRowStyles } from './getLogRowStyles';
//Components
@ -44,10 +44,10 @@ interface Props extends Themeable2 {
enableLogDetails: boolean;
logsSortOrder?: LogsSortOrder | null;
forceEscape?: boolean;
showDetectedFields?: string[];
scrollElement?: HTMLDivElement;
showRowMenu?: boolean;
app?: CoreApp;
displayedFields?: string[];
getRows: () => LogRowModel[];
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
@ -55,8 +55,8 @@ interface Props extends Themeable2 {
getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise<DataQueryResponse>;
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
showContextToggle?: (row?: LogRowModel) => boolean;
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onLogRowHover?: (row?: LogRowModel) => void;
toggleContextIsOpen?: () => void;
}
@ -149,8 +149,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
getRows,
onClickFilterLabel,
onClickFilterOutLabel,
onClickShowDetectedField,
onClickHideDetectedField,
onClickShowField,
onClickHideField,
enableLogDetails,
row,
showDuplicates,
@ -158,7 +158,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
showRowMenu,
showLabels,
showTime,
showDetectedFields,
displayedFields,
wrapLogMessage,
prettifyLogMessage,
theme,
@ -217,10 +217,10 @@ class UnThemedLogRow extends PureComponent<Props, State> {
<LogLabels labels={processedRow.uniqueLabels} />
</td>
)}
{showDetectedFields && showDetectedFields.length > 0 ? (
<LogRowMessageDetectedFields
{displayedFields && displayedFields.length > 0 ? (
<LogRowMessageDisplayedFields
row={processedRow}
showDetectedFields={showDetectedFields!}
showDetectedFields={displayedFields!}
getFieldLinks={getFieldLinks}
wrapLogMessage={wrapLogMessage}
/>
@ -251,13 +251,13 @@ class UnThemedLogRow extends PureComponent<Props, State> {
getFieldLinks={getFieldLinks}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getRows={getRows}
row={processedRow}
wrapLogMessage={wrapLogMessage}
hasError={hasError}
showDetectedFields={showDetectedFields}
displayedFields={displayedFields}
app={app}
/>
)}

View File

@ -13,7 +13,7 @@ export interface Props extends Themeable2 {
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
}
class UnThemedLogRowMessageDetectedFields extends PureComponent<Props> {
class UnThemedLogRowMessageDisplayedFields extends PureComponent<Props> {
render() {
const { row, showDetectedFields, getFieldLinks, wrapLogMessage } = this.props;
const fields = getAllFields(row, getFieldLinks);
@ -30,10 +30,14 @@ class UnThemedLogRowMessageDetectedFields extends PureComponent<Props> {
return key === parsedKey;
});
if (field) {
if (field !== undefined && field !== null) {
return `${parsedKey}=${field.value}`;
}
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {
return `${parsedKey}=${row.labels[parsedKey]}`;
}
return null;
})
.filter((s) => s !== null)
@ -43,5 +47,5 @@ class UnThemedLogRowMessageDetectedFields extends PureComponent<Props> {
}
}
export const LogRowMessageDetectedFields = withTheme2(UnThemedLogRowMessageDetectedFields);
LogRowMessageDetectedFields.displayName = 'LogRowMessageDetectedFields';
export const LogRowMessageDisplayedFields = withTheme2(UnThemedLogRowMessageDisplayedFields);
LogRowMessageDisplayedFields.displayName = 'LogRowMessageDisplayedFields';

View File

@ -20,6 +20,11 @@ describe('LogRows', () => {
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
@ -62,6 +67,11 @@ describe('LogRows', () => {
timeZone={'utc'}
previewLimit={1}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
@ -87,6 +97,11 @@ describe('LogRows', () => {
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
expect(screen.queryAllByRole('row')).toHaveLength(2);
@ -107,6 +122,11 @@ describe('LogRows', () => {
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
@ -131,6 +151,11 @@ describe('LogRows', () => {
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Ascending}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
@ -155,6 +180,11 @@ describe('LogRows', () => {
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Descending}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);

View File

@ -35,7 +35,7 @@ export interface Props extends Themeable2 {
logsSortOrder?: LogsSortOrder | null;
previewLimit?: number;
forceEscape?: boolean;
showDetectedFields?: string[];
displayedFields?: string[];
app?: CoreApp;
scrollElement?: HTMLDivElement;
showContextToggle?: (row?: LogRowModel) => boolean;
@ -43,8 +43,8 @@ export interface Props extends Themeable2 {
onClickFilterOutLabel?: (key: string, value: string) => void;
getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise<any>;
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onLogRowHover?: (row?: LogRowModel) => void;
}
@ -121,9 +121,9 @@ class UnThemedLogRows extends PureComponent<Props, State> {
previewLimit,
getFieldLinks,
logsSortOrder,
showDetectedFields,
onClickShowDetectedField,
onClickHideDetectedField,
displayedFields,
onClickShowField,
onClickHideField,
forceEscape,
onLogRowHover,
app,
@ -162,15 +162,15 @@ class UnThemedLogRows extends PureComponent<Props, State> {
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
enableLogDetails={enableLogDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
@ -193,15 +193,15 @@ class UnThemedLogRows extends PureComponent<Props, State> {
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
enableLogDetails={enableLogDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}

View File

@ -147,8 +147,9 @@ export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => {
label: logs-row-details__icon;
position: relative;
color: ${theme.v1.palette.gray3};
padding-top: ${theme.spacing(0.75)};
padding-left: ${theme.spacing(0.75)};
padding-top: 1px;
padding-bottom: 1px;
padding-right: ${theme.spacing(0.75)};
`,
logDetailsLabel: css`
label: logs-row-details__label;

View File

@ -2,12 +2,6 @@ import memoizeOne from 'memoize-one';
import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data';
import { getParser } from '../utils';
import { MAX_CHARACTERS } from './LogRowMessage';
const memoizedGetParser = memoizeOne(getParser);
type FieldDef = {
key: string;
value: string;
@ -24,56 +18,15 @@ export const getAllFields = memoizeOne(
row: LogRowModel,
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>
) => {
const logMessageFields = parseMessage(row.entry);
const dataframeFields = getDataframeFields(row, getFieldLinks);
const fieldsMap = [...dataframeFields, ...logMessageFields].reduce((acc, field) => {
// Strip enclosing quotes for hashing. When values are parsed from log line the quotes are kept, but if same
// value is in the dataFrame it will be without the quotes. We treat them here as the same value.
// We need to handle this scenario:
// - we use derived-fields in Loki
// - we name the derived field the same as the parsed-field-name
// - the same field will appear twice
// - in the fields coming from `logMessageFields`
// - in the fields coming from `dataframeFields`
// - but, in the fields coming from `logMessageFields`, there might be doublequotes around the value
// - we want to "merge" data from both sources, so we remove quotes from the beginning and end
const value = field.value.replace(/(^")|("$)/g, '');
const fieldHash = `${field.key}=${value}`;
if (acc[fieldHash]) {
acc[fieldHash].links = [...(acc[fieldHash].links || []), ...(field.links || [])];
} else {
acc[fieldHash] = field;
}
return acc;
}, {} as { [key: string]: FieldDef });
const allFields = Object.values(fieldsMap);
allFields.sort(sortFieldsLinkFirst);
return allFields;
return Object.values(dataframeFields);
}
);
const parseMessage = memoizeOne((rowEntry): FieldDef[] => {
if (rowEntry.length > MAX_CHARACTERS) {
return [];
}
const parser = memoizedGetParser(rowEntry);
if (!parser) {
return [];
}
// Use parser to highlight detected fields
const detectedFields = parser.getFields(rowEntry);
const fields = detectedFields.map((field) => {
const key = parser.getLabelFromField(field);
const value = parser.getValueFromField(field);
return { key, value };
});
return fields;
});
// creates fields from the dataframe-fields, adding data-links, when field.config.links exists
/**
* creates fields from the dataframe-fields, adding data-links, when field.config.links exists
*/
const getDataframeFields = memoizeOne(
(
row: LogRowModel,
@ -94,30 +47,28 @@ const getDataframeFields = memoizeOne(
}
);
function sortFieldsLinkFirst(fieldA: FieldDef, fieldB: FieldDef) {
if (fieldA.links?.length && !fieldB.links?.length) {
return -1;
}
if (!fieldA.links?.length && fieldB.links?.length) {
return 1;
}
return fieldA.key > fieldB.key ? 1 : fieldA.key < fieldB.key ? -1 : 0;
}
function shouldRemoveField(field: Field, index: number, row: LogRowModel) {
// Remove field if it is:
// "labels" field that is in Loki used to store all labels
if (field.name === 'labels' && field.type === FieldType.other) {
return true;
}
// "id" field which we use for react key
if (field.name === 'id') {
// id and tsNs are arbitrary added fields in the backend and should be hidden in the UI
if (field.name === 'id' || field.name === 'tsNs') {
return true;
}
// entry field which we are showing as the log message
if (row.entryFieldIndex === index) {
return true;
}
const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time);
if (
field.name === firstTimeField?.name &&
field.type === FieldType.time &&
field.values.get(0) === firstTimeField.values.get(0)
) {
return true;
}
// hidden field
if (field.config.custom?.hidden) {
return true;