GrafanaUI: Remove obsolete logs exports (#66268)

* grafana-ui: removed obsolete logs exports

* updated betterer stats

* updated CODEOWNERS
This commit is contained in:
Gábor Farkas 2023-04-12 15:41:33 +02:00 committed by GitHub
parent f48d31171e
commit 18cb2ac9dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 0 additions and 2464 deletions

View File

@ -1096,16 +1096,6 @@ exports[`better eslint`] = {
"packages/grafana-ui/src/components/Layout/Layout.story.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"packages/grafana-ui/src/components/Logs/LogRows.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-ui/src/components/Logs/logParser.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

1
.github/CODEOWNERS vendored
View File

@ -304,7 +304,6 @@
/packages/grafana-ui/src/components/ @grafana/grafana-frontend-platform
/packages/grafana-ui/src/components/DateTimePickers/ @grafana/grafana-frontend-platform
/packages/grafana-ui/src/components/GraphNG/ @grafana/dataviz-squad
/packages/grafana-ui/src/components/Logs/ @grafana/observability-logs
/packages/grafana-ui/src/components/Table/ @grafana/grafana-bi-squad
/packages/grafana-ui/src/components/TimeSeries/ @grafana/dataviz-squad
/packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad

View File

@ -1,180 +0,0 @@
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 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
import { Icon } from '../Icon/Icon';
import { Tooltip } from '../Tooltip/Tooltip';
import { LogDetailsRow } from './LogDetailsRow';
import { getLogRowStyles } from './getLogRowStyles';
import { getAllFields } from './logParser';
/** @deprecated will be removed in the next major version */
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>
);
}
}
/** @deprecated will be removed in the next major version */
export const LogDetails = withTheme2(UnThemedLogDetails);
LogDetails.displayName = 'LogDetails';

View File

@ -1,121 +0,0 @@
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

@ -1,224 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { PureComponent } from 'react';
import { Field, LinkModel, LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
import { withTheme2 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
import { ClipboardButton } from '../ClipboardButton/ClipboardButton';
import { DataLinkButton } from '../DataLinks/DataLinkButton';
import { IconButton } from '../IconButton/IconButton';
import { LogLabelStats } from './LogLabelStats';
import { getLogRowStyles } from './getLogRowStyles';
/** @deprecated will be removed in the next major version */
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: ${theme.shape.radius.circle};
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>
);
}
}
/** @deprecated will be removed in the next major version */
export const LogDetailsRow = withTheme2(UnThemedLogDetailsRow);
LogDetailsRow.displayName = 'LogDetailsRow';

View File

@ -1,101 +0,0 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
import { stylesFactory } from '../../themes';
import { withTheme2 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
//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>
);
}
}
/** @deprecated will be removed in the next major version */
export const LogLabelStats = withTheme2(UnThemedLogLabelStats);
LogLabelStats.displayName = 'LogLabelStats';

View File

