Explore: UI change for log row details (#20034)

Add LogDetail section that is shown when log line is clicked and expanded. Contains labels/fields and actions to show stats and add/remove label filter.
This commit is contained in:
Ivana Huckova 2019-11-01 10:01:00 +01:00 committed by Andrej Ocenas
parent 750e8d27bf
commit a5e8e0e291
19 changed files with 661 additions and 360 deletions

View File

@ -89,7 +89,15 @@ describe('LogsParsers', () => {
}); });
test('should return parsed fields', () => { test('should return parsed fields', () => {
expect(parser.getFields('foo=bar baz="42 + 1"')).toEqual(['foo=bar', 'baz="42 + 1"']); expect(
parser.getFields(
'foo=bar baz="42 + 1" msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"'
)
).toEqual([
'foo=bar',
'baz="42 + 1"',
'msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"',
]);
}); });
test('should return label for field', () => { test('should return label for field', () => {
@ -98,6 +106,11 @@ describe('LogsParsers', () => {
test('should return value for field', () => { test('should return value for field', () => {
expect(parser.getValueFromField('foo=bar')).toBe('bar'); expect(parser.getValueFromField('foo=bar')).toBe('bar');
expect(
parser.getValueFromField(
'msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"'
)
).toBe('"[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"');
}); });
test('should build a valid value matcher', () => { test('should build a valid value matcher', () => {
@ -117,7 +130,7 @@ describe('LogsParsers', () => {
}); });
test('should return parsed fields', () => { test('should return parsed fields', () => {
expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo" : "bar"', '"baz" : 42']); expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo":"bar"', '"baz":42']);
}); });
test('should return parsed fields for nested quotes', () => { test('should return parsed fields for nested quotes', () => {
@ -126,6 +139,7 @@ describe('LogsParsers', () => {
test('should return label for field', () => { test('should return label for field', () => {
expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo'); expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo');
expect(parser.getLabelFromField('"docker.memory.fail.count":0')).toBe('docker.memory.fail.count');
}); });
test('should return value for field', () => { test('should return value for field', () => {

View File

@ -1,10 +1,14 @@
import { countBy, chain, map, escapeRegExp } from 'lodash'; import { countBy, chain } from 'lodash';
import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs'; import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs';
import { DataFrame, FieldType } from '../types/index'; import { DataFrame, FieldType } from '../types/index';
import { ArrayVector } from '../vector/ArrayVector'; import { ArrayVector } from '../vector/ArrayVector';
const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/; // This matches:
// first a label from start of the string or first white space, then any word chars until "="
// second either an empty quotes, or anything that starts with quote and ends with unescaped quote,
// or any non whitespace chars that do not start with qoute
const LOGFMT_REGEXP = /(?:^|\s)(\w+)=(""|(?:".*?[^\\]"|[^"\s]\S*))/;
/** /**
* Returns the log level of a log line. * Returns the log level of a log line.
@ -79,21 +83,15 @@ export const LogsParsers: { [name: string]: LogsParser } = {
JSON: { JSON: {
buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`),
getFields: line => { getFields: line => {
const fields: string[] = [];
try { try {
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
map(parsed, (value, key) => { return Object.keys(parsed).map(key => {
const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${escapeRegExp(JSON.stringify(value))}"?`); return `"${key}":${JSON.stringify(parsed[key])}`;
const match = line.match(fieldMatcher);
if (match) {
fields.push(match[0]);
}
}); });
} catch {} } catch {}
return fields; return [];
}, },
getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1], getLabelFromField: field => (field.match(/^"([^"]+)"\s*:/) || [])[1],
getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1], getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1],
test: line => { test: line => {
try { try {

View File

@ -0,0 +1,88 @@
import React from 'react';
import { LogDetails, Props } from './LogDetails';
import { LogRowModel, LogLevel, GrafanaTheme } from '@grafana/data';
import { mount } from 'enzyme';
const setup = (propOverrides?: object) => {
const props: Props = {
theme: {} as GrafanaTheme,
row: {
logLevel: 'error' as LogLevel,
timeFromNow: '',
timeEpochMs: 1546297200000,
timeLocal: '',
timeUtc: '',
hasAnsi: false,
entry: '',
raw: '',
timestamp: '',
uid: '0',
} as LogRowModel,
getRows: () => [],
onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {},
};
Object.assign(props, propOverrides);
const wrapper = mount(<LogDetails {...props} />);
return wrapper;
};
describe('LogDetails', () => {
describe('when labels are present', () => {
it('should render heading', () => {
const wrapper = setup({ row: { labels: { key1: 'label1', key2: 'label2' } } });
expect(wrapper.find({ 'aria-label': 'Log labels' })).toHaveLength(1);
}),
it('should render labels', () => {
const wrapper = setup({ row: { labels: { key1: 'label1', key2: 'label2' } } });
expect(wrapper.text().includes('key1label1key2label2')).toBe(true);
});
}),
describe('when row entry has parsable fields', () => {
it('should render heading ', () => {
const wrapper = setup({ row: { entry: 'test=successful' } });
expect(wrapper.find({ 'aria-label': 'Parsed fields' })).toHaveLength(1);
}),
it('should render parsed fields', () => {
const wrapper = setup({
row: { entry: 'test=successful' },
parser: {
getLabelFromField: () => 'test',
getValueFromField: () => 'successful',
},
});
expect(wrapper.text().includes('testsuccessful')).toBe(true);
});
}),
describe('when row entry have parsable fields and labels are present', () => {
it('should render all headings', () => {
const wrapper = setup({ row: { entry: 'test=successful', labels: { key: 'label' } } });
expect(wrapper.find({ 'aria-label': 'Log labels' })).toHaveLength(1);
expect(wrapper.find({ 'aria-label': 'Parsed fields' })).toHaveLength(1);
}),
it('should render all labels and parsed fields', () => {
const wrapper = setup({
row: { entry: 'test=successful', labels: { key: 'label' } },
parser: {
getLabelFromField: () => 'test',
getValueFromField: () => 'successful',
},
});
expect(wrapper.text().includes('keylabel')).toBe(true);
expect(wrapper.text().includes('testsuccessful')).toBe(true);
});
}),
describe('when row entry and labels are not present', () => {
it('should render no details available message', () => {
const wrapper = setup({ parsedFields: [] });
expect(wrapper.text().includes('No details available')).toBe(true);
}),
it('should not render headings', () => {
const wrapper = setup({ parsedFields: [] });
expect(wrapper.find({ 'aria-label': 'Log labels' })).toHaveLength(0);
expect(wrapper.find({ 'aria-label': 'Parsed fields' })).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,98 @@
import React, { PureComponent } from 'react';
import memoizeOne from 'memoize-one';
import { getParser, LogRowModel, LogsParser } from '@grafana/data';
import { Themeable } from '../../types/theme';
import { withTheme } from '../../themes/index';
import { getLogRowStyles } from './getLogRowStyles';
//Components
import { LogDetailsRow } from './LogDetailsRow';
export interface Props extends Themeable {
row: LogRowModel;
getRows: () => LogRowModel[];
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
}
class UnThemedLogDetails extends PureComponent<Props> {
parseMessage = memoizeOne(
(rowEntry): { parsedFields: string[]; parser?: LogsParser } => {
const parser = getParser(rowEntry);
if (!parser) {
return { parsedFields: [] };
}
// Use parser to highlight detected fields
const parsedFields = parser.getFields(rowEntry);
return { parsedFields, parser };
}
);
render() {
const { row, theme, onClickFilterOutLabel, onClickFilterLabel, getRows } = this.props;
const style = getLogRowStyles(theme, row.logLevel);
const labels = row.labels ? row.labels : {};
const labelsAvailable = Object.keys(labels).length > 0;
const { parsedFields, parser } = this.parseMessage(row.entry);
const parsedFieldsAvailable = parsedFields && parsedFields.length > 0;
return (
<div className={style.logsRowDetailsTable}>
{labelsAvailable && (
<div className={style.logsRowDetailsSectionTable}>
<div className={style.logsRowDetailsHeading} aria-label="Log labels">
Log Labels:
</div>
{Object.keys(labels).map(key => {
const value = labels[key];
const field = `${key}=${value}`;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
field={field}
row={row}
getRows={getRows}
isLabel={true}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickFilterLabel={onClickFilterLabel}
/>
);
})}
</div>
)}
{parsedFieldsAvailable && (
<div className={style.logsRowDetailsSectionTable}>
<div className={style.logsRowDetailsHeading} aria-label="Parsed fields">
Parsed fields:
</div>
{parsedFields &&
parsedFields.map(field => {
const key = parser!.getLabelFromField(field);
const value = parser!.getValueFromField(field);
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
field={field}
row={row}
isLabel={false}
getRows={getRows}
parser={parser}
/>
);
})}
</div>
)}
{!parsedFieldsAvailable && !labelsAvailable && <div aria-label="No details">No details available</div>}
</div>
);
}
}
export const LogDetails = withTheme(UnThemedLogDetails);
LogDetails.displayName = 'LogDetails';

View File

@ -0,0 +1,49 @@
import React from 'react';
import { LogDetailsRow, Props } from './LogDetailsRow';
import { LogRowModel, LogsParser, GrafanaTheme } from '@grafana/data';
import { mount } from 'enzyme';
const setup = (propOverrides?: object) => {
const props: Props = {
theme: {} as GrafanaTheme,
parsedValue: '',
parsedKey: '',
field: '',
isLabel: true,
parser: {} as LogsParser,
row: {} as LogRowModel,
getRows: () => [],
onClickFilterLabel: () => {},
onClickFilterOutLabel: () => {},
};
Object.assign(props, propOverrides);
const wrapper = mount(<LogDetailsRow {...props} />);
return wrapper;
};
describe('LogDetailsRow', () => {
it('should render parsed key', () => {
const wrapper = setup({ parsedKey: 'test key' });
expect(wrapper.text().includes('test key')).toBe(true);
}),
it('should render parsed value', () => {
const wrapper = setup({ parsedValue: 'test value' });
expect(wrapper.text().includes('test value')).toBe(true);
});
it('should render metrics button', () => {
const wrapper = setup();
expect(wrapper.find('i.fa-signal')).toHaveLength(1);
});
describe('if props is a label', () => {
it('should render filter label button', () => {
const wrapper = setup();
expect(wrapper.find('i.fa-search-plus')).toHaveLength(1);
}),
it('should render filte out label button', () => {
const wrapper = setup();
expect(wrapper.find('i.fa-search-minus')).toHaveLength(1);
});
});
});

View File

@ -0,0 +1,141 @@
import React, { PureComponent } from 'react';
import {
LogRowModel,
LogsParser,
LogLabelStatsModel,
calculateFieldStats,
calculateLogsLabelStats,
} from '@grafana/data';
import { Themeable } from '../../types/theme';
import { withTheme } from '../../themes/index';
import { getLogRowStyles } from './getLogRowStyles';
//Components
import { LogLabelStats } from './LogLabelStats';
export interface Props extends Themeable {
parsedValue: string;
parsedKey: string;
field: string;
row: LogRowModel;
isLabel: boolean;
parser?: LogsParser;
getRows: () => LogRowModel[];
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
}
interface State {
showFieldsStats: boolean;
fieldCount: number;
fieldLabel: string | null;
fieldStats: LogLabelStatsModel[] | null;
fieldValue: string | null;
}
class UnThemedLogDetailsRow extends PureComponent<Props, State> {
state: State = {
showFieldsStats: false,
fieldCount: 0,
fieldLabel: null,
fieldStats: null,
fieldValue: null,
};
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) {
this.createStatsForLabels();
}
this.toggleFieldsStats();
};
toggleFieldsStats() {
this.setState(state => {
return {
showFieldsStats: !state.showFieldsStats,
};
});
}
createStatsForLabels() {
const { getRows, parser, parsedKey, parsedValue, isLabel } = this.props;
const allRows = getRows();
const fieldLabel = parsedKey;
const fieldValue = parsedValue;
let fieldStats = [];
if (isLabel) {
fieldStats = calculateLogsLabelStats(allRows, parsedKey);
} else {
const matcher = parser!.buildMatcher(fieldLabel);
fieldStats = calculateFieldStats(allRows, matcher);
}
const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue });
}
render() {
const { theme, parsedKey, parsedValue, isLabel } = this.props;
const { showFieldsStats, fieldStats, fieldLabel, fieldValue, fieldCount } = this.state;
const style = getLogRowStyles(theme);
return (
<div className={style.logsRowDetailsValue}>
{/* Action buttons - show stats/filter results */}
<div onClick={this.showStats} className={style.logsRowDetailsIcon}>
<i className={'fa fa-signal'} />
</div>
{isLabel ? (
<div onClick={() => this.filterLabel()} className={style.logsRowDetailsIcon}>
<i className={'fa fa-search-plus'} />
</div>
) : (
<div className={style.logsRowDetailsIcon} />
)}
{isLabel ? (
<div onClick={() => this.filterOutLabel()} className={style.logsRowDetailsIcon}>
<i className={'fa fa-search-minus'} />
</div>
) : (
<div className={style.logsRowDetailsIcon} />
)}
{/* Key - value columns */}
<div className={style.logsRowDetailsLabel}>
<span>{parsedKey}</span>
</div>
<div className={style.logsRowCell}>
<span>{parsedValue}</span>
{showFieldsStats && (
<div className={style.logsRowCell}>
<LogLabelStats
stats={fieldStats!}
label={fieldLabel!}
value={fieldValue!}
rowCount={fieldCount}
isLabel={isLabel}
/>
</div>
)}
</div>
</div>
);
}
}
export const LogDetailsRow = withTheme(UnThemedLogDetailsRow);
LogDetailsRow.displayName = 'LogDetailsRow';

View File

@ -110,13 +110,7 @@ class UnThemedLogLabel extends PureComponent<Props, State> {
)} )}
{showStats && ( {showStats && (
<span className={cx([styles.logsLabelStats])}> <span className={cx([styles.logsLabelStats])}>
<LogLabelStats <LogLabelStats stats={stats} rowCount={getRows().length} label={label} value={value} isLabel={true} />
stats={stats}
rowCount={getRows().length}
label={label}
value={value}
onClickClose={this.onClickClose}
/>
</span> </span>
)} )}
</span> </span>

View File

@ -1,48 +1,56 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { css, cx } from 'emotion'; import { css } from 'emotion';
import { LogLabelStatsModel } from '@grafana/data'; import { LogLabelStatsModel, GrafanaTheme } from '@grafana/data';
import { LogLabelStatsRow } from './LogLabelStatsRow';
import { Themeable } from '../../types/theme'; import { Themeable } from '../../types/theme';
import { GrafanaTheme } from '@grafana/data'; import { stylesFactory } from '../../themes';
import { selectThemeVariant } from '../../themes/selectThemeVariant'; import { selectThemeVariant } from '../../themes/selectThemeVariant';
import { withTheme } from '../../themes/index'; import { withTheme } from '../../themes/index';
//Components
import { LogLabelStatsRow } from './LogLabelStatsRow';
const STATS_ROW_LIMIT = 5; const STATS_ROW_LIMIT = 5;
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = stylesFactory((theme: GrafanaTheme) => {
logsStats: css` const borderColor = selectThemeVariant(
label: logs-stats; {
background-color: ${selectThemeVariant({ light: theme.colors.pageBg, dark: theme.colors.dark2 }, theme.type)}; light: theme.colors.gray5,
color: ${theme.colors.text}; dark: theme.colors.dark9,
border: 1px solid ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)}; },
border-radius: ${theme.border.radius.md}; theme.type
max-width: 500px; );
`, return {
logsStatsHeader: css` logsStats: css`
label: logs-stats__header; label: logs-stats;
background: ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)}; display: table-cell;
padding: 6px 10px; column-span: 2;
display: flex; background: inherit;
`, color: ${theme.colors.text};
logsStatsTitle: css` `,
label: logs-stats__title; logsStatsHeader: css`
font-weight: ${theme.typography.weight.semibold}; label: logs-stats__header;
padding-right: ${theme.spacing.d}; border-bottom: 1px solid ${borderColor};
overflow: hidden; display: flex;
display: inline-block; `,
white-space: nowrap; logsStatsTitle: css`
text-overflow: ellipsis; label: logs-stats__title;
flex-grow: 1; font-weight: ${theme.typography.weight.semibold};
`, padding-right: ${theme.spacing.d};
logsStatsClose: css` display: inline-block;
label: logs-stats__close; white-space: nowrap;
cursor: pointer; text-overflow: ellipsis;
`, flex-grow: 1;
logsStatsBody: css` `,
label: logs-stats__body; logsStatsClose: css`
padding: 20px 10px 10px 10px; label: logs-stats__close;
`, cursor: pointer;
`,
logsStatsBody: css`
label: logs-stats__body;
padding: 5px 0;
`,
};
}); });
interface Props extends Themeable { interface Props extends Themeable {
@ -50,12 +58,12 @@ interface Props extends Themeable {
label: string; label: string;
value: string; value: string;
rowCount: number; rowCount: number;
onClickClose: () => void; isLabel: boolean;
} }
class UnThemedLogLabelStats extends PureComponent<Props> { class UnThemedLogLabelStats extends PureComponent<Props> {
render() { render() {
const { label, rowCount, stats, value, onClickClose, theme } = this.props; const { label, rowCount, stats, value, theme, isLabel } = this.props;
const style = getStyles(theme); const style = getStyles(theme);
const topRows = stats.slice(0, STATS_ROW_LIMIT); const topRows = stats.slice(0, STATS_ROW_LIMIT);
let activeRow = topRows.find(row => row.value === value); let activeRow = topRows.find(row => row.value === value);
@ -74,14 +82,13 @@ class UnThemedLogLabelStats extends PureComponent<Props> {
const otherProportion = otherCount / total; const otherProportion = otherCount / total;
return ( return (
<div className={cx([style.logsStats])}> <div className={style.logsStats}>
<div className={cx([style.logsStatsHeader])}> <div className={style.logsStatsHeader}>
<span className={cx([style.logsStatsTitle])}> <div className={style.logsStatsTitle}>
{label}: {total} of {rowCount} rows have that label {label}: {total} of {rowCount} rows have that {isLabel ? 'label' : 'field'}
</span> </div>
<span className={cx([style.logsStatsClose, 'fa fa-remove'])} onClick={onClickClose} />
</div> </div>
<div className={cx([style.logsStatsBody])}> <div className={style.logsStatsBody}>
{topRows.map(stat => ( {topRows.map(stat => (
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} /> <LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
))} ))}

View File

@ -13,14 +13,6 @@ const getStyles = (theme: GrafanaTheme) => ({
label: logs-stats-row--active; label: logs-stats-row--active;
color: ${theme.colors.blue}; color: ${theme.colors.blue};
position: relative; position: relative;
::after {
display: inline;
content: '*';
position: absolute;
top: 0;
left: -8px;
}
`, `,
logsStatsRowLabel: css` logsStatsRowLabel: css`
label: logs-stats-row__label; label: logs-stats-row__label;

View File

@ -1,33 +1,37 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data'; import { LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data';
import { cx } from 'emotion';
import { import {
LogRowContextRows, LogRowContextRows,
LogRowContextQueryErrors, LogRowContextQueryErrors,
HasMoreContextRows, HasMoreContextRows,
LogRowContextProvider, LogRowContextProvider,
} from './LogRowContextProvider'; } from './LogRowContextProvider';
import { LogLabels } from './LogLabels';
import { Themeable } from '../../types/theme'; import { Themeable } from '../../types/theme';
import { withTheme } from '../../themes/index'; import { withTheme } from '../../themes/index';
import { getLogRowStyles } from './getLogRowStyles'; import { getLogRowStyles } from './getLogRowStyles';
//Components
import { LogDetails } from './LogDetails';
import { LogRowMessage } from './LogRowMessage'; import { LogRowMessage } from './LogRowMessage';
interface Props extends Themeable { interface Props extends Themeable {
highlighterExpressions?: string[]; highlighterExpressions?: string[];
row: LogRowModel; row: LogRowModel;
showDuplicates: boolean; showDuplicates: boolean;
showLabels: boolean;
showTime: boolean; showTime: boolean;
timeZone: TimeZone; timeZone: TimeZone;
isLogsPanel?: boolean;
getRows: () => LogRowModel[]; getRows: () => LogRowModel[];
onClickLabel?: (label: string, value: string) => void; onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
onContextClick?: () => void; onContextClick?: () => void;
getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>; getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
} }
interface State { interface State {
showContext: boolean; showContext: boolean;
showDetails: boolean;
} }
/** /**
@ -40,6 +44,7 @@ interface State {
class UnThemedLogRow extends PureComponent<Props, State> { class UnThemedLogRow extends PureComponent<Props, State> {
state: State = { state: State = {
showContext: false, showContext: false,
showDetails: false,
}; };
toggleContext = () => { toggleContext = () => {
@ -50,6 +55,14 @@ class UnThemedLogRow extends PureComponent<Props, State> {
}); });
}; };
toggleDetails = () => {
this.setState(state => {
return {
showDetails: !state.showDetails,
};
});
};
renderLogRow( renderLogRow(
context?: LogRowContextRows, context?: LogRowContextRows,
errors?: LogRowContextQueryErrors, errors?: LogRowContextQueryErrors,
@ -58,57 +71,66 @@ class UnThemedLogRow extends PureComponent<Props, State> {
) { ) {
const { const {
getRows, getRows,
onClickFilterLabel,
onClickFilterOutLabel,
highlighterExpressions, highlighterExpressions,
onClickLabel, isLogsPanel,
row, row,
showDuplicates, showDuplicates,
showLabels,
timeZone, timeZone,
showTime, showTime,
theme, theme,
} = this.props; } = this.props;
const { showContext } = this.state; const { showDetails, showContext } = this.state;
const style = getLogRowStyles(theme, row.logLevel); const style = getLogRowStyles(theme, row.logLevel);
const showUtc = timeZone === 'utc'; const showUtc = timeZone === 'utc';
return ( return (
<div className={cx([style.logsRow])}> <div className={style.logsRow}>
{showDuplicates && ( {showDuplicates && (
<div className={cx([style.logsRowDuplicates])}> <div className={style.logsRowDuplicates}>
{row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null} {row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
</div> </div>
)} )}
<div className={cx([style.logsRowLevel])} /> <div className={style.logsRowLevel} />
{showTime && showUtc && ( {!isLogsPanel && (
<div className={cx([style.logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}> <div title="See log details" onClick={this.toggleDetails} className={style.logsRowToggleDetails}>
{row.timeUtc} <i className={showDetails ? 'fa fa-chevron-up' : 'fa fa-chevron-down'} />
</div> </div>
)} )}
{showTime && !showUtc && ( <div>
<div className={cx([style.logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}> <div>
{row.timeLocal} {showTime && showUtc && (
</div> <div className={style.logsRowLocalTime} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
)} {row.timeUtc}
{showLabels && ( </div>
<div className={cx([style.logsRowLabels])}> )}
<LogLabels {showTime && !showUtc && (
<div className={style.logsRowLocalTime} title={`${row.timeUtc} (${row.timeFromNow})`}>
{row.timeLocal}
</div>
)}
<LogRowMessage
highlighterExpressions={highlighterExpressions}
row={row}
getRows={getRows} getRows={getRows}
labels={row.uniqueLabels ? row.uniqueLabels : {}} errors={errors}
onClickLabel={onClickLabel} hasMoreContextRows={hasMoreContextRows}
updateLimit={updateLimit}
context={context}
showContext={showContext}
onToggleContext={this.toggleContext}
/> />
</div> </div>
)} {this.state.showDetails && (
<LogRowMessage <LogDetails
highlighterExpressions={highlighterExpressions} onClickFilterLabel={onClickFilterLabel}
row={row} onClickFilterOutLabel={onClickFilterOutLabel}
getRows={getRows} getRows={getRows}
errors={errors} row={row}
hasMoreContextRows={hasMoreContextRows} />
updateLimit={updateLimit} )}
context={context} </div>
showContext={showContext}
onToggleContext={this.toggleContext}
/>
</div> </div>
); );
} }

View File

@ -1,68 +1,35 @@
import React, { PureComponent, FunctionComponent, useContext } from 'react'; import React, { PureComponent } from 'react';
import _ from 'lodash'; import _ from 'lodash';
// @ts-ignore
import Highlighter from 'react-highlight-words';
import {
LogRowModel,
LogLabelStatsModel,
LogsParser,
calculateFieldStats,
getParser,
findHighlightChunksInText,
} from '@grafana/data';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { selectThemeVariant, ThemeContext } from '../../index'; import { LogRowModel, findHighlightChunksInText, GrafanaTheme } from '@grafana/data';
import { GrafanaTheme } from '@grafana/data';
// @ts-ignore
import Highlighter from 'react-highlight-words';
import { LogRowContextQueryErrors, HasMoreContextRows, LogRowContextRows } from './LogRowContextProvider'; import { LogRowContextQueryErrors, HasMoreContextRows, LogRowContextRows } from './LogRowContextProvider';
import { LogRowContext } from './LogRowContext'; import { selectThemeVariant } from '../../index';
import { LogMessageAnsi } from './LogMessageAnsi';
import { LogLabelStats } from './LogLabelStats';
import { Themeable } from '../../types/theme'; import { Themeable } from '../../types/theme';
import { withTheme } from '../../themes/index'; import { withTheme } from '../../themes/index';
import { getLogRowStyles } from './getLogRowStyles'; import { getLogRowStyles } from './getLogRowStyles';
import { stylesFactory } from '../../themes/stylesFactory'; import { stylesFactory } from '../../themes/stylesFactory';
//Components
import { LogRowContext } from './LogRowContext';
import { LogMessageAnsi } from './LogMessageAnsi';
interface Props extends Themeable { interface Props extends Themeable {
highlighterExpressions?: string[];
row: LogRowModel; row: LogRowModel;
getRows: () => LogRowModel[];
errors?: LogRowContextQueryErrors;
hasMoreContextRows?: HasMoreContextRows; hasMoreContextRows?: HasMoreContextRows;
updateLimit?: () => void;
context?: LogRowContextRows;
showContext: boolean; showContext: boolean;
errors?: LogRowContextQueryErrors;
context?: LogRowContextRows;
highlighterExpressions?: string[];
getRows: () => LogRowModel[];
onToggleContext: () => void; onToggleContext: () => void;
updateLimit?: () => void;
} }
interface State { interface State {}
fieldCount: number;
fieldLabel: string | null;
fieldStats: LogLabelStatsModel[] | null;
fieldValue: string | null;
parsed: boolean;
parser?: LogsParser;
parsedFieldHighlights: string[];
showFieldStats: boolean;
}
/**
* Renders a highlighted field.
* When hovering, a stats icon is shown.
*/
const FieldHighlight = (onClick: any): FunctionComponent<any> => (props: any) => {
const theme = useContext(ThemeContext);
const style = getLogRowStyles(theme);
return (
<span className={props.className} style={props.style}>
{props.children}
<span
className={cx([style, 'logs-row__field-highlight--icon', 'fa fa-signal'])}
onClick={() => onClick(props.children)}
/>
</span>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
const outlineColor = selectThemeVariant( const outlineColor = selectThemeVariant(
@ -86,98 +53,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
.setAlpha(0.7) .setAlpha(0.7)
.toRgbString()}; .toRgbString()};
`, `,
whiteSpacePreWrap: css`
label: whiteSpacePreWrap;
white-space: pre-wrap;
`,
}; };
}); });
class UnThemedLogRowMessage extends PureComponent<Props, State> { class UnThemedLogRowMessage extends PureComponent<Props, State> {
mouseMessageTimer: number | null = null;
state: State = {
fieldCount: 0,
fieldLabel: null,
fieldStats: null,
fieldValue: null,
parsed: false,
parser: undefined,
parsedFieldHighlights: [],
showFieldStats: false,
};
componentWillUnmount() {
this.clearMouseMessageTimer();
}
onClickClose = () => {
this.setState({ showFieldStats: false });
};
onClickHighlight = (fieldText: string) => {
const { getRows } = this.props;
const { parser } = this.state;
const allRows = getRows();
// Build value-agnostic row matcher based on the field label
const fieldLabel = parser!.getLabelFromField(fieldText);
const fieldValue = parser!.getValueFromField(fieldText);
const matcher = parser!.buildMatcher(fieldLabel);
const fieldStats = calculateFieldStats(allRows, matcher);
const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
};
onMouseOverMessage = () => {
if (this.props.showContext || this.isTextSelected()) {
// When showing context we don't want to the LogRow rerender as it will mess up state of context block
// making the "after" context to be scrolled to the top, what is desired only on open
// The log row message needs to be refactored to separate component that encapsulates parsing and parsed message state
return;
}
// Don't parse right away, user might move along
this.mouseMessageTimer = window.setTimeout(this.parseMessage, 500);
};
onMouseOutMessage = () => {
if (this.props.showContext) {
// See comment in onMouseOverMessage method
return;
}
this.clearMouseMessageTimer();
this.setState({ parsed: false });
};
clearMouseMessageTimer = () => {
if (this.mouseMessageTimer) {
clearTimeout(this.mouseMessageTimer);
}
};
parseMessage = () => {
if (!this.state.parsed) {
const { row } = this.props;
const parser = getParser(row.entry);
if (parser) {
// Use parser to highlight detected fields
const parsedFieldHighlights = parser.getFields(this.props.row.entry);
this.setState({ parsedFieldHighlights, parsed: true, parser });
}
}
};
isTextSelected() {
if (!window.getSelection) {
return false;
}
const selection = window.getSelection();
if (!selection) {
return false;
}
return selection.anchorNode !== null && selection.isCollapsed === false;
}
onContextToggle = (e: React.SyntheticEvent<HTMLElement>) => { onContextToggle = (e: React.SyntheticEvent<HTMLElement>) => {
e.stopPropagation(); e.stopPropagation();
this.props.onToggleContext(); this.props.onToggleContext();
@ -195,17 +78,10 @@ class UnThemedLogRowMessage extends PureComponent<Props, State> {
showContext, showContext,
onToggleContext, onToggleContext,
} = this.props; } = this.props;
const { const {} = this.state;
fieldCount,
fieldLabel,
fieldStats,
fieldValue,
parsed,
parsedFieldHighlights,
showFieldStats,
} = this.state;
const style = getLogRowStyles(theme, row.logLevel); const style = getLogRowStyles(theme, row.logLevel);
const { entry, hasAnsi, raw } = row; const { entry, hasAnsi, raw } = row;
const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords); const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
const highlights = previewHighlights ? highlighterExpressions : row.searchWords; const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0; const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0;
@ -213,13 +89,8 @@ class UnThemedLogRowMessage extends PureComponent<Props, State> {
? cx([style.logsRowMatchHighLight, style.logsRowMatchHighLightPreview]) ? cx([style.logsRowMatchHighLight, style.logsRowMatchHighLightPreview])
: cx([style.logsRowMatchHighLight]); : cx([style.logsRowMatchHighLight]);
const styles = getStyles(theme); const styles = getStyles(theme);
return ( return (
<div <div className={style.logsRowMessage}>
className={cx([style.logsRowMessage])}
onMouseEnter={this.onMouseOverMessage}
onMouseLeave={this.onMouseOutMessage}
>
<div className={styles.positionRelative}> <div className={styles.positionRelative}>
{showContext && context && ( {showContext && context && (
<LogRowContext <LogRowContext
@ -236,37 +107,18 @@ class UnThemedLogRowMessage extends PureComponent<Props, State> {
/> />
)} )}
<span className={cx(styles.positionRelative, { [styles.rowWithContext]: showContext })}> <span className={cx(styles.positionRelative, { [styles.rowWithContext]: showContext })}>
{parsed && ( {needsHighlighter ? (
<Highlighter <Highlighter
style={{ whiteSpace: 'pre-wrap' }} style={styles.whiteSpacePreWrap}
autoEscape
highlightTag={FieldHighlight(this.onClickHighlight)}
textToHighlight={entry}
searchWords={parsedFieldHighlights}
highlightClassName={cx([style.logsRowFieldHighLight])}
/>
)}
{!parsed && needsHighlighter && (
<Highlighter
style={{ whiteSpace: 'pre-wrap' }}
textToHighlight={entry} textToHighlight={entry}
searchWords={highlights} searchWords={highlights}
findChunks={findHighlightChunksInText} findChunks={findHighlightChunksInText}
highlightClassName={highlightClassName} highlightClassName={highlightClassName}
/> />
)} ) : hasAnsi ? (
{hasAnsi && !parsed && !needsHighlighter && <LogMessageAnsi value={raw} />} <LogMessageAnsi value={raw} />
{!hasAnsi && !parsed && !needsHighlighter && entry} ) : (
{showFieldStats && ( entry
<div className={cx([style.logsRowStats])}>
<LogLabelStats
stats={fieldStats!}
label={fieldLabel!}
value={fieldValue!}
onClickClose={this.onClickClose}
rowCount={fieldCount}
/>
</div>
)} )}
</span> </span>
{row.searchWords && row.searchWords.length > 0 && ( {row.searchWords && row.searchWords.length > 0 && (

View File

@ -17,7 +17,6 @@ describe('LogRows', () => {
dedupStrategy={LogsDedupStrategy.none} dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]} highlighterExpressions={[]}
showTime={false} showTime={false}
showLabels={false}
timeZone={'utc'} timeZone={'utc'}
/> />
); );
@ -40,7 +39,6 @@ describe('LogRows', () => {
dedupStrategy={LogsDedupStrategy.none} dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]} highlighterExpressions={[]}
showTime={false} showTime={false}
showLabels={false}
timeZone={'utc'} timeZone={'utc'}
previewLimit={1} previewLimit={1}
/> />
@ -75,7 +73,6 @@ describe('LogRows', () => {
dedupStrategy={LogsDedupStrategy.none} dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]} highlighterExpressions={[]}
showTime={false} showTime={false}
showLabels={false}
timeZone={'utc'} timeZone={'utc'}
/> />
); );
@ -97,7 +94,6 @@ describe('LogRows', () => {
dedupStrategy={LogsDedupStrategy.none} dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]} highlighterExpressions={[]}
showTime={false} showTime={false}
showLabels={false}
timeZone={'utc'} timeZone={'utc'}
/> />
); );
@ -121,6 +117,7 @@ const makeLog = (overides: Partial<LogRowModel>): LogRowModel => {
timeEpochMs: 1, timeEpochMs: 1,
timeLocal: '', timeLocal: '',
timeUtc: '', timeUtc: '',
searchWords: [],
...overides, ...overides,
}; };
}; };

View File

@ -1,12 +1,13 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { cx } from 'emotion'; import memoizeOne from 'memoize-one';
import { LogsModel, TimeZone, LogsDedupStrategy, LogRowModel } from '@grafana/data'; import { LogsModel, TimeZone, LogsDedupStrategy, LogRowModel } from '@grafana/data';
import { LogRow } from './LogRow';
import { Themeable } from '../../types/theme'; import { Themeable } from '../../types/theme';
import { withTheme } from '../../themes/index'; import { withTheme } from '../../themes/index';
import { getLogRowStyles } from './getLogRowStyles'; import { getLogRowStyles } from './getLogRowStyles';
import memoizeOne from 'memoize-one';
//Components
import { LogRow } from './LogRow';
export const PREVIEW_LIMIT = 100; export const PREVIEW_LIMIT = 100;
export const RENDER_LIMIT = 500; export const RENDER_LIMIT = 500;
@ -16,13 +17,14 @@ export interface Props extends Themeable {
dedupStrategy: LogsDedupStrategy; dedupStrategy: LogsDedupStrategy;
highlighterExpressions: string[]; highlighterExpressions: string[];
showTime: boolean; showTime: boolean;
showLabels: boolean;
timeZone: TimeZone; timeZone: TimeZone;
deduplicatedData?: LogsModel; deduplicatedData?: LogsModel;
onClickLabel?: (label: string, value: string) => void;
getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
rowLimit?: number; rowLimit?: number;
isLogsPanel?: boolean;
previewLimit?: number; previewLimit?: number;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
} }
interface State { interface State {
@ -71,17 +73,17 @@ class UnThemedLogRows extends PureComponent<Props, State> {
data, data,
deduplicatedData, deduplicatedData,
highlighterExpressions, highlighterExpressions,
showLabels,
timeZone, timeZone,
onClickLabel, onClickFilterLabel,
onClickFilterOutLabel,
rowLimit, rowLimit,
theme, theme,
isLogsPanel,
previewLimit, previewLimit,
} = this.props; } = this.props;
const { renderAll } = this.state; const { renderAll } = this.state;
const dedupedData = deduplicatedData ? deduplicatedData : data; const dedupedData = deduplicatedData ? deduplicatedData : data;
const hasData = data && data.rows && data.rows.length > 0; const hasData = data && data.rows && data.rows.length > 0;
const hasLabel = hasData && dedupedData && dedupedData.hasUniqueLabels ? true : false;
const dedupCount = dedupedData const dedupCount = dedupedData
? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0) ? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0)
: 0; : 0;
@ -99,7 +101,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
const { logsRows } = getLogRowStyles(theme); const { logsRows } = getLogRowStyles(theme);
return ( return (
<div className={cx([logsRows])}> <div className={logsRows}>
{hasData && {hasData &&
firstRows.map((row, index) => ( firstRows.map((row, index) => (
<LogRow <LogRow
@ -109,10 +111,11 @@ class UnThemedLogRows extends PureComponent<Props, State> {
highlighterExpressions={highlighterExpressions} highlighterExpressions={highlighterExpressions}
row={row} row={row}
showDuplicates={showDuplicates} showDuplicates={showDuplicates}
showLabels={showLabels && hasLabel}
showTime={showTime} showTime={showTime}
timeZone={timeZone} timeZone={timeZone}
onClickLabel={onClickLabel} isLogsPanel={isLogsPanel}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
/> />
))} ))}
{hasData && {hasData &&
@ -124,10 +127,11 @@ class UnThemedLogRows extends PureComponent<Props, State> {
getRowContext={getRowContext} getRowContext={getRowContext}
row={row} row={row}
showDuplicates={showDuplicates} showDuplicates={showDuplicates}
showLabels={showLabels && hasLabel}
showTime={showTime} showTime={showTime}
timeZone={timeZone} timeZone={timeZone}
onClickLabel={onClickLabel} isLogsPanel={isLogsPanel}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
/> />
))} ))}
{hasData && !renderAll && <span>Rendering {rowCount - previewLimit!} rows...</span>} {hasData && !renderAll && <span>Rendering {rowCount - previewLimit!} rows...</span>}

View File

@ -7,6 +7,7 @@ import { stylesFactory } from '../../themes';
export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: LogLevel) => { export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: LogLevel) => {
let logColor = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.gray2 }, theme.type); let logColor = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.gray2 }, theme.type);
const bgColor = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.gray2 }, theme.type);
switch (logLevel) { switch (logLevel) {
case LogLevel.crit: case LogLevel.crit:
case LogLevel.critical: case LogLevel.critical:
@ -32,34 +33,13 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
} }
return { return {
logsRowFieldHighLight: css`
label: logs-row__field-highlight;
background: inherit;
padding: inherit;
border-bottom: 1px dotted ${theme.colors.yellow};
.logs-row__field-highlight--icon {
margin-left: 0.5em;
cursor: pointer;
display: none;
}
&:hover {
color: ${theme.colors.yellow};
border-bottom-style: solid;
.logs-row__field-highlight--icon {
display: inline;
}
}
`,
logsRowMatchHighLight: css` logsRowMatchHighLight: css`
label: logs-row__match-highlight; label: logs-row__match-highlight;
background: inherit; background: inherit;
padding: inherit; padding: inherit;
color: ${theme.colors.yellow}; color: ${theme.colors.yellow};
border-bottom: 1px solid ${theme.colors.yellow}; border-bottom: ${theme.border.width.sm} solid ${theme.colors.yellow};
background-color: rgba(${theme.colors.yellow}, 0.1); background-color: rgba(${theme.colors.yellow}, 0.1);
`, `,
logsRowMatchHighLightPreview: css` logsRowMatchHighLightPreview: css`
@ -81,9 +61,9 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
> div { > div {
display: table-cell; display: table-cell;
padding-right: 10px; padding-right: ${theme.spacing.sm};
border-top: 1px solid transparent; border-top: ${theme.border.width.sm} solid transparent;
border-bottom: 1px solid transparent; border-bottom: ${theme.border.width.sm} solid transparent;
height: 100%; height: 100%;
} }
@ -111,24 +91,87 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
background-color: ${logColor}; background-color: ${logColor};
} }
`, `,
logsRowCell: css`
label: logs-row-cell;
display: table-cell;
word-break: break-all;
`,
logsRowToggleDetails: css`
label: logs-row-toggle-details__level;
position: relative;
width: 15px;
padding-right: ${theme.spacing.sm};
font-size: 9px;
cursor: pointer;
`,
logsRowLocalTime: css` logsRowLocalTime: css`
label: logs-row__localtime; label: logs-row__localtime;
display: table-cell;
white-space: nowrap; white-space: nowrap;
width: 12.5em; width: 12.5em;
`, `,
logsRowLabels: css`
label: logs-row__labels;
width: 20%;
line-height: 1.2;
position: relative;
`,
logsRowMessage: css` logsRowMessage: css`
label: logs-row__message; label: logs-row__message;
word-break: break-all; word-break: break-all;
display: table-cell;
`, `,
logsRowStats: css` logsRowStats: css`
label: logs-row__stats; label: logs-row__stats;
margin: 5px 0; margin: 5px 0;
`, `,
//Log details sepcific CSS
logsRowDetailsTable: css`
label: logs-row-details-table;
display: table;
border: 1px solid ${bgColor};
border-radius: 3px;
margin: 20px 0;
padding: ${theme.spacing.sm};
width: 100%;
`,
logsRowDetailsSectionTable: css`
label: logs-row-details-table__section;
display: table;
table-layout: fixed;
margin: 5px 0;
width: 100%;
`,
logsRowDetailsIcon: css`
label: logs-row-details__icon;
display: table-cell;
position: relative;
width: 22px;
padding-right: ${theme.spacing.sm};
color: ${theme.colors.gray3};
&:hover {
cursor: pointer;
color: ${theme.colors.yellow};
}
`,
logsRowDetailsLabel: css`
label: logs-row-details__label;
display: table-cell;
padding: 0 ${theme.spacing.md} 0 ${theme.spacing.md};
width: 12.5em;
word-break: break-all;
`,
logsRowDetailsHeading: css`
label: logs-row-details__heading;
display: table-caption;
margin: 5px 0 7px;
font-weight: ${theme.typography.weight.bold};
`,
logsRowDetailsValue: css`
label: logs-row-details__row;
display: table-row;
line-height: 2;
padding: 0 ${theme.spacing.xl} 0 ${theme.spacing.md};
position: relative;
cursor: default;
&:hover {
color: ${theme.colors.yellow};
}
`,
}; };
}); });

View File

@ -192,10 +192,14 @@ export class Explore extends React.PureComponent<ExploreProps> {
this.props.setQueries(this.props.exploreId, [query]); this.props.setQueries(this.props.exploreId, [query]);
}; };
onClickLabel = (key: string, value: string) => { onClickFilterLabel = (key: string, value: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', key, value }); this.onModifyQueries({ type: 'ADD_FILTER', key, value });
}; };
onClickFilterOutLabel = (key: string, value: string) => {
this.onModifyQueries({ type: 'ADD_FILTER_OUT', key, value });
};
onModifyQueries = (action: any, index?: number) => { onModifyQueries = (action: any, index?: number) => {
const { datasourceInstance } = this.props; const { datasourceInstance } = this.props;
if (datasourceInstance && datasourceInstance.modifyQuery) { if (datasourceInstance && datasourceInstance.modifyQuery) {
@ -307,14 +311,15 @@ export class Explore extends React.PureComponent<ExploreProps> {
/> />
)} )}
{mode === ExploreMode.Metrics && ( {mode === ExploreMode.Metrics && (
<TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} /> <TableContainer exploreId={exploreId} onClickCell={this.onClickFilterLabel} />
)} )}
{mode === ExploreMode.Logs && ( {mode === ExploreMode.Logs && (
<LogsContainer <LogsContainer
width={width} width={width}
exploreId={exploreId} exploreId={exploreId}
syncedTimes={syncedTimes} syncedTimes={syncedTimes}
onClickLabel={this.onClickLabel} onClickFilterLabel={this.onClickFilterLabel}
onClickFilterOutLabel={this.onClickFilterOutLabel}
onStartScanning={this.onStartScanning} onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning} onStopScanning={this.onStopScanning}
/> />

View File

@ -39,7 +39,8 @@ interface Props {
scanRange?: RawTimeRange; scanRange?: RawTimeRange;
dedupStrategy: LogsDedupStrategy; dedupStrategy: LogsDedupStrategy;
onChangeTime: (range: AbsoluteTimeRange) => void; onChangeTime: (range: AbsoluteTimeRange) => void;
onClickLabel?: (label: string, value: string) => void; onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
onStartScanning?: () => void; onStartScanning?: () => void;
onStopScanning?: () => void; onStopScanning?: () => void;
onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void; onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void;
@ -48,13 +49,11 @@ interface Props {
} }
interface State { interface State {
showLabels: boolean;
showTime: boolean; showTime: boolean;
} }
export class Logs extends PureComponent<Props, State> { export class Logs extends PureComponent<Props, State> {
state = { state = {
showLabels: false,
showTime: true, showTime: true,
}; };
@ -66,15 +65,6 @@ export class Logs extends PureComponent<Props, State> {
return onDedupStrategyChange(dedup); return onDedupStrategyChange(dedup);
}; };
onChangeLabels = (event?: React.SyntheticEvent) => {
const target = event && (event.target as HTMLInputElement);
if (target) {
this.setState({
showLabels: target.checked,
});
}
};
onChangeTime = (event?: React.SyntheticEvent) => { onChangeTime = (event?: React.SyntheticEvent) => {
const target = event && (event.target as HTMLInputElement); const target = event && (event.target as HTMLInputElement);
if (target) { if (target) {
@ -108,7 +98,8 @@ export class Logs extends PureComponent<Props, State> {
data, data,
highlighterExpressions, highlighterExpressions,
loading = false, loading = false,
onClickLabel, onClickFilterLabel,
onClickFilterOutLabel,
timeZone, timeZone,
scanning, scanning,
scanRange, scanRange,
@ -122,7 +113,7 @@ export class Logs extends PureComponent<Props, State> {
return null; return null;
} }
const { showLabels, showTime } = this.state; const { showTime } = this.state;
const { dedupStrategy } = this.props; const { dedupStrategy } = this.props;
const hasData = data && data.rows && data.rows.length > 0; const hasData = data && data.rows && data.rows.length > 0;
const dedupCount = dedupedData const dedupCount = dedupedData
@ -163,7 +154,6 @@ export class Logs extends PureComponent<Props, State> {
<div className="logs-panel-options"> <div className="logs-panel-options">
<div className="logs-panel-controls"> <div className="logs-panel-controls">
<Switch label="Time" checked={showTime} onChange={this.onChangeTime} transparent /> <Switch label="Time" checked={showTime} onChange={this.onChangeTime} transparent />
<Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} transparent />
<ToggleButtonGroup label="Dedup" transparent={true}> <ToggleButtonGroup label="Dedup" transparent={true}>
{Object.keys(LogsDedupStrategy).map((dedupType: string, i) => ( {Object.keys(LogsDedupStrategy).map((dedupType: string, i) => (
<ToggleButton <ToggleButton
@ -198,9 +188,9 @@ export class Logs extends PureComponent<Props, State> {
dedupStrategy={dedupStrategy} dedupStrategy={dedupStrategy}
getRowContext={this.props.getRowContext} getRowContext={this.props.getRowContext}
highlighterExpressions={highlighterExpressions} highlighterExpressions={highlighterExpressions}
onClickLabel={onClickLabel} onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
rowLimit={data ? data.rows.length : undefined} rowLimit={data ? data.rows.length : undefined}
showLabels={showLabels}
showTime={showTime} showTime={showTime}
timeZone={timeZone} timeZone={timeZone}
/> />

View File

@ -35,7 +35,8 @@ interface LogsContainerProps {
logsHighlighterExpressions?: string[]; logsHighlighterExpressions?: string[];
logsResult?: LogsModel; logsResult?: LogsModel;
dedupedResult?: LogsModel; dedupedResult?: LogsModel;
onClickLabel: (key: string, value: string) => void; onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
onStartScanning: () => void; onStartScanning: () => void;
onStopScanning: () => void; onStopScanning: () => void;
timeZone: TimeZone; timeZone: TimeZone;
@ -87,7 +88,8 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
logsHighlighterExpressions, logsHighlighterExpressions,
logsResult, logsResult,
dedupedResult, dedupedResult,
onClickLabel, onClickFilterLabel,
onClickFilterOutLabel,
onStartScanning, onStartScanning,
onStopScanning, onStopScanning,
absoluteRange, absoluteRange,
@ -126,7 +128,8 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
highlighterExpressions={logsHighlighterExpressions} highlighterExpressions={logsHighlighterExpressions}
loading={loading} loading={loading}
onChangeTime={this.onChangeTime} onChangeTime={this.onChangeTime}
onClickLabel={onClickLabel} onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onStartScanning={onStartScanning} onStartScanning={onStartScanning}
onStopScanning={onStopScanning} onStopScanning={onStopScanning}
onDedupStrategyChange={this.handleDedupStrategyChange} onDedupStrategyChange={this.handleDedupStrategyChange}

View File

@ -262,6 +262,10 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
selector = addLabelToSelector(selector, action.key, action.value); selector = addLabelToSelector(selector, action.key, action.value);
break; break;
} }
case 'ADD_FILTER_OUT': {
selector = addLabelToSelector(selector, action.key, action.value, '!=');
break;
}
default: default:
break; break;
} }

View File

@ -31,8 +31,8 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
dedupStrategy={LogsDedupStrategy.none} dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]} highlighterExpressions={[]}
showTime={showTime} showTime={showTime}
showLabels={false}
timeZone={timeZone} timeZone={timeZone}
isLogsPanel={true}
/> />
</CustomScrollbar> </CustomScrollbar>
); );