mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GrafanaUI: Remove obsolete logs exports (#66268)
* grafana-ui: removed obsolete logs exports * updated betterer stats * updated CODEOWNERS
This commit is contained in:
parent
f48d31171e
commit
18cb2ac9dd
@ -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
1
.github/CODEOWNERS
vendored
@ -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
|
||||
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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}>
|
||||
|
||||
<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';
|
@ -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';
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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 });
|
||||
});
|
||||
});
|
@ -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';
|
@ -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';
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
});
|
||||
};
|
@ -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';
|
@ -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';
|
@ -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';
|
@ -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};
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user