Logs: move logs-components from grafana-ui to grafana-main (#55041)

* logs: added a copy of the grafana-ui logs-files

* fix: added the ansicolor package to the main grafana package

* logs-components: import things from grafana-ui

* import from local files

* logs-components: other fixes

* add betterer-exceptions

* apply updates from grafana-ui package
This commit is contained in:
Gábor Farkas 2022-09-19 10:51:46 +02:00 committed by GitHub
parent 32c4245efd
commit 1ee6a1f7c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 3106 additions and 10 deletions

View File

@ -59,7 +59,7 @@ exports[`no enzyme tests`] = {
"public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.test.tsx:145048794": [
[0, 17, 13, "RegExp match", "2409514259"]
],
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:4057721851": [
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:3543762762": [
[1, 19, 13, "RegExp match", "2409514259"]
]
}`
@ -4673,6 +4673,19 @@ exports[`better eslint`] = {
"public/app/features/live/pages/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/logs/components/LogRowContextProvider.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
],
"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/manage-dashboards/DashboardImportPage.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
@ -5895,6 +5908,9 @@ exports[`better eslint`] = {
"public/app/plugins/datasource/cloud-monitoring/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/datasource/cloudwatch/__mocks__/LogsQueryRunner.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -298,6 +298,7 @@
"angular-bindonce": "0.3.1",
"angular-route": "1.8.3",
"angular-sanitize": "1.8.3",
"ansicolor": "1.1.100",
"app": "link:./public/app",
"baron": "3.0.3",
"brace": "0.11.1",

View File

@ -3,7 +3,10 @@ import React, { PureComponent } from 'react';
import tinycolor from 'tinycolor2';
import { LogRowModel, TimeZone, dateTimeFormat, GrafanaTheme2 } from '@grafana/data';
import { LogMessageAnsi, getLogRowStyles, Icon, Button, Themeable2, withTheme2 } from '@grafana/ui';
import { Icon, Button, Themeable2, withTheme2 } from '@grafana/ui';
import { LogMessageAnsi } from '../logs/components/LogMessageAnsi';
import { getLogRowStyles } from '../logs/components/getLogRowStyles';
import { ElapsedTime } from './ElapsedTime';

View File

@ -26,7 +26,6 @@ import {
import { reportInteraction } from '@grafana/runtime';
import {
RadioButtonGroup,
LogRows,
Button,
InlineField,
InlineFieldRow,
@ -34,11 +33,13 @@ import {
withTheme2,
Themeable2,
} from '@grafana/ui';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import { dedupLogRows, filterLogLevels } from 'app/core/logsModel';
import store from 'app/core/store';
import { ExploreId } from 'app/types/explore';
import { RowContextOptions } from '../logs/components/LogRowContextProvider';
import { LogRows } from '../logs/components/LogRows';
import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsVolumePanel } from './LogsVolumePanel';

View File

@ -1,8 +1,10 @@
import React from 'react';
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel } from '@grafana/data';
import { Button, Tooltip, LogLabels } from '@grafana/ui';
import { MAX_CHARACTERS } from '@grafana/ui/src/components/Logs/LogRowMessage';
import { Button, Tooltip } from '@grafana/ui';
import { LogLabels } from '../logs/components/LogLabels';
import { MAX_CHARACTERS } from '../logs/components/LogRowMessage';
import { MetaInfoText, MetaItemProps } from './MetaInfoText';

View File

@ -0,0 +1,130 @@
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { Field, LogLevel, LogRowModel, MutableDataFrame, createTheme } from '@grafana/data';
import { LogDetails, Props } from './LogDetails';
import { createLogRow } from './__mocks__/logRow';
const setup = (propOverrides?: Partial<Props>, rowOverrides?: Partial<LogRowModel>) => {
const props: Props = {
showDuplicates: false,
wrapLogMessage: false,
row: createLogRow({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides }),
getRows: () => [],
onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {},
theme: createTheme(),
...(propOverrides || {}),
};
render(
<table>
<tbody>
<LogDetails {...props} />
</tbody>
</table>
);
};
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);
});
it('should render labels', () => {
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'label1' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'key2' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'label2' })).toBeInTheDocument();
});
});
describe('when log row has error', () => {
it('should not render log level border', () => {
// Is this a good test case for RTL??
setup({ hasError: true }, undefined);
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);
});
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', () => {
it('should render no details available message', () => {
setup(undefined, { entry: '' });
expect(screen.getByText('No details available')).toBeInTheDocument();
});
it('should not render headings', () => {
setup(undefined, { entry: '' });
expect(screen.queryAllByLabelText('Log labels')).toHaveLength(0);
expect(screen.queryAllByLabelText('Detected fields')).toHaveLength(0);
});
});
it('should render fields from dataframe with links', () => {
const entry = 'traceId=1234 msg="some message"';
const dataFrame = new MutableDataFrame({
fields: [
{ name: 'entry', values: [entry] },
// As we have traceId in message already this will shadow it.
{
name: 'traceId',
values: ['1234'],
config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] },
},
{ name: 'userId', values: ['5678'] },
],
});
setup(
{
getFieldLinks: (field: Field, rowIndex: number) => {
if (field.config && field.config.links) {
return field.config.links.map((link) => {
return {
href: link.url.replace('${__value.text}', field.values.get(rowIndex)),
title: link.title,
target: '_blank',
origin: field,
};
});
}
return [];
},
},
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }
);
expect(screen.getAllByRole('table')).toHaveLength(2);
const rowDetailsTable = screen.getAllByRole('table')[1];
const rowDetailRows = within(rowDetailsTable).getAllByRole('row');
expect(rowDetailRows).toHaveLength(4); // 3 LogDetailsRow + 1 header
const traceIdRow = within(rowDetailsTable).getByRole('cell', { name: 'traceId' }).closest('tr');
expect(traceIdRow).toBeInTheDocument();
const link = within(traceIdRow!).getByRole('link', { name: 'link' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'localhost:3210/1234');
});
});

View File

@ -0,0 +1,176 @@
import { css, cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import {
calculateFieldStats,
calculateLogsLabelStats,
calculateStats,
Field,
getParser,
LinkModel,
LogRowModel,
GrafanaTheme2,
} from '@grafana/data';
import { withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui';
import { LogDetailsRow } from './LogDetailsRow';
import { getLogRowStyles } from './getLogRowStyles';
import { getAllFields } from './logParser';
//Components
export interface Props extends Themeable2 {
row: LogRowModel;
showDuplicates: boolean;
getRows: () => LogRowModel[];
wrapLogMessage: boolean;
className?: string;
hasError?: boolean;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
showDetectedFields?: string[];
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
logsRowLevelDetails: css`
label: logs-row__level_details;
&::after {
top: -3px;
}
`,
logDetails: css`
label: logDetailsDefaultCursor;
cursor: default;
&:hover {
background-color: ${theme.colors.background.primary};
}
`,
};
};
class UnThemedLogDetails extends PureComponent<Props> {
getParser = memoizeOne(getParser);
getStatsForDetectedField = (key: string) => {
const matcher = this.getParser(this.props.row.entry)!.buildMatcher(key);
return calculateFieldStats(this.props.getRows(), matcher);
};
render() {
const {
row,
theme,
hasError,
onClickFilterOutLabel,
onClickFilterLabel,
getRows,
showDuplicates,
className,
onClickShowDetectedField,
onClickHideDetectedField,
showDetectedFields,
getFieldLinks,
wrapLogMessage,
} = this.props;
const style = getLogRowStyles(theme, row.logLevel);
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;
// If logs with error, we are not showing the level color
const levelClassName = cx(!hasError && [style.logsRowLevel, styles.logsRowLevelDetails]);
return (
<tr className={cx(className, styles.logDetails)}>
{showDuplicates && <td />}
<td className={levelClassName} aria-label="Log level" />
<td colSpan={4}>
<div className={style.logDetailsContainer}>
<table className={style.logDetailsTable}>
<tbody>
{labelsAvailable && (
<tr>
<td colSpan={5} className={style.logDetailsHeading} aria-label="Log labels">
Log labels
</td>
</tr>
)}
{Object.keys(labels)
.sort()
.map((key) => {
const value = labels[key];
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
isLabel={true}
getStats={() => calculateLogsLabelStats(getRows(), key)}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickFilterLabel={onClickFilterLabel}
/>
);
})}
{detectedFieldsAvailable && (
<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: 4px;
`}
/>
</Tooltip>
</td>
</tr>
)}
{fields.sort().map((field) => {
const { key, value, links, fieldIndex } = field;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
links={links}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getStats={() =>
fieldIndex === undefined
? this.getStatsForDetectedField(key)
: calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())
}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
/>
);
})}
{!detectedFieldsAvailable && !labelsAvailable && (
<tr>
<td colSpan={5} aria-label="No details">
No details available
</td>
</tr>
)}
</tbody>
</table>
</div>
</td>
</tr>
);
}
}
export const LogDetails = withTheme2(UnThemedLogDetails);
LogDetails.displayName = 'LogDetails';

View File