@ -1,85 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
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};
`,
});
/** @deprecated will be removed in the next major version */
export interface Props {
active?: boolean;
count: number;
proportion: number;
value?: string;
}
/** @deprecated will be removed in the next major version */
export const LogLabelStatsRow = ({ active, count, proportion, value }: Props) => {
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

@ -1,27 +0,0 @@
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

@ -1,74 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, Labels } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
// Levels are already encoded in color, filename is a Loki-ism
const HIDDEN_LABELS = ['level', 'lvl', 'filename'];
interface Props {
labels: Labels;
}
/** @deprecated will be removed in the next major version */
export const LogLabels = ({ labels }: Props) => {
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

@ -1,45 +0,0 @@
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={theme} />);
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

@ -1,107 +0,0 @@
import ansicolor from 'ansicolor';
import React, { PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import { findHighlightChunksInText, GrafanaTheme2 } from '@grafana/data';
import { withTheme2 } from '../../themes';
import { Themeable2 } from '../../types';
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;
}
/** @deprecated will be removed in the next major version */
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
);
});
}
}
/** @deprecated will be removed in the next major version */
export const LogMessageAnsi = withTheme2(UnThemedLogMessageAnsi);
LogMessageAnsi.displayName = 'LogMessageAnsi';

View File

@ -1,261 +0,0 @@
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 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
import { Icon } from '../Icon/Icon';
import { Tooltip } from '../Tooltip/Tooltip';
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();
}
}
/** @deprecated will be removed in the next major version */
export const LogRow = withTheme2(UnThemedLogRow);
LogRow.displayName = 'LogRow';

View File

@ -1,25 +0,0 @@
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

@ -1,237 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { useRef, useState, useLayoutEffect, useEffect } from 'react';
import { GrafanaTheme2, DataQueryError, LogRowModel, textUtil } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { Alert } from '../Alert/Alert';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { List } from '../List/List';
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()};
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 = ({ row, rows, onLoadMoreContext, canLoadMoreRows }: LogRowContextGroupHeaderProps) => {
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>
);
};
/** @deprecated will be removed in the next major version */
export const LogRowContextGroup = ({
row,
rows,
error,
className,
shouldScrollToBottom,
canLoadMoreRows,
onLoadMoreContext,
}: LogRowContextGroupProps) => {
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) => {
const message = typeof item === 'string' ? item : item.message ?? '';
return (
<div
className={css`
padding: 5px 0;
`}
>
{textUtil.hasAnsiCodes(message) ? <LogMessageAnsi value={message} /> : message}
</div>
);
}}
/>
)}
{error && <Alert title={error} />}
</div>
</CustomScrollbar>
</div>
{/* When displaying "before" context */}
{!shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
</div>
);
};
/** @deprecated will be removed in the next major version */
export const LogRowContext = ({
row,
context,
errors,
onOutsideClick,
onLoadMoreContext,
hasMoreContextRows,
wrapLogMessage,
}: LogRowContextProps) => {
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

@ -1,216 +0,0 @@
import { useState, useEffect } from 'react';
import useAsync from 'react-use/lib/useAsync';
import {
LogRowModel,
toDataFrame,
Field,
FieldCache,
LogsSortOrder,
DataQueryResponse,
DataQueryError,
} from '@grafana/data';
/** @deprecated will be removed in the next major version */
export interface RowContextOptions {
direction?: 'BACKWARD' | 'FORWARD';
limit?: number;
}
/** @deprecated will be removed in the next major version */
export interface LogRowContextRows {
before?: string[];
after?: string[];
}
/** @deprecated will be removed in the next major version */
export interface LogRowContextQueryErrors {
before?: string;
after?: string;
}
/** @deprecated will be removed in the next major version */
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;
}
/** @deprecated will be removed in the next major version */
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,
};
};
/** @deprecated will be removed in the next major version */
export const LogRowContextProvider = ({ getRowContext, row, children, logsSortOrder }: LogRowContextProviderProps) => {
// 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>(null);
// 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;
if (currentResult) {
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 (!valueBefore || currentResultBefore.length === valueBefore.length) {
hasMoreLogsBefore = false;
}
if (!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

@ -1,191 +0,0 @@
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 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
import { IconButton } from '../IconButton/IconButton';
import { Tooltip } from '../Tooltip/Tooltip';
import { LogMessageAnsi } from './LogMessageAnsi';
import { LogRowContext } from './LogRowContext';
import { LogRowContextQueryErrors, HasMoreContextRows, LogRowContextRows } from './LogRowContextProvider';
import { getLogRowStyles } from './getLogRowStyles';
/** @deprecated will be removed in the next major version */
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>
);
}
}
/** @deprecated will be removed in the next major version */
export const LogRowMessage = withTheme2(UnThemedLogRowMessage);
LogRowMessage.displayName = 'LogRowMessage';

View File

@ -1,51 +0,0 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { LogRowModel, Field, LinkModel } from '@grafana/data';
import { withTheme2 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
import { getAllFields } from './logParser';
/** @deprecated will be removed in the next major version */
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>;
}
}
/** @deprecated will be removed in the next major version */
export const LogRowMessageDetectedFields = withTheme2(UnThemedLogRowMessageDetectedFields);
LogRowMessageDetectedFields.displayName = 'LogRowMessageDetectedFields';

View File

@ -1,194 +0,0 @@
import memoizeOne from 'memoize-one';
import React, { PureComponent } from 'react';
import { TimeZone, LogsDedupStrategy, LogRowModel, Field, LinkModel, LogsSortOrder, sortLogRows } from '@grafana/data';
import { withTheme2 } from '../../themes/index';
import { Themeable2 } from '../../types/theme';
import { LogRow } from './LogRow';
import { RowContextOptions } from './LogRowContextProvider';
import { getLogRowStyles } from './getLogRowStyles';
/** @deprecated will be removed in the next major version */
export const PREVIEW_LIMIT = 100;
/** @deprecated will be removed in the next major version */
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>
);
}
}
/** @deprecated will be removed in the next major version */
export const LogRows = withTheme2(UnThemedLogRows);
LogRows.displayName = 'LogsRows';

View File

@ -1,186 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LogLevel } from '@grafana/data';
import { styleMixins } from '../../themes';
/** @deprecated will be removed in the next major version */
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: ${theme.shape.radius.default};
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

@ -1,124 +0,0 @@
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).
*
* @deprecated will be removed in the next major version
*/
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

@ -147,10 +147,6 @@ export { Alert, type AlertVariant } from './Alert/Alert';
export { GraphSeriesToggler, type GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
export { Collapse, ControlledCollapse } from './Collapse/Collapse';
export { CollapsableSection } from './Collapse/CollapsableSection';
export { LogLabels } from './Logs/LogLabels';
export { LogMessageAnsi } from './Logs/LogMessageAnsi';
export { LogRows } from './Logs/LogRows';
export { getLogRowStyles } from './Logs/getLogRowStyles';
export { DataLinkButton } from './DataLinks/DataLinkButton';
export { FieldLinkList } from './DataLinks/FieldLinkList';
// Panel editors