From a5e8e0e2917f294faef0c026c1515567c8929d4c Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri, 1 Nov 2019 10:01:00 +0100 Subject: [PATCH] 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. --- packages/grafana-data/src/utils/logs.test.ts | 18 +- packages/grafana-data/src/utils/logs.ts | 22 +- .../src/components/Logs/LogDetails.test.tsx | 88 ++++++++ .../src/components/Logs/LogDetails.tsx | 98 +++++++++ .../components/Logs/LogDetailsRow.test.tsx | 49 +++++ .../src/components/Logs/LogDetailsRow.tsx | 141 ++++++++++++ .../src/components/Logs/LogLabel.tsx | 8 +- .../src/components/Logs/LogLabelStats.tsx | 99 +++++---- .../src/components/Logs/LogLabelStatsRow.tsx | 8 - .../grafana-ui/src/components/Logs/LogRow.tsx | 92 +++++--- .../src/components/Logs/LogRowMessage.tsx | 206 +++--------------- .../src/components/Logs/LogRows.test.tsx | 5 +- .../src/components/Logs/LogRows.tsx | 32 +-- .../src/components/Logs/getLogRowStyles.ts | 105 ++++++--- public/app/features/explore/Explore.tsx | 11 +- public/app/features/explore/Logs.tsx | 24 +- public/app/features/explore/LogsContainer.tsx | 9 +- .../app/plugins/datasource/loki/datasource.ts | 4 + public/app/plugins/panel/logs/LogsPanel.tsx | 2 +- 19 files changed, 661 insertions(+), 360 deletions(-) create mode 100644 packages/grafana-ui/src/components/Logs/LogDetails.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetails.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts index 28f171e525d..d5778647891 100644 --- a/packages/grafana-data/src/utils/logs.test.ts +++ b/packages/grafana-data/src/utils/logs.test.ts @@ -89,7 +89,15 @@ describe('LogsParsers', () => { }); 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', () => { @@ -98,6 +106,11 @@ describe('LogsParsers', () => { test('should return value for field', () => { 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', () => { @@ -117,7 +130,7 @@ describe('LogsParsers', () => { }); 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', () => { @@ -126,6 +139,7 @@ describe('LogsParsers', () => { test('should return label for field', () => { 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', () => { diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index b14542d57fe..d0c60d72134 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -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 { DataFrame, FieldType } from '../types/index'; 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. @@ -79,21 +83,15 @@ export const LogsParsers: { [name: string]: LogsParser } = { JSON: { buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), getFields: line => { - const fields: string[] = []; try { const parsed = JSON.parse(line); - map(parsed, (value, key) => { - const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${escapeRegExp(JSON.stringify(value))}"?`); - - const match = line.match(fieldMatcher); - if (match) { - fields.push(match[0]); - } + return Object.keys(parsed).map(key => { + return `"${key}":${JSON.stringify(parsed[key])}`; }); } catch {} - return fields; + return []; }, - getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1], + getLabelFromField: field => (field.match(/^"([^"]+)"\s*:/) || [])[1], getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1], test: line => { try { diff --git a/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx b/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx new file mode 100644 index 00000000000..b37bc83181d --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx @@ -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(); + 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); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Logs/LogDetails.tsx b/packages/grafana-ui/src/components/Logs/LogDetails.tsx new file mode 100644 index 00000000000..39c4bdef6bf --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogDetails.tsx @@ -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 { + 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 ( +
+ {labelsAvailable && ( +
+
+ Log Labels: +
+ {Object.keys(labels).map(key => { + const value = labels[key]; + const field = `${key}=${value}`; + return ( + + ); + })} +
+ )} + + {parsedFieldsAvailable && ( +
+
+ Parsed fields: +
+ {parsedFields && + parsedFields.map(field => { + const key = parser!.getLabelFromField(field); + const value = parser!.getValueFromField(field); + return ( + + ); + })} +
+ )} + {!parsedFieldsAvailable && !labelsAvailable &&
No details available
} +
+ ); + } +} + +export const LogDetails = withTheme(UnThemedLogDetails); +LogDetails.displayName = 'LogDetails'; diff --git a/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx b/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx new file mode 100644 index 00000000000..fa439a291a8 --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx @@ -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(); + 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); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx b/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx new file mode 100644 index 00000000000..4803905a980 --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx @@ -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 { + 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 ( +
+ {/* Action buttons - show stats/filter results */} +
+ +
+ {isLabel ? ( +
this.filterLabel()} className={style.logsRowDetailsIcon}> + +
+ ) : ( +
+ )} + {isLabel ? ( +
this.filterOutLabel()} className={style.logsRowDetailsIcon}> + +
+ ) : ( +
+ )} + + {/* Key - value columns */} +
+ {parsedKey} +
+
+ {parsedValue} + {showFieldsStats && ( +
+ +
+ )} +
+
+ ); + } +} + +export const LogDetailsRow = withTheme(UnThemedLogDetailsRow); +LogDetailsRow.displayName = 'LogDetailsRow'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabel.tsx b/packages/grafana-ui/src/components/Logs/LogLabel.tsx index d91f6a2316d..9298b35f7a3 100644 --- a/packages/grafana-ui/src/components/Logs/LogLabel.tsx +++ b/packages/grafana-ui/src/components/Logs/LogLabel.tsx @@ -110,13 +110,7 @@ class UnThemedLogLabel extends PureComponent { )} {showStats && ( - + )} diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx index e842c2bbf69..bf970875a14 100644 --- a/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx +++ b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx @@ -1,48 +1,56 @@ import React, { PureComponent } from 'react'; -import { css, cx } from 'emotion'; -import { LogLabelStatsModel } from '@grafana/data'; +import { css } from 'emotion'; +import { LogLabelStatsModel, GrafanaTheme } from '@grafana/data'; -import { LogLabelStatsRow } from './LogLabelStatsRow'; import { Themeable } from '../../types/theme'; -import { GrafanaTheme } from '@grafana/data'; +import { stylesFactory } from '../../themes'; import { selectThemeVariant } from '../../themes/selectThemeVariant'; import { withTheme } from '../../themes/index'; +//Components +import { LogLabelStatsRow } from './LogLabelStatsRow'; + const STATS_ROW_LIMIT = 5; -const getStyles = (theme: GrafanaTheme) => ({ - logsStats: css` - label: logs-stats; - background-color: ${selectThemeVariant({ light: theme.colors.pageBg, dark: theme.colors.dark2 }, theme.type)}; - color: ${theme.colors.text}; - border: 1px solid ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)}; - border-radius: ${theme.border.radius.md}; - max-width: 500px; - `, - logsStatsHeader: css` - label: logs-stats__header; - background: ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)}; - padding: 6px 10px; - display: flex; - `, - logsStatsTitle: css` - label: logs-stats__title; - font-weight: ${theme.typography.weight.semibold}; - padding-right: ${theme.spacing.d}; - overflow: hidden; - 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: 20px 10px 10px 10px; - `, +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const borderColor = selectThemeVariant( + { + light: theme.colors.gray5, + dark: theme.colors.dark9, + }, + theme.type + ); + return { + logsStats: css` + label: logs-stats; + display: table-cell; + column-span: 2; + background: inherit; + color: ${theme.colors.text}; + `, + logsStatsHeader: css` + label: logs-stats__header; + border-bottom: 1px solid ${borderColor}; + display: flex; + `, + logsStatsTitle: css` + label: logs-stats__title; + font-weight: ${theme.typography.weight.semibold}; + padding-right: ${theme.spacing.d}; + 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 Themeable { @@ -50,12 +58,12 @@ interface Props extends Themeable { label: string; value: string; rowCount: number; - onClickClose: () => void; + isLabel: boolean; } class UnThemedLogLabelStats extends PureComponent { render() { - const { label, rowCount, stats, value, onClickClose, theme } = this.props; + 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); @@ -74,14 +82,13 @@ class UnThemedLogLabelStats extends PureComponent { const otherProportion = otherCount / total; return ( -
-
- - {label}: {total} of {rowCount} rows have that label - - +
+
+
+ {label}: {total} of {rowCount} rows have that {isLabel ? 'label' : 'field'} +
-
+
{topRows.map(stat => ( ))} diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx index 194380a7cb1..b78af42babd 100644 --- a/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx @@ -13,14 +13,6 @@ const getStyles = (theme: GrafanaTheme) => ({ label: logs-stats-row--active; color: ${theme.colors.blue}; position: relative; - - ::after { - display: inline; - content: '*'; - position: absolute; - top: 0; - left: -8px; - } `, logsStatsRowLabel: css` label: logs-stats-row__label; diff --git a/packages/grafana-ui/src/components/Logs/LogRow.tsx b/packages/grafana-ui/src/components/Logs/LogRow.tsx index 5206e62c970..1c12deaad10 100644 --- a/packages/grafana-ui/src/components/Logs/LogRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRow.tsx @@ -1,33 +1,37 @@ import React, { PureComponent } from 'react'; import { LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data'; -import { cx } from 'emotion'; + import { LogRowContextRows, LogRowContextQueryErrors, HasMoreContextRows, LogRowContextProvider, } from './LogRowContextProvider'; -import { LogLabels } from './LogLabels'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; import { getLogRowStyles } from './getLogRowStyles'; + +//Components +import { LogDetails } from './LogDetails'; import { LogRowMessage } from './LogRowMessage'; interface Props extends Themeable { highlighterExpressions?: string[]; row: LogRowModel; showDuplicates: boolean; - showLabels: boolean; showTime: boolean; timeZone: TimeZone; + isLogsPanel?: boolean; getRows: () => LogRowModel[]; - onClickLabel?: (label: string, value: string) => void; + onClickFilterLabel?: (key: string, value: string) => void; + onClickFilterOutLabel?: (key: string, value: string) => void; onContextClick?: () => void; getRowContext: (row: LogRowModel, options?: any) => Promise; } interface State { showContext: boolean; + showDetails: boolean; } /** @@ -40,6 +44,7 @@ interface State { class UnThemedLogRow extends PureComponent { state: State = { showContext: false, + showDetails: false, }; toggleContext = () => { @@ -50,6 +55,14 @@ class UnThemedLogRow extends PureComponent { }); }; + toggleDetails = () => { + this.setState(state => { + return { + showDetails: !state.showDetails, + }; + }); + }; + renderLogRow( context?: LogRowContextRows, errors?: LogRowContextQueryErrors, @@ -58,57 +71,66 @@ class UnThemedLogRow extends PureComponent { ) { const { getRows, + onClickFilterLabel, + onClickFilterOutLabel, highlighterExpressions, - onClickLabel, + isLogsPanel, row, showDuplicates, - showLabels, timeZone, showTime, theme, } = this.props; - const { showContext } = this.state; + const { showDetails, showContext } = this.state; const style = getLogRowStyles(theme, row.logLevel); const showUtc = timeZone === 'utc'; return ( -
+
{showDuplicates && ( -
+
{row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
)} -
- {showTime && showUtc && ( -
- {row.timeUtc} +
+ {!isLogsPanel && ( +
+
)} - {showTime && !showUtc && ( -
- {row.timeLocal} -
- )} - {showLabels && ( -
- +
+ {showTime && showUtc && ( +
+ {row.timeUtc} +
+ )} + {showTime && !showUtc && ( +
+ {row.timeLocal} +
+ )} +
- )} - + {this.state.showDetails && ( + + )} +
); } diff --git a/packages/grafana-ui/src/components/Logs/LogRowMessage.tsx b/packages/grafana-ui/src/components/Logs/LogRowMessage.tsx index e1f682c106e..733e3e9cd6a 100644 --- a/packages/grafana-ui/src/components/Logs/LogRowMessage.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRowMessage.tsx @@ -1,68 +1,35 @@ -import React, { PureComponent, FunctionComponent, useContext } from 'react'; +import React, { PureComponent } from 'react'; 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 { css, cx } from 'emotion'; -import { selectThemeVariant, ThemeContext } from '../../index'; -import { GrafanaTheme } from '@grafana/data'; +import { LogRowModel, findHighlightChunksInText, GrafanaTheme } from '@grafana/data'; + +// @ts-ignore +import Highlighter from 'react-highlight-words'; import { LogRowContextQueryErrors, HasMoreContextRows, LogRowContextRows } from './LogRowContextProvider'; -import { LogRowContext } from './LogRowContext'; -import { LogMessageAnsi } from './LogMessageAnsi'; -import { LogLabelStats } from './LogLabelStats'; +import { selectThemeVariant } from '../../index'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; import { getLogRowStyles } from './getLogRowStyles'; import { stylesFactory } from '../../themes/stylesFactory'; +//Components +import { LogRowContext } from './LogRowContext'; +import { LogMessageAnsi } from './LogMessageAnsi'; + interface Props extends Themeable { - highlighterExpressions?: string[]; row: LogRowModel; - getRows: () => LogRowModel[]; - errors?: LogRowContextQueryErrors; hasMoreContextRows?: HasMoreContextRows; - updateLimit?: () => void; - context?: LogRowContextRows; showContext: boolean; + errors?: LogRowContextQueryErrors; + context?: LogRowContextRows; + highlighterExpressions?: string[]; + getRows: () => LogRowModel[]; onToggleContext: () => void; + updateLimit?: () => void; } -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 => (props: any) => { - const theme = useContext(ThemeContext); - const style = getLogRowStyles(theme); - return ( - - {props.children} - onClick(props.children)} - /> - - ); -}; +interface State {} const getStyles = stylesFactory((theme: GrafanaTheme) => { const outlineColor = selectThemeVariant( @@ -86,98 +53,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { .setAlpha(0.7) .toRgbString()}; `, + whiteSpacePreWrap: css` + label: whiteSpacePreWrap; + white-space: pre-wrap; + `, }; }); class UnThemedLogRowMessage extends PureComponent { - 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) => { e.stopPropagation(); this.props.onToggleContext(); @@ -195,17 +78,10 @@ class UnThemedLogRowMessage extends PureComponent { showContext, onToggleContext, } = this.props; - const { - fieldCount, - fieldLabel, - fieldStats, - fieldValue, - parsed, - parsedFieldHighlights, - showFieldStats, - } = this.state; + const {} = this.state; const style = getLogRowStyles(theme, row.logLevel); const { entry, hasAnsi, raw } = row; + const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords); const highlights = previewHighlights ? highlighterExpressions : row.searchWords; const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0; @@ -213,13 +89,8 @@ class UnThemedLogRowMessage extends PureComponent { ? cx([style.logsRowMatchHighLight, style.logsRowMatchHighLightPreview]) : cx([style.logsRowMatchHighLight]); const styles = getStyles(theme); - return ( -
+
{showContext && context && ( { /> )} - {parsed && ( + {needsHighlighter ? ( - )} - {!parsed && needsHighlighter && ( - - )} - {hasAnsi && !parsed && !needsHighlighter && } - {!hasAnsi && !parsed && !needsHighlighter && entry} - {showFieldStats && ( -
- -
+ ) : hasAnsi ? ( + + ) : ( + entry )}
{row.searchWords && row.searchWords.length > 0 && ( diff --git a/packages/grafana-ui/src/components/Logs/LogRows.test.tsx b/packages/grafana-ui/src/components/Logs/LogRows.test.tsx index 906aff2023f..05b082af02d 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.test.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.test.tsx @@ -17,7 +17,6 @@ describe('LogRows', () => { dedupStrategy={LogsDedupStrategy.none} highlighterExpressions={[]} showTime={false} - showLabels={false} timeZone={'utc'} /> ); @@ -40,7 +39,6 @@ describe('LogRows', () => { dedupStrategy={LogsDedupStrategy.none} highlighterExpressions={[]} showTime={false} - showLabels={false} timeZone={'utc'} previewLimit={1} /> @@ -75,7 +73,6 @@ describe('LogRows', () => { dedupStrategy={LogsDedupStrategy.none} highlighterExpressions={[]} showTime={false} - showLabels={false} timeZone={'utc'} /> ); @@ -97,7 +94,6 @@ describe('LogRows', () => { dedupStrategy={LogsDedupStrategy.none} highlighterExpressions={[]} showTime={false} - showLabels={false} timeZone={'utc'} /> ); @@ -121,6 +117,7 @@ const makeLog = (overides: Partial): LogRowModel => { timeEpochMs: 1, timeLocal: '', timeUtc: '', + searchWords: [], ...overides, }; }; diff --git a/packages/grafana-ui/src/components/Logs/LogRows.tsx b/packages/grafana-ui/src/components/Logs/LogRows.tsx index 66a427e7889..b493dcb5282 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.tsx @@ -1,12 +1,13 @@ import React, { PureComponent } from 'react'; -import { cx } from 'emotion'; +import memoizeOne from 'memoize-one'; import { LogsModel, TimeZone, LogsDedupStrategy, LogRowModel } from '@grafana/data'; -import { LogRow } from './LogRow'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; import { getLogRowStyles } from './getLogRowStyles'; -import memoizeOne from 'memoize-one'; + +//Components +import { LogRow } from './LogRow'; export const PREVIEW_LIMIT = 100; export const RENDER_LIMIT = 500; @@ -16,13 +17,14 @@ export interface Props extends Themeable { dedupStrategy: LogsDedupStrategy; highlighterExpressions: string[]; showTime: boolean; - showLabels: boolean; timeZone: TimeZone; deduplicatedData?: LogsModel; - onClickLabel?: (label: string, value: string) => void; - getRowContext?: (row: LogRowModel, options?: any) => Promise; rowLimit?: number; + isLogsPanel?: boolean; previewLimit?: number; + onClickFilterLabel?: (key: string, value: string) => void; + onClickFilterOutLabel?: (key: string, value: string) => void; + getRowContext?: (row: LogRowModel, options?: any) => Promise; } interface State { @@ -71,17 +73,17 @@ class UnThemedLogRows extends PureComponent { data, deduplicatedData, highlighterExpressions, - showLabels, timeZone, - onClickLabel, + onClickFilterLabel, + onClickFilterOutLabel, rowLimit, theme, + isLogsPanel, previewLimit, } = this.props; const { renderAll } = this.state; const dedupedData = deduplicatedData ? deduplicatedData : data; const hasData = data && data.rows && data.rows.length > 0; - const hasLabel = hasData && dedupedData && dedupedData.hasUniqueLabels ? true : false; const dedupCount = dedupedData ? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0) : 0; @@ -99,7 +101,7 @@ class UnThemedLogRows extends PureComponent { const { logsRows } = getLogRowStyles(theme); return ( -
+
{hasData && firstRows.map((row, index) => ( { highlighterExpressions={highlighterExpressions} row={row} showDuplicates={showDuplicates} - showLabels={showLabels && hasLabel} showTime={showTime} timeZone={timeZone} - onClickLabel={onClickLabel} + isLogsPanel={isLogsPanel} + onClickFilterLabel={onClickFilterLabel} + onClickFilterOutLabel={onClickFilterOutLabel} /> ))} {hasData && @@ -124,10 +127,11 @@ class UnThemedLogRows extends PureComponent { getRowContext={getRowContext} row={row} showDuplicates={showDuplicates} - showLabels={showLabels && hasLabel} showTime={showTime} timeZone={timeZone} - onClickLabel={onClickLabel} + isLogsPanel={isLogsPanel} + onClickFilterLabel={onClickFilterLabel} + onClickFilterOutLabel={onClickFilterOutLabel} /> ))} {hasData && !renderAll && Rendering {rowCount - previewLimit!} rows...} diff --git a/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts index d8a698515d5..bfe5c8f2b7e 100644 --- a/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts +++ b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts @@ -7,6 +7,7 @@ import { stylesFactory } from '../../themes'; export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: LogLevel) => { 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) { case LogLevel.crit: case LogLevel.critical: @@ -32,34 +33,13 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo } 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` label: logs-row__match-highlight; background: inherit; padding: inherit; 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); `, logsRowMatchHighLightPreview: css` @@ -81,9 +61,9 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo > div { display: table-cell; - padding-right: 10px; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; + padding-right: ${theme.spacing.sm}; + border-top: ${theme.border.width.sm} solid transparent; + border-bottom: ${theme.border.width.sm} solid transparent; height: 100%; } @@ -111,24 +91,87 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo 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` label: logs-row__localtime; + display: table-cell; white-space: nowrap; width: 12.5em; `, - logsRowLabels: css` - label: logs-row__labels; - width: 20%; - line-height: 1.2; - position: relative; - `, logsRowMessage: css` label: logs-row__message; word-break: break-all; + display: table-cell; `, logsRowStats: css` label: logs-row__stats; 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}; + } + `, }; }); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index cf62bf31fb2..67d06d087e3 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -192,10 +192,14 @@ export class Explore extends React.PureComponent { this.props.setQueries(this.props.exploreId, [query]); }; - onClickLabel = (key: string, value: string) => { + onClickFilterLabel = (key: string, value: string) => { 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) => { const { datasourceInstance } = this.props; if (datasourceInstance && datasourceInstance.modifyQuery) { @@ -307,14 +311,15 @@ export class Explore extends React.PureComponent { /> )} {mode === ExploreMode.Metrics && ( - + )} {mode === ExploreMode.Logs && ( diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index b101ae05c4d..d2b68a5c7c1 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -39,7 +39,8 @@ interface Props { scanRange?: RawTimeRange; dedupStrategy: LogsDedupStrategy; onChangeTime: (range: AbsoluteTimeRange) => void; - onClickLabel?: (label: string, value: string) => void; + onClickFilterLabel?: (key: string, value: string) => void; + onClickFilterOutLabel?: (key: string, value: string) => void; onStartScanning?: () => void; onStopScanning?: () => void; onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void; @@ -48,13 +49,11 @@ interface Props { } interface State { - showLabels: boolean; showTime: boolean; } export class Logs extends PureComponent { state = { - showLabels: false, showTime: true, }; @@ -66,15 +65,6 @@ export class Logs extends PureComponent { 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) => { const target = event && (event.target as HTMLInputElement); if (target) { @@ -108,7 +98,8 @@ export class Logs extends PureComponent { data, highlighterExpressions, loading = false, - onClickLabel, + onClickFilterLabel, + onClickFilterOutLabel, timeZone, scanning, scanRange, @@ -122,7 +113,7 @@ export class Logs extends PureComponent { return null; } - const { showLabels, showTime } = this.state; + const { showTime } = this.state; const { dedupStrategy } = this.props; const hasData = data && data.rows && data.rows.length > 0; const dedupCount = dedupedData @@ -163,7 +154,6 @@ export class Logs extends PureComponent {
- {Object.keys(LogsDedupStrategy).map((dedupType: string, i) => ( { dedupStrategy={dedupStrategy} getRowContext={this.props.getRowContext} highlighterExpressions={highlighterExpressions} - onClickLabel={onClickLabel} + onClickFilterLabel={onClickFilterLabel} + onClickFilterOutLabel={onClickFilterOutLabel} rowLimit={data ? data.rows.length : undefined} - showLabels={showLabels} showTime={showTime} timeZone={timeZone} /> diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 5aba50a1e8b..6c41b14fbfd 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -35,7 +35,8 @@ interface LogsContainerProps { logsHighlighterExpressions?: string[]; logsResult?: LogsModel; dedupedResult?: LogsModel; - onClickLabel: (key: string, value: string) => void; + onClickFilterLabel?: (key: string, value: string) => void; + onClickFilterOutLabel?: (key: string, value: string) => void; onStartScanning: () => void; onStopScanning: () => void; timeZone: TimeZone; @@ -87,7 +88,8 @@ export class LogsContainer extends PureComponent { logsHighlighterExpressions, logsResult, dedupedResult, - onClickLabel, + onClickFilterLabel, + onClickFilterOutLabel, onStartScanning, onStopScanning, absoluteRange, @@ -126,7 +128,8 @@ export class LogsContainer extends PureComponent { highlighterExpressions={logsHighlighterExpressions} loading={loading} onChangeTime={this.onChangeTime} - onClickLabel={onClickLabel} + onClickFilterLabel={onClickFilterLabel} + onClickFilterOutLabel={onClickFilterOutLabel} onStartScanning={onStartScanning} onStopScanning={onStopScanning} onDedupStrategyChange={this.handleDedupStrategyChange} diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index de65c148038..bbffe4b5615 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -262,6 +262,10 @@ export class LokiDatasource extends DataSourceApi { selector = addLabelToSelector(selector, action.key, action.value); break; } + case 'ADD_FILTER_OUT': { + selector = addLabelToSelector(selector, action.key, action.value, '!='); + break; + } default: break; } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index c926fef69ed..a4f0527077f 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -31,8 +31,8 @@ export const LogsPanel: React.FunctionComponent = ({ dedupStrategy={LogsDedupStrategy.none} highlighterExpressions={[]} showTime={showTime} - showLabels={false} timeZone={timeZone} + isLogsPanel={true} /> );