@ -0,0 +1,121 @@
import { screen, render, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { LogDetailsRow } from './LogDetailsRow';
type Props = ComponentProps<typeof LogDetailsRow>;
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
parsedValue: '',
parsedKey: '',
isLabel: true,
wrapLogMessage: false,
getStats: () => null,
onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {},
onClickShowDetectedField: () => {},
onClickHideDetectedField: () => {},
showDetectedFields: [],
};
Object.assign(props, propOverrides);
return render(
<table>
<tbody>
<LogDetailsRow {...props} />
</tbody>
</table>
);
};
describe('LogDetailsRow', () => {
it('should render parsed key', () => {
setup({ parsedKey: 'test key' });
expect(screen.getByText('test key')).toBeInTheDocument();
});
it('should render parsed value', () => {
setup({ parsedValue: 'test value' });
expect(screen.getByText('test value')).toBeInTheDocument();
});
it('should render metrics button', () => {
setup();
expect(screen.getAllByTitle('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);
});
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();
});
});
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();
});
});
it('should render stats when stats icon is clicked', () => {
setup({
parsedKey: 'key',
parsedValue: 'value',
getStats: () => {
return [
{
count: 1,
proportion: 1 / 2,
value: 'value',
},
{
count: 1,
proportion: 1 / 2,
value: 'another value',
},
];
},
});
expect(screen.queryByTestId('logLabelStats')).not.toBeInTheDocument();
const adHocStatsButton = screen.getByTitle('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

@ -0,0 +1,219 @@
import { css, cx } from '@emotion/css';
import React, { PureComponent } from 'react';
import { Field, LinkModel, LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
import { withTheme2, Themeable2, ClipboardButton, DataLinkButton, IconButton } from '@grafana/ui';
import { LogLabelStats } from './LogLabelStats';
import { getLogRowStyles } from './getLogRowStyles';
//Components
export interface Props extends Themeable2 {
parsedValue: string;
parsedKey: string;
wrapLogMessage?: boolean;
isLabel?: boolean;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
links?: Array<LinkModel<Field>>;
getStats: () => LogLabelStatsModel[] | null;
showDetectedFields?: string[];
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
}
interface State {
showFieldsStats: boolean;
fieldCount: number;
fieldStats: LogLabelStatsModel[] | null;
mouseOver: boolean;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
noHoverBackground: css`
label: noHoverBackground;
:hover {
background-color: transparent;
}
`,
hoverCursor: css`
label: hoverCursor;
cursor: pointer;
`,
wordBreakAll: css`
label: wordBreakAll;
word-break: break-all;
`,
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: 20px;
width: 26px;
height: 26px;
`,
wrapLine: css`
label: wrapLine;
white-space: pre-wrap;
`,
};
};
class UnThemedLogDetailsRow extends PureComponent<Props, State> {
state: State = {
showFieldsStats: false,
fieldCount: 0,
fieldStats: null,
mouseOver: false,
};
showField = () => {
const { onClickShowDetectedField, parsedKey } = this.props;
if (onClickShowDetectedField) {
onClickShowDetectedField(parsedKey);
}
};
hideField = () => {
const { onClickHideDetectedField, parsedKey } = this.props;
if (onClickHideDetectedField) {
onClickHideDetectedField(parsedKey);
}
};
filterLabel = () => {
const { onClickFilterLabel, parsedKey, parsedValue } = this.props;
if (onClickFilterLabel) {
onClickFilterLabel(parsedKey, parsedValue);
}
};
filterOutLabel = () => {
const { onClickFilterOutLabel, parsedKey, parsedValue } = this.props;
if (onClickFilterOutLabel) {
onClickFilterOutLabel(parsedKey, parsedValue);
}
};
showStats = () => {
const { showFieldsStats } = this.state;
if (!showFieldsStats) {
const fieldStats = this.props.getStats();
const fieldCount = fieldStats ? fieldStats.reduce((sum, stat) => sum + stat.count, 0) : 0;
this.setState({ fieldStats, fieldCount });
}
this.toggleFieldsStats();
};
toggleFieldsStats() {
this.setState((state) => {
return {
showFieldsStats: !state.showFieldsStats,
};
});
}
hoverValueCopy() {
const mouseOver = !this.state.mouseOver;
this.setState({ mouseOver });
}
render() {
const {
theme,
parsedKey,
parsedValue,
isLabel,
links,
showDetectedFields,
wrapLogMessage,
onClickShowDetectedField,
onClickHideDetectedField,
onClickFilterLabel,
onClickFilterOutLabel,
} = this.props;
const { showFieldsStats, fieldStats, fieldCount, mouseOver } = this.state;
const styles = getStyles(theme);
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} />
) : (
<IconButton name="eye" title="Show this field instead of the message" 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}
</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>
);
}
}
export const LogDetailsRow = withTheme2(UnThemedLogDetailsRow);
LogDetailsRow.displayName = 'LogDetailsRow';

View File

@ -0,0 +1,97 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
import { stylesFactory, withTheme2, Themeable2 } from '@grafana/ui';
//Components
import { LogLabelStatsRow } from './LogLabelStatsRow';
const STATS_ROW_LIMIT = 5;
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
logsStats: css`
label: logs-stats;
background: inherit;
color: ${theme.colors.text.primary};
word-break: break-all;
width: fit-content;
max-width: 100%;
`,
logsStatsHeader: css`
label: logs-stats__header;
border-bottom: 1px solid ${theme.colors.border.medium};
display: flex;
`,
logsStatsTitle: css`
label: logs-stats__title;
font-weight: ${theme.typography.fontWeightMedium};
padding-right: ${theme.spacing(2)};
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 1;
`,
logsStatsClose: css`
label: logs-stats__close;
cursor: pointer;
`,
logsStatsBody: css`
label: logs-stats__body;
padding: 5px 0;
`,
};
});
interface Props extends Themeable2 {
stats: LogLabelStatsModel[];
label: string;
value: string;
rowCount: number;
isLabel?: boolean;
}
class UnThemedLogLabelStats extends PureComponent<Props> {
render() {
const { label, rowCount, stats, value, theme, isLabel } = this.props;
const style = getStyles(theme);
const topRows = stats.slice(0, STATS_ROW_LIMIT);
let activeRow = topRows.find((row) => row.value === value);
let otherRows = stats.slice(STATS_ROW_LIMIT);
const insertActiveRow = !activeRow;
// Remove active row from other to show extra
if (insertActiveRow) {
activeRow = otherRows.find((row) => row.value === value);
otherRows = otherRows.filter((row) => row.value !== value);
}
const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
const total = topCount + otherCount;
const otherProportion = otherCount / total;
return (
<div className={style.logsStats} data-testid="logLabelStats">
<div className={style.logsStatsHeader}>
<div className={style.logsStatsTitle}>
{label}: {total} of {rowCount} rows have that {isLabel ? 'label' : 'field'}
</div>
</div>
<div className={style.logsStatsBody}>
{topRows.map((stat) => (
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
))}
{insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />}
{otherCount > 0 && (
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
)}
</div>
</div>
);
}
}
export const LogLabelStats = withTheme2(UnThemedLogLabelStats);
LogLabelStats.displayName = 'LogLabelStats';

View File

@ -0,0 +1,82 @@
import { css, cx } from '@emotion/css';
import React, { FunctionComponent } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
const getStyles = (theme: GrafanaTheme2) => ({
logsStatsRow: css`
label: logs-stats-row;
margin: ${parseInt(theme.spacing(2), 10) / 1.75}px 0;
`,
logsStatsRowActive: css`
label: logs-stats-row--active;
color: ${theme.colors.primary.text};
position: relative;
`,
logsStatsRowLabel: css`
label: logs-stats-row__label;
display: flex;
margin-bottom: 1px;
`,
logsStatsRowValue: css`
label: logs-stats-row__value;
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
`,
logsStatsRowCount: css`
label: logs-stats-row__count;
text-align: right;
margin-left: 0.5em;
`,
logsStatsRowPercent: css`
label: logs-stats-row__percent;
text-align: right;
margin-left: 0.5em;
width: 3em;
`,
logsStatsRowBar: css`
label: logs-stats-row__bar;
height: 4px;
overflow: hidden;
background: ${theme.colors.text.disabled};
`,
logsStatsRowInnerBar: css`
label: logs-stats-row__innerbar;
height: 4px;
overflow: hidden;
background: ${theme.colors.primary.main};
`,
});
export interface Props {
active?: boolean;
count: number;
proportion: number;
value?: string;
}
export const LogLabelStatsRow: FunctionComponent<Props> = ({ active, count, proportion, value }) => {
const style = useStyles2(getStyles);
const percent = `${Math.round(proportion * 100)}%`;
const barStyle = { width: percent };
const className = active ? cx([style.logsStatsRow, style.logsStatsRowActive]) : cx([style.logsStatsRow]);
return (
<div className={className}>
<div className={cx([style.logsStatsRowLabel])}>
<div className={cx([style.logsStatsRowValue])} title={value}>
{value}
</div>
<div className={cx([style.logsStatsRowCount])}>{count}</div>
<div className={cx([style.logsStatsRowPercent])}>{percent}</div>
</div>
<div className={cx([style.logsStatsRowBar])}>
<div className={cx([style.logsStatsRowInnerBar])} style={barStyle} />
</div>
</div>
);
};
LogLabelStatsRow.displayName = 'LogLabelStatsRow';

View File

@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { LogLabels } from './LogLabels';
describe('<LogLabels />', () => {
it('renders notice when no labels are found', () => {
render(<LogLabels labels={{}} />);
expect(screen.queryByText('(no unique labels)')).toBeInTheDocument();
});
it('renders labels', () => {
render(<LogLabels labels={{ foo: 'bar', baz: '42' }} />);
expect(screen.queryByText('bar')).toBeInTheDocument();
expect(screen.queryByText('42')).toBeInTheDocument();
});
it('excludes labels with certain names or labels starting with underscore', () => {
render(<LogLabels labels={{ foo: 'bar', level: '42', _private: '13' }} />);
expect(screen.queryByText('bar')).toBeInTheDocument();
expect(screen.queryByText('42')).not.toBeInTheDocument();
expect(screen.queryByText('13')).not.toBeInTheDocument();
});
it('excludes labels with empty string values', () => {
render(<LogLabels labels={{ foo: 'bar', baz: '' }} />);
expect(screen.queryByText('bar')).toBeInTheDocument();
expect(screen.queryByText('baz')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,72 @@
import { css, cx } from '@emotion/css';
import React, { FunctionComponent } from 'react';
import { GrafanaTheme2, Labels } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
// Levels are already encoded in color, filename is a Loki-ism
const HIDDEN_LABELS = ['level', 'lvl', 'filename'];
interface Props {
labels: Labels;
}
export const LogLabels: FunctionComponent<Props> = ({ labels }) => {
const styles = useStyles2(getStyles);
const displayLabels = Object.keys(labels).filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label));
if (displayLabels.length === 0) {
return (
<span className={cx([styles.logsLabels])}>
<span className={cx([styles.logsLabel])}>(no unique labels)</span>
</span>
);
}
return (
<span className={cx([styles.logsLabels])}>
{displayLabels.sort().map((label) => {
const value = labels[label];
if (!value) {
return;
}
const tooltip = `${label}: ${value}`;
return (
<span key={label} className={cx([styles.logsLabel])}>
<span className={cx([styles.logsLabelValue])} title={tooltip}>
{value}
</span>
</span>
);
})}
</span>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
logsLabels: css`
display: flex;
flex-wrap: wrap;
font-size: ${theme.typography.size.xs};
`,
logsLabel: css`
label: logs-label;
display: flex;
padding: 0 2px;
background-color: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius(1)};
margin: 1px 4px 0 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`,
logsLabelValue: css`
label: logs-label__value;
display: inline-block;
max-width: 20em;
text-overflow: ellipsis;
overflow: hidden;
`,
};
};

View File

@ -0,0 +1,45 @@
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { createTheme } from '@grafana/data';
import { UnThemedLogMessageAnsi as LogMessageAnsi } from './LogMessageAnsi';
describe('<LogMessageAnsi />', () => {
it('renders string without ANSI codes', () => {
render(<LogMessageAnsi value="Lorem ipsum" theme={createTheme()} />);
expect(screen.queryByTestId('ansiLogLine')).not.toBeInTheDocument();
expect(screen.queryByText('Lorem ipsum')).toBeInTheDocument();
});
it('renders string with ANSI codes', () => {
const value = 'Lorem \u001B[31mipsum\u001B[0m et dolor';
render(<LogMessageAnsi value={value} theme={createTheme()} />);
expect(screen.queryByTestId('ansiLogLine')).toBeInTheDocument();
expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(1);
expect(screen.getAllByTestId('ansiLogLine').at(0)).toHaveAttribute('style', expect.stringMatching('color'));
const { getByText } = within(screen.getAllByTestId('ansiLogLine').at(0)!);
expect(getByText('ipsum')).toBeInTheDocument();
});
it('renders string with ANSI codes with correctly converted css classnames', () => {
const value = 'Lorem \u001B[1;32mIpsum';
render(<LogMessageAnsi value={value} theme={createTheme()} />);
expect(screen.queryByTestId('ansiLogLine')).toBeInTheDocument();
expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(1);
expect(screen.getAllByTestId('ansiLogLine').at(0)).toHaveAttribute('style', expect.stringMatching('font-weight'));
});
it('renders string with ANSI dim code with appropriate themed color', () => {
const value = 'Lorem \u001B[1;2mIpsum';
const theme = createTheme();
render(<LogMessageAnsi value={value} theme={createTheme()} />);
expect(screen.queryByTestId('ansiLogLine')).toBeInTheDocument();
expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(1);
expect(screen.getAllByTestId('ansiLogLine').at(0)).toHaveStyle({ color: theme.colors.text.secondary });
});
});

View File

@ -0,0 +1,103 @@
import ansicolor from 'ansicolor';
import React, { PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import { findHighlightChunksInText, GrafanaTheme2 } from '@grafana/data';
import { withTheme2, Themeable2 } from '@grafana/ui';
interface Style {
[key: string]: string;
}
interface ParsedChunk {
style: Style;
text: string;
}
function convertCSSToStyle(theme: GrafanaTheme2, css: string): Style {
return css.split(/;\s*/).reduce<Style>((accumulated, line) => {
// The ansicolor package returns this color if the chunk has the ANSI dim
// style (`\e[2m`), but it is nearly unreadable in the dark theme, so we use
// GrafanaTheme2 instead to style it in a way that works across all themes.
if (line === 'color:rgba(0,0,0,0.5)') {
return { color: theme.colors.text.secondary };
}
const match = line.match(/([^:\s]+)\s*:\s*(.+)/);
if (match && match[1] && match[2]) {
const key = match[1].replace(/-([a-z])/g, (_, character) => character.toUpperCase());
accumulated[key] = match[2];
}
return accumulated;
}, {});
}
interface Props extends Themeable2 {
value: string;
highlight?: {
searchWords: string[];
highlightClassName: string;
};
}
interface State {
chunks: ParsedChunk[];
prevValue: string;
}
export class UnThemedLogMessageAnsi extends PureComponent<Props, State> {
state: State = {
chunks: [],
prevValue: '',
};
static getDerivedStateFromProps(props: Props, state: State) {
if (props.value === state.prevValue) {
return null;
}
const parsed = ansicolor.parse(props.value);
return {
chunks: parsed.spans.map((span) => {
return span.css
? {
style: convertCSSToStyle(props.theme, span.css),
text: span.text,
}
: { text: span.text };
}),
prevValue: props.value,
};
}
render() {
const { chunks } = this.state;
return chunks.map((chunk, index) => {
const chunkText = this.props.highlight?.searchWords ? (
<Highlighter
key={index}
textToHighlight={chunk.text}
searchWords={this.props.highlight.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName={this.props.highlight.highlightClassName}
/>
) : (
chunk.text
);
return chunk.style ? (
<span key={index} style={chunk.style} data-testid="ansiLogLine">
{chunkText}
</span>
) : (
chunkText
);
});
}
}
export const LogMessageAnsi = withTheme2(UnThemedLogMessageAnsi);
LogMessageAnsi.displayName = 'LogMessageAnsi';

View File

@ -0,0 +1,256 @@
import { cx, css } from '@emotion/css';
import React, { PureComponent } from 'react';
import {
Field,
LinkModel,
LogRowModel,
LogsSortOrder,
TimeZone,
DataQueryResponse,
dateTimeFormat,
checkLogsError,
escapeUnescapedString,
GrafanaTheme2,
} from '@grafana/data';
import { styleMixins, withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui';
import { LogDetails } from './LogDetails';
import { LogLabels } from './LogLabels';
import {
LogRowContextRows,
LogRowContextQueryErrors,
HasMoreContextRows,
LogRowContextProvider,
RowContextOptions,
} from './LogRowContextProvider';
import { LogRowMessage } from './LogRowMessage';
import { LogRowMessageDetectedFields } from './LogRowMessageDetectedFields';
import { getLogRowStyles } from './getLogRowStyles';
//Components
interface Props extends Themeable2 {
row: LogRowModel;
showDuplicates: boolean;
showLabels: boolean;
showTime: boolean;
wrapLogMessage: boolean;
prettifyLogMessage: boolean;
timeZone: TimeZone;
enableLogDetails: boolean;
logsSortOrder?: LogsSortOrder | null;
forceEscape?: boolean;
showDetectedFields?: string[];
getRows: () => LogRowModel[];
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
onContextClick?: () => void;
getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise<DataQueryResponse>;
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
showContextToggle?: (row?: LogRowModel) => boolean;
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
onLogRowHover?: (row?: LogRowModel) => void;
}
interface State {
showContext: boolean;
showDetails: boolean;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
topVerticalAlign: css`
label: topVerticalAlign;
margin-top: -${theme.spacing(0.9)};
margin-left: -${theme.spacing(0.25)};
`,
detailsOpen: css`
&:hover {
background-color: ${styleMixins.hoverColor(theme.colors.background.primary, theme)};
}
`,
errorLogRow: css`
label: erroredLogRow;
color: ${theme.colors.text.secondary};
`,
};
};
/**
* Renders a log line.
*
* When user hovers over it for a certain time, it lazily parses the log line.
* Once a parser is found, it will determine fields, that will be highlighted.
* When the user requests stats for a field, they will be calculated and rendered below the row.
*/
class UnThemedLogRow extends PureComponent<Props, State> {
state: State = {
showContext: false,
showDetails: false,
};
toggleContext = () => {
this.setState((state) => {
return {
showContext: !state.showContext,
};
});
};
toggleDetails = () => {
if (!this.props.enableLogDetails) {
return;
}
this.setState((state) => {
return {
showDetails: !state.showDetails,
};
});
};
renderTimeStamp(epochMs: number) {
return dateTimeFormat(epochMs, {
timeZone: this.props.timeZone,
});
}
renderLogRow(
context?: LogRowContextRows,
errors?: LogRowContextQueryErrors,
hasMoreContextRows?: HasMoreContextRows,
updateLimit?: () => void
) {
const {
getRows,
onClickFilterLabel,
onClickFilterOutLabel,
onClickShowDetectedField,
onClickHideDetectedField,
enableLogDetails,
row,
showDuplicates,
showContextToggle,
showLabels,
showTime,
showDetectedFields,
wrapLogMessage,
prettifyLogMessage,
theme,
getFieldLinks,
forceEscape,
onLogRowHover,
} = this.props;
const { showDetails, showContext } = this.state;
const style = getLogRowStyles(theme, row.logLevel);
const styles = getStyles(theme);
const { errorMessage, hasError } = checkLogsError(row);
const logRowBackground = cx(style.logsRow, {
[styles.errorLogRow]: hasError,
});
const processedRow =
row.hasUnescapedContent && forceEscape
? { ...row, entry: escapeUnescapedString(row.entry), raw: escapeUnescapedString(row.raw) }
: row;
return (
<>
<tr
className={logRowBackground}
onClick={this.toggleDetails}
onMouseEnter={() => {
onLogRowHover && onLogRowHover(row);
}}
onMouseLeave={() => {
onLogRowHover && onLogRowHover(undefined);
}}
>
{showDuplicates && (
<td className={style.logsRowDuplicates}>
{processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null}
</td>
)}
<td className={cx({ [style.logsRowLevel]: !hasError })}>
{hasError && (
<Tooltip content={`Error: ${errorMessage}`} placement="right" theme="error">
<Icon className={style.logIconError} name="exclamation-triangle" size="xs" />
</Tooltip>
)}
</td>
{enableLogDetails && (
<td title={showDetails ? 'Hide log details' : 'See log details'} className={style.logsRowToggleDetails}>
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
</td>
)}
{showTime && <td className={style.logsRowLocalTime}>{this.renderTimeStamp(row.timeEpochMs)}</td>}
{showLabels && processedRow.uniqueLabels && (
<td className={style.logsRowLabels}>
<LogLabels labels={processedRow.uniqueLabels} />
</td>
)}
{showDetectedFields && showDetectedFields.length > 0 ? (
<LogRowMessageDetectedFields
row={processedRow}
showDetectedFields={showDetectedFields!}
getFieldLinks={getFieldLinks}
wrapLogMessage={wrapLogMessage}
/>
) : (
<LogRowMessage
row={processedRow}
getRows={getRows}
errors={errors}
hasMoreContextRows={hasMoreContextRows}
updateLimit={updateLimit}
context={context}
contextIsOpen={showContext}
showContextToggle={showContextToggle}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
onToggleContext={this.toggleContext}
/>
)}
</tr>
{this.state.showDetails && (
<LogDetails
className={logRowBackground}
showDuplicates={showDuplicates}
getFieldLinks={getFieldLinks}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getRows={getRows}
row={processedRow}
wrapLogMessage={wrapLogMessage}
hasError={hasError}
showDetectedFields={showDetectedFields}
/>
)}
</>
);
}
render() {
const { showContext } = this.state;
const { logsSortOrder, row, getRowContext } = this.props;
if (showContext) {
return (
<>
<LogRowContextProvider row={row} getRowContext={getRowContext} logsSortOrder={logsSortOrder}>
{({ result, errors, hasMoreContextRows, updateLimit }) => {
return <>{this.renderLogRow(result, errors, hasMoreContextRows, updateLimit)}</>;
}}
</LogRowContextProvider>
</>
);
}
return this.renderLogRow();
}
}
export const LogRow = withTheme2(UnThemedLogRow);
LogRow.displayName = 'LogRow';

View File

@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { LogRowModel } from '@grafana/data';
import { LogRowContextGroup } from './LogRowContext';
describe('LogRowContextGroup component', () => {
it('should correctly render logs with ANSI', () => {
const defaultProps = {
rows: ['Log 1 with \u001B[31mANSI\u001B[0m code', 'Log 2', 'Log 3 with \u001B[31mANSI\u001B[0m code'],
onLoadMoreContext: () => {},
canLoadMoreRows: false,
row: {} as LogRowModel,
className: '',
};
render(
<div>
<LogRowContextGroup {...defaultProps} />
</div>
);
expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(2);
});
});

View File

@ -0,0 +1,234 @@
import { css, cx } from '@emotion/css';
import React, { useRef, useState, useLayoutEffect, useEffect } from 'react';
import { GrafanaTheme2, DataQueryError, LogRowModel, textUtil } from '@grafana/data';
import { useStyles2, Alert, ClickOutsideWrapper, CustomScrollbar, List } from '@grafana/ui';
import { LogMessageAnsi } from './LogMessageAnsi';
import { LogRowContextRows, LogRowContextQueryErrors, HasMoreContextRows } from './LogRowContextProvider';
interface LogRowContextProps {
row: LogRowModel;
context: LogRowContextRows;
wrapLogMessage: boolean;
errors?: LogRowContextQueryErrors;
hasMoreContextRows?: HasMoreContextRows;
onOutsideClick: () => void;
onLoadMoreContext: () => void;
}
const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) => {
/**
* This is workaround for displaying uncropped context when we have unwrapping log messages.
* We are using margins to correctly position context. Because non-wrapped logs have always 1 line of log
* and 1 line of Show/Hide context switch. Therefore correct position can be reliably achieved by margins.
* We also adjust width to 75%.
*/
const afterContext = wrapLogMessage
? css`
top: -250px;
`
: css`
margin-top: -250px;
width: 75%;
`;
const beforeContext = wrapLogMessage
? css`
top: 100%;
`
: css`
margin-top: 40px;
width: 75%;
`;
return {
commonStyles: css`
position: absolute;
height: 250px;
z-index: ${theme.zIndex.dropdown};
overflow: hidden;
background: ${theme.colors.background.primary};
box-shadow: 0 0 10px ${theme.v1.palette.black};
border: 1px solid ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius(2)};
width: 100%;
`,
header: css`
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
background: ${theme.colors.background.secondary};
`,
logs: css`
height: 220px;
padding: 10px;
`,
afterContext,
beforeContext,
};
};
interface LogRowContextGroupHeaderProps {
row: LogRowModel;
rows: Array<string | DataQueryError>;
onLoadMoreContext: () => void;
shouldScrollToBottom?: boolean;
canLoadMoreRows?: boolean;
}
interface LogRowContextGroupProps extends LogRowContextGroupHeaderProps {
rows: Array<string | DataQueryError>;
className?: string;
error?: string;
}
const LogRowContextGroupHeader: React.FunctionComponent<LogRowContextGroupHeaderProps> = ({
row,
rows,
onLoadMoreContext,
canLoadMoreRows,
}) => {
const { header } = useStyles2(getLogRowContextStyles);
return (
<div className={header}>
<span
className={css`
opacity: 0.6;
`}
>
Found {rows.length} rows.
</span>
{(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && (
<span
className={css`
margin-left: 10px;
&:hover {
text-decoration: underline;
cursor: pointer;
}
`}
onClick={onLoadMoreContext}
>
Load 10 more
</span>
)}
</div>
);
};
export const LogRowContextGroup: React.FunctionComponent<LogRowContextGroupProps> = ({
row,
rows,
error,
className,
shouldScrollToBottom,
canLoadMoreRows,
onLoadMoreContext,
}) => {
const { commonStyles, logs } = useStyles2(getLogRowContextStyles);
const [scrollTop, setScrollTop] = useState(0);
const listContainerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
// We want to scroll to bottom only when we receive first 10 log lines
const shouldScrollRows = rows.length > 0 && rows.length <= 10;
if (shouldScrollToBottom && shouldScrollRows && listContainerRef.current) {
setScrollTop(listContainerRef.current.offsetHeight);
}
}, [shouldScrollToBottom, rows]);
const headerProps = {
row,
rows,
onLoadMoreContext,
canLoadMoreRows,
};
return (
<div className={cx(commonStyles, className)}>
{/* When displaying "after" context */}
{shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
<div className={logs}>
<CustomScrollbar autoHide scrollTop={scrollTop} autoHeightMin={'210px'}>
<div ref={listContainerRef}>
{!error && (
<List
items={rows}
renderItem={(item) => {
return (
<div
className={css`
padding: 5px 0;
`}
>
{typeof item === 'string' && textUtil.hasAnsiCodes(item) ? <LogMessageAnsi value={item} /> : item}
</div>
);
}}
/>
)}
{error && <Alert title={error} />}
</div>
</CustomScrollbar>
</div>
{/* When displaying "before" context */}
{!shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
</div>
);
};
export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
row,
context,
errors,
onOutsideClick,
onLoadMoreContext,
hasMoreContextRows,
wrapLogMessage,
}) => {
useEffect(() => {
const handleEscKeyDown = (e: KeyboardEvent): void => {
if (e.keyCode === 27) {
onOutsideClick();
}
};
document.addEventListener('keydown', handleEscKeyDown, false);
return () => {
document.removeEventListener('keydown', handleEscKeyDown, false);
};
}, [onOutsideClick]);
const { afterContext, beforeContext } = useStyles2((theme) => getLogRowContextStyles(theme, wrapLogMessage));
return (
<ClickOutsideWrapper onClick={onOutsideClick}>
{/* e.stopPropagation is necessary so the log details doesn't open when clicked on log line in context
* and/or when context log line is being highlighted */}
<div onClick={(e) => e.stopPropagation()}>
{context.after && (
<LogRowContextGroup
rows={context.after}
error={errors && errors.after}
row={row}
className={afterContext}
shouldScrollToBottom
canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false}
onLoadMoreContext={onLoadMoreContext}
/>
)}
{context.before && (
<LogRowContextGroup
onLoadMoreContext={onLoadMoreContext}
canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.before : false}
row={row}
rows={context.before}
error={errors && errors.before}
className={beforeContext}
/>
)}
</div>
</ClickOutsideWrapper>
);
};

View File

@ -0,0 +1,178 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FieldType, LogRowModel, MutableDataFrame, DataQueryResponse } from '@grafana/data';
import { getRowContexts, LogRowContextProvider, RowContextOptions } from './LogRowContextProvider';
import { createLogRow } from './__mocks__/logRow';
const row = createLogRow({ entry: '4', timeEpochMs: 4 });
describe('getRowContexts', () => {
describe('when called with a DataFrame and results are returned', () => {
it('then the result should be in correct format and filtered', async () => {
const firstResult = new MutableDataFrame({
refId: 'B',
fields: [
{ name: 'ts', type: FieldType.time, values: [3, 2, 1] },
{ name: 'line', type: FieldType.string, values: ['3', '2', '1'], labels: {} },
{ name: 'id', type: FieldType.string, values: ['3', '2', '1'], labels: {} },
],
});
const secondResult = new MutableDataFrame({
refId: 'B',
fields: [
{ name: 'ts', type: FieldType.time, values: [6, 5, 4] },
{ name: 'line', type: FieldType.string, values: ['6', '5', '4'], labels: {} },
{ name: 'id', type: FieldType.string, values: ['6', '5', '4'], labels: {} },
],
});
let called = false;
const getRowContextMock = (row: LogRowModel, options?: RowContextOptions): Promise<DataQueryResponse> => {
if (!called) {
called = true;
return Promise.resolve({ data: [firstResult] });
}
return Promise.resolve({ data: [secondResult] });
};
const result = await getRowContexts(getRowContextMock, row, 10);
expect(result).toEqual({
data: [
['3', '2'],
['6', '5', '4'],
],
errors: ['', ''],
});
});
it('then the result should be in correct format and filtered without uid', async () => {
const firstResult = new MutableDataFrame({
refId: 'B',
fields: [
{ name: 'ts', type: FieldType.time, values: [3, 2, 1] },
{ name: 'line', type: FieldType.string, values: ['3', '2', '1'], labels: {} },
],
});
const secondResult = new MutableDataFrame({
refId: 'B',
fields: [
{ name: 'ts', type: FieldType.time, values: [6, 5, 4] },
{ name: 'line', type: FieldType.string, values: ['6', '5', '4'], labels: {} },
],
});
let called = false;
const getRowContextMock = (row: LogRowModel, options?: RowContextOptions): Promise<DataQueryResponse> => {
if (!called) {
called = true;
return Promise.resolve({ data: [firstResult] });
}
return Promise.resolve({ data: [secondResult] });
};
const result = await getRowContexts(getRowContextMock, row, 10);
expect(result).toEqual({
data: [
['3', '2', '1'],
['6', '5'],
],
errors: ['', ''],
});
});
});
describe('when called with a DataFrame and errors occur', () => {
it('then the result should be in correct format', async () => {
const firstError = new Error('Error 1');
const secondError = new Error('Error 2');
let called = false;
const getRowContextMock = (row: LogRowModel, options?: RowContextOptions): Promise<DataQueryResponse> => {
if (!called) {
called = true;
return Promise.reject(firstError);
}
return Promise.reject(secondError);
};
const result = await getRowContexts(getRowContextMock, row, 10);
expect(result).toEqual({ data: [[], []], errors: ['Error 1', 'Error 2'] });
});
});
});
describe('LogRowContextProvider', () => {
describe('when requesting longer context', () => {
it('can request more log lines', async () => {
const firstResult = new MutableDataFrame({
refId: 'B',
fields: [
{ name: 'ts', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
{
name: 'line',
type: FieldType.string,
values: ['10', '9', '8', '7', '6', '5'],
labels: {},
},
{
name: 'id',
type: FieldType.string,
values: ['10', '9', '8', '7', '6', '5'],
labels: {},
},
],
});
const secondResult = new MutableDataFrame({
refId: 'B',
fields: [
{ name: 'ts', type: FieldType.time, values: [14, 13, 12] },
{ name: 'line', type: FieldType.string, values: ['14', '13', '12'], labels: {} },
{ name: 'id', type: FieldType.string, values: ['14', '13', '12'], labels: {} },
],
});
let called = false;
const getRowContextMock = (row: LogRowModel, options?: RowContextOptions): Promise<DataQueryResponse> => {
if (!called) {
called = true;
return Promise.resolve({ data: [firstResult] });
}
return Promise.resolve({ data: [secondResult] });
};
let updateLimitCalled = false;
const mockedChildren = jest.fn((mockState) => {
const { result, errors, hasMoreContextRows, updateLimit, limit } = mockState;
if (!updateLimitCalled && result.before.length === 0) {
expect(result).toEqual({ before: [], after: [] });
expect(errors).toEqual({ before: undefined, after: undefined });
expect(hasMoreContextRows).toEqual({ before: true, after: true });
expect(limit).toBe(10);
return <div data-testid="mockChild" />;
}
if (!updateLimitCalled && result.before.length > 0) {
expect(result).toEqual({ before: ['10', '9', '8', '7', '6', '5'], after: ['14', '13', '12'] });
expect(errors).toEqual({ before: '', after: '' });
expect(hasMoreContextRows).toEqual({ before: true, after: true });
expect(limit).toBe(10);
updateLimit();
updateLimitCalled = true;
return <div data-testid="mockChild" />;
}
if (updateLimitCalled && result.before.length > 0 && limit > 10) {
expect(limit).toBe(20);
}
return <div data-testid="mockChild" />;
});
render(
<LogRowContextProvider row={row} getRowContext={getRowContextMock}>
{mockedChildren}
</LogRowContextProvider>
);
await screen.findByTestId('mockChild');
});
});
});

View File

@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import useAsync from 'react-use/lib/useAsync';
import {
LogRowModel,
toDataFrame,
Field,
FieldCache,
LogsSortOrder,
DataQueryResponse,
DataQueryError,
} from '@grafana/data';
export interface RowContextOptions {
direction?: 'BACKWARD' | 'FORWARD';
limit?: number;
}
export interface LogRowContextRows {
before?: string[];
after?: string[];
}
export interface LogRowContextQueryErrors {
before?: string;
after?: string;
}
export interface HasMoreContextRows {
before: boolean;
after: boolean;
}
interface ResultType {
data: string[][];
errors: string[];
}
interface LogRowContextProviderProps {
row: LogRowModel;
logsSortOrder?: LogsSortOrder | null;
getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise<DataQueryResponse>;
children: (props: {
result: LogRowContextRows;
errors: LogRowContextQueryErrors;
hasMoreContextRows: HasMoreContextRows;
updateLimit: () => void;
limit: number;
}) => JSX.Element;
}
export const getRowContexts = async (
getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise<DataQueryResponse>,
row: LogRowModel,
limit: number,
logsSortOrder?: LogsSortOrder | null
) => {
const promises = [
getRowContext(row, {
limit,
}),
getRowContext(row, {
// The start time is inclusive so we will get the one row we are using as context entry
limit: limit + 1,
direction: 'FORWARD',
}),
];
const results: Array<DataQueryResponse | DataQueryError> = await Promise.all(promises.map((p) => p.catch((e) => e)));
const data = results.map((result) => {
const dataResult: DataQueryResponse = result as DataQueryResponse;
if (!dataResult.data) {
return [];
}
const data = [];
for (let index = 0; index < dataResult.data.length; index++) {
const dataFrame = toDataFrame(dataResult.data[index]);
const fieldCache = new FieldCache(dataFrame);
const timestampField: Field<string> = fieldCache.getFieldByName('ts')!;
const idField: Field<string> | undefined = fieldCache.getFieldByName('id');
for (let fieldIndex = 0; fieldIndex < timestampField.values.length; fieldIndex++) {
// TODO: this filtering is datasource dependant so it will make sense to move it there so the API is
// to return correct list of lines handling inclusive ranges or how to filter the correct line on the
// datasource.
// Filter out the row that is the one used as a focal point for the context as we will get it in one of the
// requests.
if (idField) {
// For Loki this means we filter only the one row. Issue is we could have other rows logged at the same
// ns which came before but they come in the response that search for logs after. This means right now
// we will show those as if they came after. This is not strictly correct but seems better than losing them
// and making this correct would mean quite a bit of complexity to shuffle things around and messing up
//counts.
if (idField.values.get(fieldIndex) === row.uid) {
continue;
}
} else {
// Fallback to timestamp. This should not happen right now as this feature is implemented only for loki
// and that has ID. Later this branch could be used in other DS but mind that this could also filter out
// logs which were logged in the same timestamp and that can be a problem depending on the precision.
if (parseInt(timestampField.values.get(fieldIndex), 10) === row.timeEpochMs) {
continue;
}
}
const lineField: Field<string> = dataFrame.fields.filter((field) => field.name === 'line')[0];
const line = lineField.values.get(fieldIndex); // assuming that both fields have same length
data.push(line);
}
}
return logsSortOrder === LogsSortOrder.Ascending ? data.reverse() : data;
});
const errors = results.map((result) => {
const errorResult: DataQueryError = result as DataQueryError;
if (!errorResult.message) {
return '';
}
return errorResult.message;
});
return {
data: logsSortOrder === LogsSortOrder.Ascending ? data.reverse() : data,
errors: logsSortOrder === LogsSortOrder.Ascending ? errors.reverse() : errors,
};
};
export const LogRowContextProvider: React.FunctionComponent<LogRowContextProviderProps> = ({
getRowContext,
row,
children,
logsSortOrder,
}) => {
// React Hook that creates a number state value called limit to component state and a setter function called setLimit
// The initial value for limit is 10
// Used for the number of rows to retrieve from backend from a specific point in time
const [limit, setLimit] = useState(10);
// React Hook that creates an object state value called result to component state and a setter function called setResult
// The initial value for result is null
// Used for sorting the response from backend
const [result, setResult] = useState<ResultType>(null as any as ResultType);
// React Hook that creates an object state value called hasMoreContextRows to component state and a setter function called setHasMoreContextRows
// The initial value for hasMoreContextRows is {before: true, after: true}
// Used for indicating in UI if there are more rows to load in a given direction
const [hasMoreContextRows, setHasMoreContextRows] = useState({
before: true,
after: true,
});
// React Hook that resolves two promises every time the limit prop changes
// First promise fetches limit number of rows backwards in time from a specific point in time
// Second promise fetches limit number of rows forwards in time from a specific point in time
const { value } = useAsync(async () => {
return await getRowContexts(getRowContext, row, limit, logsSortOrder); // Moved it to a separate function for debugging purposes
}, [limit]);
// React Hook that performs a side effect every time the value (from useAsync hook) prop changes
// The side effect changes the result state with the response from the useAsync hook
// The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result
useEffect(() => {
if (value) {
setResult((currentResult) => {
let hasMoreLogsBefore = true,
hasMoreLogsAfter = true;
const currentResultBefore = currentResult?.data[0];
const currentResultAfter = currentResult?.data[1];
const valueBefore = value.data[0];
const valueAfter = value.data[1];
// checks if there are more log rows in a given direction
// if after fetching additional rows the length of result is the same,
// we can assume there are no logs in that direction within a given time range
if (currentResult && (!valueBefore || currentResultBefore.length === valueBefore.length)) {
hasMoreLogsBefore = false;
}
if (currentResult && (!valueAfter || currentResultAfter.length === valueAfter.length)) {
hasMoreLogsAfter = false;
}
setHasMoreContextRows({
before: hasMoreLogsBefore,
after: hasMoreLogsAfter,
});
return value;
});
}
}, [value]);
return children({
result: {
before: result ? result.data[0] : [],
after: result ? result.data[1] : [],
},
errors: {
before: result ? result.errors[0] : undefined,
after: result ? result.errors[1] : undefined,
},
hasMoreContextRows,
updateLimit: () => setLimit(limit + 10),
limit,
});
};

View File

@ -0,0 +1,187 @@
import { css, cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import tinycolor from 'tinycolor2';
import { LogRowModel, findHighlightChunksInText, GrafanaTheme2 } from '@grafana/data';
import { withTheme2, Themeable2, IconButton, Tooltip } from '@grafana/ui';
import { LogMessageAnsi } from './LogMessageAnsi';
import { LogRowContext } from './LogRowContext';
import { LogRowContextQueryErrors, HasMoreContextRows, LogRowContextRows } from './LogRowContextProvider';
import { getLogRowStyles } from './getLogRowStyles';
//Components
export const MAX_CHARACTERS = 100000;
interface Props extends Themeable2 {
row: LogRowModel;
hasMoreContextRows?: HasMoreContextRows;
contextIsOpen: boolean;
wrapLogMessage: boolean;
prettifyLogMessage: boolean;
errors?: LogRowContextQueryErrors;
context?: LogRowContextRows;
showContextToggle?: (row?: LogRowModel) => boolean;
getRows: () => LogRowModel[];
onToggleContext: () => void;
updateLimit?: () => void;
}
const getStyles = (theme: GrafanaTheme2) => {
const outlineColor = tinycolor(theme.components.dashboard.background).setAlpha(0.7).toRgbString();
return {
positionRelative: css`
label: positionRelative;
position: relative;
`,
rowWithContext: css`
label: rowWithContext;
z-index: 1;
outline: 9999px solid ${outlineColor};
`,
horizontalScroll: css`
label: verticalScroll;
white-space: pre;
`,
contextNewline: css`
display: block;
margin-left: 0px;
`,
contextButton: css`
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-content: flex-end;
justify-content: space-evenly;
align-items: center;
position: absolute;
right: -8px;
top: 0;
bottom: auto;
width: 80px;
height: 36px;
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
padding: ${theme.spacing(0, 0, 0, 0.5)};
z-index: 100;
`,
};
};
function renderLogMessage(
hasAnsi: boolean,
entry: string,
highlights: string[] | undefined,
highlightClassName: string
) {
const needsHighlighter =
highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0 && entry.length < MAX_CHARACTERS;
const searchWords = highlights ?? [];
if (hasAnsi) {
const highlight = needsHighlighter ? { searchWords, highlightClassName } : undefined;
return <LogMessageAnsi value={entry} highlight={highlight} />;
} else if (needsHighlighter) {
return (
<Highlighter
textToHighlight={entry}
searchWords={searchWords}
findChunks={findHighlightChunksInText}
highlightClassName={highlightClassName}
/>
);
} else {
return entry;
}
}
const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): string => {
if (prettifyLogMessage) {
try {
return JSON.stringify(JSON.parse(line), undefined, 2);
} catch (error) {
return line;
}
}
return line;
});
class UnThemedLogRowMessage extends PureComponent<Props> {
onContextToggle = (e: React.SyntheticEvent<HTMLElement>) => {
e.stopPropagation();
this.props.onToggleContext();
};
render() {
const {
row,
theme,
errors,
hasMoreContextRows,
updateLimit,
context,
contextIsOpen,
showContextToggle,
wrapLogMessage,
prettifyLogMessage,
onToggleContext,
} = this.props;
const style = getLogRowStyles(theme, row.logLevel);
const { hasAnsi, raw } = row;
const restructuredEntry = restructureLog(raw, prettifyLogMessage);
const styles = getStyles(theme);
return (
// When context is open, the position has to be NOT relative.
// Setting the postion as inline-style to overwrite the more sepecific style definition from `style.logsRowMessage`.
<td style={contextIsOpen ? { position: 'unset' } : undefined} className={style.logsRowMessage}>
<div
className={cx({ [styles.positionRelative]: wrapLogMessage }, { [styles.horizontalScroll]: !wrapLogMessage })}
>
{contextIsOpen && context && (
<LogRowContext
row={row}
context={context}
errors={errors}
wrapLogMessage={wrapLogMessage}
hasMoreContextRows={hasMoreContextRows}
onOutsideClick={onToggleContext}
onLoadMoreContext={() => {
if (updateLimit) {
updateLimit();
}
}}
/>
)}
<span className={cx(styles.positionRelative, { [styles.rowWithContext]: contextIsOpen })}>
{renderLogMessage(hasAnsi, restructuredEntry, row.searchWords, style.logsRowMatchHighLight)}
</span>
{!contextIsOpen && showContextToggle?.(row) && (
<span
className={cx('log-row-context', style.context, styles.contextButton)}
onClick={(e) => e.stopPropagation()}
>
<Tooltip placement="top" content={'Show context'}>
<IconButton size="md" name="gf-show-context" onClick={this.onContextToggle} />
</Tooltip>
<Tooltip placement="top" content={'Copy'}>
<IconButton
size="md"
name="copy"
onClick={() => navigator.clipboard.writeText(JSON.stringify(restructuredEntry))}
/>
</Tooltip>
</span>
)}
</div>
</td>
);
}
}
export const LogRowMessage = withTheme2(UnThemedLogRowMessage);
LogRowMessage.displayName = 'LogRowMessage';

View File

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { LogRowModel, Field, LinkModel } from '@grafana/data';
import { withTheme2, Themeable2 } from '@grafana/ui';
import { getAllFields } from './logParser';
export interface Props extends Themeable2 {
row: LogRowModel;
showDetectedFields: string[];
wrapLogMessage: boolean;
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
}
class UnThemedLogRowMessageDetectedFields extends PureComponent<Props> {
render() {
const { row, showDetectedFields, getFieldLinks, wrapLogMessage } = this.props;
const fields = getAllFields(row, getFieldLinks);
const wrapClassName = wrapLogMessage
? ''
: css`
white-space: nowrap;
`;
const line = showDetectedFields
.map((parsedKey) => {
const field = fields.find((field) => {
const { key } = field;
return key === parsedKey;
});
if (field) {
return `${parsedKey}=${field.value}`;
}
return null;
})
.filter((s) => s !== null)
.join(' ');
return <td className={wrapClassName}>{line}</td>;
}
}
export const LogRowMessageDetectedFields = withTheme2(UnThemedLogRowMessageDetectedFields);
LogRowMessageDetectedFields.displayName = 'LogRowMessageDetectedFields';

View File

@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/react';
import { range } from 'lodash';
import React from 'react';
import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { LogRows, PREVIEW_LIMIT } from './LogRows';
import { createLogRow } from './__mocks__/logRow';
describe('LogRows', () => {
it('renders rows', () => {
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
/>
);
expect(screen.queryAllByRole('row')).toHaveLength(3);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1');
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 2');
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3');
});
it('renders rows only limited number of rows first', () => {
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
jest.useFakeTimers();
const { rerender } = render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
previewLimit={1}
enableLogDetails={true}
/>
);
// There is an extra row with the rows that are rendering
expect(screen.queryAllByRole('row')).toHaveLength(2);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1');
jest.runAllTimers();
rerender(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
previewLimit={1}
enableLogDetails={true}
/>
);
expect(screen.queryAllByRole('row')).toHaveLength(3);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1');
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 2');
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3');
jest.useRealTimers();
});
it('renders deduped rows if supplied', () => {
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
const dedupedRows: LogRowModel[] = [createLogRow({ uid: '4' }), createLogRow({ uid: '5' })];
render(
<LogRows
logRows={rows}
deduplicatedRows={dedupedRows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
/>
);
expect(screen.queryAllByRole('row')).toHaveLength(2);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 4');
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 5');
});
it('renders with default preview limit', () => {
// PREVIEW_LIMIT * 2 is there because otherwise we just render all rows
const rows: LogRowModel[] = range(PREVIEW_LIMIT * 2 + 1).map((num) => createLogRow({ uid: num.toString() }));
render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
/>
);
// There is an extra row with the rows that are rendering
expect(screen.queryAllByRole('row')).toHaveLength(101);
});
it('renders asc ordered rows if order and function supplied', () => {
const rows: LogRowModel[] = [
createLogRow({ uid: '1', timeEpochMs: 1 }),
createLogRow({ uid: '3', timeEpochMs: 3 }),
createLogRow({ uid: '2', timeEpochMs: 2 }),
];
render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Ascending}
enableLogDetails={true}
/>
);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1');
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 2');
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3');
});
it('renders desc ordered rows if order and function supplied', () => {
const rows: LogRowModel[] = [
createLogRow({ uid: '1', timeEpochMs: 1 }),
createLogRow({ uid: '3', timeEpochMs: 3 }),
createLogRow({ uid: '2', timeEpochMs: 2 }),
];
render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Descending}
enableLogDetails={true}
/>
);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 3');
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 2');
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 1');
});
});

View File

@ -0,0 +1,190 @@
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import { TimeZone, LogsDedupStrategy, LogRowModel, Field, LinkModel, LogsSortOrder, sortLogRows } from '@grafana/data';
import { withTheme2, Themeable2 } from '@grafana/ui';
//Components
import { LogRow } from './LogRow';
import { RowContextOptions } from './LogRowContextProvider';
import { getLogRowStyles } from './getLogRowStyles';
export const PREVIEW_LIMIT = 100;
export interface Props extends Themeable2 {
logRows?: LogRowModel[];
deduplicatedRows?: LogRowModel[];
dedupStrategy: LogsDedupStrategy;
showLabels: boolean;
showTime: boolean;
wrapLogMessage: boolean;
prettifyLogMessage: boolean;
timeZone: TimeZone;
enableLogDetails: boolean;
logsSortOrder?: LogsSortOrder | null;
previewLimit?: number;
forceEscape?: boolean;
showDetectedFields?: string[];
showContextToggle?: (row?: LogRowModel) => boolean;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise<any>;
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
onClickShowDetectedField?: (key: string) => void;
onClickHideDetectedField?: (key: string) => void;
onLogRowHover?: (row?: LogRowModel) => void;
}
interface State {
renderAll: boolean;
}
class UnThemedLogRows extends PureComponent<Props, State> {
renderAllTimer: number | null = null;
static defaultProps = {
previewLimit: PREVIEW_LIMIT,
};
state: State = {
renderAll: false,
};
componentDidMount() {
// Staged rendering
const { logRows, previewLimit } = this.props;
const rowCount = logRows ? logRows.length : 0;
// Render all right away if not too far over the limit
const renderAll = rowCount <= previewLimit! * 2;
if (renderAll) {
this.setState({ renderAll });
} else {
this.renderAllTimer = window.setTimeout(() => this.setState({ renderAll: true }), 2000);
}
}
componentWillUnmount() {
if (this.renderAllTimer) {
clearTimeout(this.renderAllTimer);
}
}
makeGetRows = memoizeOne((orderedRows: LogRowModel[]) => {
return () => orderedRows;
});
sortLogs = memoizeOne((logRows: LogRowModel[], logsSortOrder: LogsSortOrder): LogRowModel[] =>
sortLogRows(logRows, logsSortOrder)
);
render() {
const {
dedupStrategy,
showContextToggle,
showLabels,
showTime,
wrapLogMessage,
prettifyLogMessage,
logRows,
deduplicatedRows,
timeZone,
onClickFilterLabel,
onClickFilterOutLabel,
theme,
enableLogDetails,
previewLimit,
getFieldLinks,
logsSortOrder,
showDetectedFields,
onClickShowDetectedField,
onClickHideDetectedField,
forceEscape,
onLogRowHover,
} = this.props;
const { renderAll } = this.state;
const { logsRowsTable } = getLogRowStyles(theme);
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
const hasData = logRows && logRows.length > 0;
const dedupCount = dedupedRows
? dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0)
: 0;
const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0;
// Staged rendering
const processedRows = dedupedRows ? dedupedRows : [];
const orderedRows = logsSortOrder ? this.sortLogs(processedRows, logsSortOrder) : processedRows;
const firstRows = orderedRows.slice(0, previewLimit!);
const lastRows = orderedRows.slice(previewLimit!, orderedRows.length);
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = this.makeGetRows(orderedRows);
const getRowContext = this.props.getRowContext ? this.props.getRowContext : () => Promise.resolve([]);
return (
<table className={logsRowsTable}>
<tbody>
{hasData &&
firstRows.map((row, index) => (
<LogRow
key={row.uid}
getRows={getRows}
getRowContext={getRowContext}
row={row}
showContextToggle={showContextToggle}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
enableLogDetails={enableLogDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
onLogRowHover={onLogRowHover}
/>
))}
{hasData &&
renderAll &&
lastRows.map((row, index) => (
<LogRow
key={row.uid}
getRows={getRows}
getRowContext={getRowContext}
row={row}
showContextToggle={showContextToggle}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
enableLogDetails={enableLogDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
onLogRowHover={onLogRowHover}
/>
))}
{hasData && !renderAll && (
<tr>
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
</tr>
)}
</tbody>
</table>
);
}
}
export const LogRows = withTheme2(UnThemedLogRows);
LogRows.displayName = 'LogsRows';

View File

@ -0,0 +1,27 @@
import { LogLevel, LogRowModel, MutableDataFrame } from '@grafana/data';
export const createLogRow = (overrides?: Partial<LogRowModel>): LogRowModel => {
const uid = overrides?.uid || '1';
const timeEpochMs = overrides?.timeEpochMs || 1;
const entry = overrides?.entry || `log message ${uid}`;
return {
entryFieldIndex: 0,
rowIndex: 0,
dataFrame: new MutableDataFrame(),
uid,
logLevel: LogLevel.info,
entry,
hasAnsi: false,
hasUnescapedContent: false,
labels: {},
raw: entry,
timeFromNow: '',
timeEpochMs,
timeEpochNs: (timeEpochMs * 1000000).toString(),
timeLocal: '',
timeUtc: '',
searchWords: [],
...overrides,
};
};

View File

@ -0,0 +1,184 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LogLevel } from '@grafana/data';
import { styleMixins } from '@grafana/ui';
export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => {
let logColor = theme.isLight ? theme.v1.palette.gray5 : theme.v1.palette.gray2;
const hoverBgColor = styleMixins.hoverColor(theme.colors.background.secondary, theme);
switch (logLevel) {
case LogLevel.crit:
case LogLevel.critical:
logColor = '#705da0';
break;
case LogLevel.error:
case LogLevel.err:
logColor = '#e24d42';
break;
case LogLevel.warning:
case LogLevel.warn:
logColor = theme.colors.warning.main;
break;
case LogLevel.info:
logColor = '#7eb26d';
break;
case LogLevel.debug:
logColor = '#1f78c1';
break;
case LogLevel.trace:
logColor = '#6ed0e0';
break;
}
return {
logsRowMatchHighLight: css`
label: logs-row__match-highlight;
background: inherit;
padding: inherit;
color: ${theme.components.textHighlight.text}
background-color: ${theme.components.textHighlight};
`,
logsRowsTable: css`
label: logs-rows;
font-family: ${theme.typography.fontFamilyMonospace};
font-size: ${theme.typography.bodySmall.fontSize};
width: 100%;
`,
context: css`
label: context;
visibility: hidden;
white-space: nowrap;
position: relative;
margin-left: 10px;
`,
logsRow: css`
label: logs-row;
width: 100%;
cursor: pointer;
vertical-align: top;
&:hover {
.log-row-context {
visibility: visible;
z-index: 1;
text-decoration: underline;
&:hover {
color: ${theme.colors.warning.main};
}
}
}
td:last-child {
width: 100%;
}
> td {
position: relative;
padding-right: ${theme.spacing(1)};
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
height: 100%;
}
&:hover {
background: ${hoverBgColor};
}
`,
logsRowDuplicates: css`
label: logs-row__duplicates;
text-align: right;
width: 4em;
cursor: default;
`,
logsRowLevel: css`
label: logs-row__level;
max-width: 10px;
cursor: default;
&::after {
content: '';
display: block;
position: absolute;
top: 1px;
bottom: 1px;
width: 3px;
left: 4px;
background-color: ${logColor};
}
`,
logIconError: css`
color: ${theme.colors.warning.main};
`,
logsRowToggleDetails: css`
label: logs-row-toggle-details__level;
font-size: 9px;
padding-top: 5px;
max-width: 15px;
`,
logsRowLocalTime: css`
label: logs-row__localtime;
white-space: nowrap;
`,
logsRowLabels: css`
label: logs-row__labels;
white-space: nowrap;
max-width: 22em;
/* This is to make the labels vertical align */
> span {
margin-top: 0.75px;
}
`,
logsRowMessage: css`
label: logs-row__message;
white-space: pre-wrap;
word-break: break-all;
`,
//Log details specific CSS
logDetailsContainer: css`
label: logs-row-details-table;
border: 1px solid ${theme.colors.border.medium};
padding: 0 ${theme.spacing(1)} ${theme.spacing(1)};
border-radius: 3px;
margin: 20px 8px 20px 16px;
cursor: default;
`,
logDetailsTable: css`
label: logs-row-details-table;
line-height: 18px;
width: 100%;
td:last-child {
width: 100%;
}
`,
logsDetailsIcon: css`
label: logs-row-details__icon;
position: relative;
color: ${theme.v1.palette.gray3};
padding-top: 6px;
padding-left: 6px;
`,
logDetailsLabel: css`
label: logs-row-details__label;
max-width: 30em;
min-width: 20em;
padding: 0 ${theme.spacing(1)};
overflow-wrap: break-word;
`,
logDetailsHeading: css`
label: logs-row-details__heading;
font-weight: ${theme.typography.fontWeightBold};
padding: ${theme.spacing(1)} 0 ${theme.spacing(0.5)};
`,
logDetailsValue: css`
label: logs-row-details__row;
position: relative;
vertical-align: middle;
cursor: default;
&:hover {
background-color: ${hoverBgColor};
}
`,
};
};

View File

@ -0,0 +1,168 @@
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
import { createLogRow } from './__mocks__/logRow';
import { getAllFields } from './logParser';
describe('getAllFields', () => {
it('should filter out field with labels name and other type', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.other,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === 'labels')).toBe(undefined);
});
it('should not filter out field with labels name and string type', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.string,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(2);
expect(fields.find((field) => field.key === 'labels')).not.toBe(undefined);
});
it('should filter out field with id name', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'id',
type: FieldType.string,
config: {},
values: new ArrayVector(['1659620138401000000_8b1f7688_']),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === 'id')).toBe(undefined);
});
it('should filter out entry field which is shown as the log message', () => {
const logRow = createLogRow({
entryFieldIndex: 3,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.other,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
{
name: 'Time',
type: FieldType.time,
config: {},
values: new ArrayVector([1659620138401]),
},
{
name: 'Line',
type: FieldType.string,
config: {},
values: new ArrayVector([
'_entry="log text with ANSI \u001b[31mpart of the text\u001b[0m [616951240]" counter=300 float=NaN label=val3 level=info',
]),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.find((field) => field.key === 'Line')).toBe(undefined);
});
it('should filter out field with config hidden field', () => {
const testField = { ...testStringField };
testField.config = {
custom: {
hidden: true,
},
};
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testField }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.key === testField.name)).toBe(undefined);
});
it('should filter out field with null values', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testFieldWithNullValue }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.key === testFieldWithNullValue.name)).toBe(undefined);
});
it('should not filter out field with string values', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testStringField }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === testStringField.name)).not.toBe(undefined);
});
});
const testStringField = {
name: 'test_field_string',
type: FieldType.string,
config: {},
values: new ArrayVector(['abc']),
};
const testFieldWithNullValue = {
name: 'test_field_null',
type: FieldType.string,
config: {},
values: new ArrayVector([null]),
};

View File

@ -0,0 +1,122 @@
import memoizeOne from 'memoize-one';
import { Field, FieldType, getParser, LinkModel, LogRowModel } from '@grafana/data';
import { MAX_CHARACTERS } from './LogRowMessage';
const memoizedGetParser = memoizeOne(getParser);
type FieldDef = {
key: string;
value: string;
links?: Array<LinkModel<Field>>;
fieldIndex?: number;
};
/**
* Returns all fields for log row which consists of fields we parse from the message itself and additional fields
* found in the dataframe (they may contain links).
*/
export const getAllFields = memoizeOne(
(row: LogRowModel, getFieldLinks?: (field: Field, rowIndex: number) => 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;
}
);
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
const getDataframeFields = memoizeOne(
(row: LogRowModel, getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>): FieldDef[] => {
return row.dataFrame.fields
.map((field, index) => ({ ...field, index }))
.filter((field, index) => !shouldRemoveField(field, index, row))
.map((field) => {
const links = getFieldLinks ? getFieldLinks(field, row.rowIndex) : [];
return {
key: field.name,
value: field.values.get(row.rowIndex).toString(),
links: links,
fieldIndex: field.index,
};
});
}
);
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') {
return true;
}
// entry field which we are showing as the log message
if (row.entryFieldIndex === index) {
return true;
}
// hidden field
if (field.config.custom?.hidden) {
return true;
}
// field that has empty value (we want to keep 0 or empty string)
if (field.values.get(row.rowIndex) == null) {
return true;
}
return false;
}

View File

@ -12,10 +12,11 @@ import {
ScopedVars,
} from '@grafana/data';
import { DataSourceWithBackend } from '@grafana/runtime';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { RowContextOptions } from '../../../features/logs/components/LogRowContextProvider';
import { CloudWatchAnnotationSupport } from './annotationSupport';
import { SQLCompletionItemProvider } from './cloudwatch-sql/completion/CompletionItemProvider';
import { isCloudWatchAnnotationQuery, isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from './guards';

View File

@ -27,11 +27,12 @@ import {
QueryFixAction,
} from '@grafana/data';
import { BackendSrvRequest, getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import { queryLogsVolume } from 'app/core/logsModel';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { RowContextOptions } from '../../../features/logs/components/LogRowContextProvider';
import { ElasticsearchAnnotationsQueryEditor } from './components/QueryEditor/AnnotationQueryEditor';
import {
BucketAggregation,

View File

@ -35,13 +35,13 @@ import {
QueryFixAction,
} from '@grafana/data';
import { FetchError, config, DataSourceWithBackend } from '@grafana/runtime';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import { queryLogsVolume } from 'app/core/logsModel';
import { convertToWebSocketUrl } from 'app/core/utils/explore';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { serializeParams } from '../../../core/utils/fetch';
import { RowContextOptions } from '../../../features/logs/components/LogRowContextProvider';
import { renderLegendFormat } from '../prometheus/legend';
import { replaceVariables, returnVariables } from '../prometheus/querybuilder/shared/parsingUtils';

View File

@ -11,11 +11,14 @@ import {
DataHoverClearEvent,
DataHoverEvent,
} from '@grafana/data';
import { LogRows, CustomScrollbar, LogLabels, useStyles2, usePanelContext } from '@grafana/ui';
import { CustomScrollbar, useStyles2, usePanelContext } from '@grafana/ui';
import { dataFrameToLogsModel, dedupLogRows, COMMON_LABELS } from 'app/core/logsModel';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
import { LogLabels } from '../../../features/logs/components/LogLabels';
import { LogRows } from '../../../features/logs/components/LogRows';
import { Options } from './types';
interface LogsPanelProps extends PanelProps<Options> {}

View File

@ -22281,6 +22281,7 @@ __metadata:
angular-bindonce: 0.3.1
angular-route: 1.8.3
angular-sanitize: 1.8.3
ansicolor: 1.1.100
app: "link:./public/app"
autoprefixer: 10.4.7
axios: 0.27.2