diff --git a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json index b89900d057d..674a55404c0 100644 --- a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json +++ b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json @@ -28,11 +28,7 @@ "value": "triggered" } ], - "colors": [ - "#299c46", - "rgba(237, 129, 40, 0.89)", - "#d44a3a" - ], + "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], "d3DivId": "d3_svg_4", "datasource": "gdev-testdata", "decimals": 2, @@ -115,11 +111,7 @@ }, "id": 4, "links": [], - "notcolors": [ - "rgba(245, 54, 54, 0.9)", - "rgba(237, 129, 40, 0.89)", - "rgba(50, 172, 45, 0.97)" - ], + "notcolors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], "operatorName": "avg", "operatorOptions": [ { @@ -1114,11 +1106,7 @@ "value": "triggered" } ], - "colors": [ - "#299c46", - "rgba(237, 129, 40, 0.89)", - "#d44a3a" - ], + "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], "d3DivId": "d3_svg_5", "datasource": "gdev-testdata", "decimals": 2, @@ -1201,11 +1189,7 @@ }, "id": 5, "links": [], - "notcolors": [ - "rgba(245, 54, 54, 0.9)", - "rgba(237, 129, 40, 0.89)", - "rgba(50, 172, 45, 0.97)" - ], + "notcolors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], "operatorName": "avg", "operatorOptions": [ { @@ -2221,11 +2205,7 @@ "value": "triggered" } ], - "colors": [ - "#299c46", - "rgba(237, 129, 40, 0.89)", - "#d44a3a" - ], + "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], "d3DivId": "d3_svg_2", "datasource": "gdev-testdata", "decimals": 2, @@ -2308,11 +2288,7 @@ }, "id": 2, "links": [], - "notcolors": [ - "rgba(245, 54, 54, 0.9)", - "rgba(237, 129, 40, 0.89)", - "rgba(50, 172, 45, 0.97)" - ], + "notcolors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], "operatorName": "avg", "operatorOptions": [ { @@ -3300,10 +3276,7 @@ ], "schemaVersion": 16, "style": "dark", - "tags": [ - "panel-test", - "gdev" - ], + "tags": ["panel-test", "gdev"], "templating": { "list": [] }, @@ -3312,29 +3285,8 @@ "to": "now" }, "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] }, "timezone": "", "title": "Panel Tests - Polystat", diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 542fff7894e..dee4d8cd703 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -17,7 +17,7 @@ export enum FieldType { /** * Every property is optional * - * Plugins may extend this with additional properties. Somethign like series overrides + * Plugins may extend this with additional properties. Something like series overrides */ export interface FieldConfig { title?: string; // The display value for this field. This supports template variables blank is auto diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index d02811a5228..90c0bf31be0 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -1,5 +1,6 @@ import { Labels } from './data'; import { GraphSeriesXY } from './graph'; +import { DataFrame } from './dataFrame'; /** * Mapping of log level abbreviation to canonical log level. @@ -36,7 +37,19 @@ export interface LogsMetaItem { } export interface LogRowModel { + // Index of the field from which the entry has been created so that we do not show it later in log row details. + entryFieldIndex: number; + + // Index of the row in the dataframe. As log rows can be stitched from multiple dataFrames, this does not have to be + // the same as rows final index when rendered. + rowIndex: number; + + // Full DataFrame from which we parsed this log. + // TODO: refactor this so we do not need to pass whole dataframes in addition to also parsed data. + dataFrame: DataFrame; duplicates?: number; + + // Actual log line entry: string; hasAnsi: boolean; labels: Labels; diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts index d5778647891..0f5c4e6e2a2 100644 --- a/packages/grafana-data/src/utils/logs.test.ts +++ b/packages/grafana-data/src/utils/logs.test.ts @@ -1,5 +1,12 @@ import { LogLevel } from '../types/logs'; -import { getLogLevel, calculateLogsLabelStats, calculateFieldStats, getParser, LogsParsers } from './logs'; +import { + getLogLevel, + calculateLogsLabelStats, + calculateFieldStats, + getParser, + LogsParsers, + calculateStats, +} from './logs'; describe('getLoglevel()', () => { it('returns no log level on empty line', () => { @@ -208,6 +215,28 @@ describe('calculateFieldStats()', () => { }); }); +describe('calculateStats()', () => { + test('should return no stats for empty array', () => { + expect(calculateStats([])).toEqual([]); + }); + + test('should return correct stats', () => { + const values = ['one', 'one', null, undefined, 'two']; + expect(calculateStats(values)).toMatchObject([ + { + value: 'one', + count: 2, + proportion: 2 / 3, + }, + { + value: 'two', + count: 1, + proportion: 1 / 3, + }, + ]); + }); +}); + describe('getParser()', () => { test('should return no parser on empty line', () => { expect(getParser('')).toBeUndefined(); diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index d0c60d72134..3047ead11e8 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -63,22 +63,6 @@ export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataF }; } -export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { - // Consider only rows that have the given label - const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); - const rowCount = rowsWithLabel.length; - - // Get label value counts for eligible rows - const countsByValue = countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]); - const sortedCounts = chain(countsByValue) - .map((count, value) => ({ count, value, proportion: count / rowCount })) - .sortBy('count') - .reverse() - .value(); - - return sortedCounts; -} - export const LogsParsers: { [name: string]: LogsParser } = { JSON: { buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), @@ -128,14 +112,32 @@ export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): Log return match ? match[1] : null; }); - const sortedCounts = chain(countsByValue) + return getSortedCounts(countsByValue, rowCount); +} + +export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]); + return getSortedCounts(countsByValue, rowCount); +} + +export function calculateStats(values: any[]): LogLabelStatsModel[] { + const nonEmptyValues = values.filter(value => value !== undefined && value !== null); + const countsByValue = countBy(nonEmptyValues); + return getSortedCounts(countsByValue, nonEmptyValues.length); +} + +const getSortedCounts = (countsByValue: { [value: string]: number }, rowCount: number) => { + return chain(countsByValue) .map((count, value) => ({ count, value, proportion: count / rowCount })) .sortBy('count') .reverse() .value(); - - return sortedCounts; -} +}; export function getParser(line: string): LogsParser | undefined { let parser; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index a5cd3e0f63e..82d4a6892cc 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -22,6 +22,7 @@ interface DataLinkInputProps { value: string; onChange: (url: string, callback?: () => void) => void; suggestions: VariableSuggestion[]; + placeholder?: string; } const plugins = [ @@ -44,128 +45,130 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ // This memoised also because rerendering the slate editor grabs focus which created problem in some cases this // was used and changes to different state were propagated here. -export const DataLinkInput: React.FC = memo(({ value, onChange, suggestions }) => { - const editorRef = useRef() as RefObject; - const theme = useContext(ThemeContext); - const styles = getStyles(theme); - const [showingSuggestions, setShowingSuggestions] = useState(false); - const [suggestionsIndex, setSuggestionsIndex] = useState(0); - const [linkUrl, setLinkUrl] = useState(makeValue(value)); - const prevLinkUrl = usePrevious(linkUrl); +export const DataLinkInput: React.FC = memo( + ({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => { + const editorRef = useRef() as RefObject; + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + const [showingSuggestions, setShowingSuggestions] = useState(false); + const [suggestionsIndex, setSuggestionsIndex] = useState(0); + const [linkUrl, setLinkUrl] = useState(makeValue(value)); + const prevLinkUrl = usePrevious(linkUrl); - // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 - const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); - stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; + // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 + const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); + stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; - // SelectionReference is used to position the variables suggestion relatively to current DOM selection - const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions, linkUrl]); + // SelectionReference is used to position the variables suggestion relatively to current DOM selection + const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions, linkUrl]); - const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => { - if (!stateRef.current.showingSuggestions) { - if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { - return setShowingSuggestions(true); - } - return next(); - } - - switch (event.key) { - case 'Backspace': - case 'Escape': - setShowingSuggestions(false); - return setSuggestionsIndex(0); - - case 'Enter': - event.preventDefault(); - return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]); - - case 'ArrowDown': - case 'ArrowUp': - event.preventDefault(); - const direction = event.key === 'ArrowDown' ? 1 : -1; - return setSuggestionsIndex(index => modulo(index + direction, stateRef.current.suggestions.length)); - default: + const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => { + if (!stateRef.current.showingSuggestions) { + if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { + return setShowingSuggestions(true); + } return next(); - } - }, []); + } - useEffect(() => { - // Update the state of the link in the parent. This is basically done on blur but we need to do it after - // our state have been updated. The duplicity of state is done for perf reasons and also because local - // state also contains things like selection and formating. - if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) { - stateRef.current.onChange(Plain.serialize(linkUrl)); - } - }, [linkUrl, prevLinkUrl]); + switch (event.key) { + case 'Backspace': + case 'Escape': + setShowingSuggestions(false); + return setSuggestionsIndex(0); - const onUrlChange = React.useCallback(({ value }: { value: Value }) => { - setLinkUrl(value); - }, []); + case 'Enter': + event.preventDefault(); + return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]); - const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => { - const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$'; - if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { - editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); - } else { - editor.insertText(`var-${item.value}=$\{${item.value}}`); - } + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + const direction = event.key === 'ArrowDown' ? 1 : -1; + return setSuggestionsIndex(index => modulo(index + direction, stateRef.current.suggestions.length)); + default: + return next(); + } + }, []); - setLinkUrl(editor.value); - setShowingSuggestions(false); + useEffect(() => { + // Update the state of the link in the parent. This is basically done on blur but we need to do it after + // our state have been updated. The duplicity of state is done for perf reasons and also because local + // state also contains things like selection and formating. + if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) { + stateRef.current.onChange(Plain.serialize(linkUrl)); + } + }, [linkUrl, prevLinkUrl]); - setSuggestionsIndex(0); - onChange(Plain.serialize(editor.value)); - }; + const onUrlChange = React.useCallback(({ value }: { value: Value }) => { + setLinkUrl(value); + }, []); - return ( -
-
- {showingSuggestions && ( - - - {({ ref, style, placement }) => { - return ( -
- setShowingSuggestions(false)} - activeIndex={suggestionsIndex} - /> -
- ); - }} -
-
+ const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => { + const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$'; + if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { + editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); + } else { + editor.insertText(`var-${item.value}=$\{${item.value}}`); + } + + setLinkUrl(editor.value); + setShowingSuggestions(false); + + setSuggestionsIndex(0); + stateRef.current.onChange(Plain.serialize(editor.value)); + }; + + return ( +
onKeyDown(event as KeyboardEvent, next)} - plugins={plugins} - className={styles.editor} - /> + > +
+ {showingSuggestions && ( + + + {({ ref, style, placement }) => { + return ( +
+ setShowingSuggestions(false)} + activeIndex={suggestionsIndex} + /> +
+ ); + }} +
+
+ )} + onKeyDown(event as KeyboardEvent, next)} + plugins={plugins} + className={styles.editor} + /> +
-
- ); -}); + ); + } +); DataLinkInput.displayName = 'DataLinkInput'; diff --git a/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx b/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx index b37bc83181d..545c824d478 100644 --- a/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx +++ b/packages/grafana-ui/src/components/Logs/LogDetails.test.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { LogDetails, Props } from './LogDetails'; -import { LogRowModel, LogLevel, GrafanaTheme } from '@grafana/data'; +import { LogRowModel, LogLevel, GrafanaTheme, MutableDataFrame, Field } from '@grafana/data'; import { mount } from 'enzyme'; +import { LogDetailsRow } from './LogDetailsRow'; -const setup = (propOverrides?: object) => { +const setup = (propOverrides?: Partial, rowOverrides?: Partial) => { const props: Props = { theme: {} as GrafanaTheme, row: { + dataFrame: new MutableDataFrame(), + entryFieldIndex: 0, + rowIndex: 0, logLevel: 'error' as LogLevel, timeFromNow: '', timeEpochMs: 1546297200000, @@ -17,72 +21,102 @@ const setup = (propOverrides?: object) => { raw: '', timestamp: '', uid: '0', - } as LogRowModel, + labels: {}, + ...(rowOverrides || {}), + }, getRows: () => [], onClickFilterLabel: () => {}, onClickFilterOutLabel: () => {}, + ...(propOverrides || {}), }; - Object.assign(props, propOverrides); - - const wrapper = mount(); - return wrapper; + return mount(); }; describe('LogDetails', () => { describe('when labels are present', () => { it('should render heading', () => { - const wrapper = setup({ row: { labels: { key1: 'label1', key2: 'label2' } } }); + const wrapper = setup(undefined, { 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); - }); }); + it('should render labels', () => { + const wrapper = setup(undefined, { 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(undefined, { entry: 'test=successful' }); + expect(wrapper.find({ 'aria-label': 'Parsed fields' })).toHaveLength(1); + }); + it('should render parsed fields', () => { + const wrapper = setup(undefined, { entry: 'test=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(undefined, { 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(undefined, { + entry: 'test=successful', + labels: { key: 'label' }, + }); + 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(undefined, { entry: '' }); + expect(wrapper.text().includes('No details available')).toBe(true); + }); + it('should not render headings', () => { + const wrapper = setup(undefined, { entry: '' }); + expect(wrapper.find({ 'aria-label': 'Log labels' })).toHaveLength(0); + expect(wrapper.find({ 'aria-label': 'Parsed fields' })).toHaveLength(0); + }); + }); + + it('should render fields from dataframe with links', () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + const wrapper = setup( + { + getFieldLinks: (field: Field, rowIndex: number) => { + if (field.config && field.config.links) { + return field.config.links.map(link => { + return { + href: link.url.replace('${__value.text}', field.values.get(rowIndex)), + title: link.title, + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + }, + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 } + ); + expect(wrapper.find(LogDetailsRow).length).toBe(3); + const traceIdRow = wrapper.find(LogDetailsRow).filter({ parsedKey: 'traceId' }); + expect(traceIdRow.length).toBe(1); + expect(traceIdRow.find('a').length).toBe(1); + expect((traceIdRow.find('a').getDOMNode() as HTMLAnchorElement).href).toBe('localhost:3210/1234'); + }); }); diff --git a/packages/grafana-ui/src/components/Logs/LogDetails.tsx b/packages/grafana-ui/src/components/Logs/LogDetails.tsx index 39c4bdef6bf..77ef609f24f 100644 --- a/packages/grafana-ui/src/components/Logs/LogDetails.tsx +++ b/packages/grafana-ui/src/components/Logs/LogDetails.tsx @@ -1,6 +1,14 @@ import React, { PureComponent } from 'react'; import memoizeOne from 'memoize-one'; -import { getParser, LogRowModel, LogsParser } from '@grafana/data'; +import { + calculateFieldStats, + calculateLogsLabelStats, + calculateStats, + Field, + getParser, + LinkModel, + LogRowModel, +} from '@grafana/data'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; @@ -9,33 +17,106 @@ import { getLogRowStyles } from './getLogRowStyles'; //Components import { LogDetailsRow } from './LogDetailsRow'; +type FieldDef = { + key: string; + value: string; + links?: string[]; + fieldIndex?: number; +}; + export interface Props extends Themeable { row: LogRowModel; getRows: () => LogRowModel[]; onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; + getFieldLinks?: (field: Field, rowIndex: number) => Array>; } class UnThemedLogDetails extends PureComponent { + getParser = memoizeOne(getParser); + parseMessage = memoizeOne( - (rowEntry): { parsedFields: string[]; parser?: LogsParser } => { - const parser = getParser(rowEntry); + (rowEntry): FieldDef[] => { + const parser = this.getParser(rowEntry); if (!parser) { - return { parsedFields: [] }; + return []; } // Use parser to highlight detected fields const parsedFields = parser.getFields(rowEntry); - return { parsedFields, parser }; + const fields = parsedFields.map(field => { + const key = parser.getLabelFromField(field); + const value = parser.getValueFromField(field); + return { key, value }; + }); + + return fields; } ); + getDerivedFields = memoizeOne( + (row: LogRowModel): FieldDef[] => { + return ( + row.dataFrame.fields + .map((field, index) => ({ ...field, index })) + // Remove Id which we use for react key and entry field which we are showing as the log message. + .filter((field, index) => 'id' !== field.name && row.entryFieldIndex !== index) + // Filter out fields without values. For example in elastic the fields are parsed from the document which can + // have different structure per row and so the dataframe is pretty sparse. + .filter(field => { + const value = field.values.get(row.rowIndex); + // Not sure exactly what will be the empty value here. And we want to keep 0 as some values can be non + // string. + return value !== null && value !== undefined; + }) + .map(field => { + const { getFieldLinks } = this.props; + const links = getFieldLinks ? getFieldLinks(field, row.rowIndex) : []; + return { + key: field.name, + value: field.values.get(row.rowIndex).toString(), + links: links.map(link => link.href), + fieldIndex: field.index, + }; + }) + ); + } + ); + + getAllFields = memoizeOne((row: LogRowModel) => { + const fields = this.parseMessage(row.entry); + const derivedFields = this.getDerivedFields(row); + const fieldsMap = [...derivedFields, ...fields].reduce( + (acc, field) => { + // Strip enclosing quotes for hashing. When values are parsed from log line the quotes are kept, but if same + // value is in the dataFrame it will be without the quotes. We treat them here as the same value. + const value = field.value.replace(/(^")|("$)/g, ''); + const fieldHash = `${field.key}=${value}`; + if (acc[fieldHash]) { + acc[fieldHash].links = [...(acc[fieldHash].links || []), ...(field.links || [])]; + } else { + acc[fieldHash] = field; + } + return acc; + }, + {} as { [key: string]: FieldDef } + ); + return Object.values(fieldsMap); + }); + + getStatsForParsedField = (key: string) => { + const matcher = this.getParser(this.props.row.entry)!.buildMatcher(key); + return calculateFieldStats(this.props.getRows(), matcher); + }; + 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; + + const fields = this.getAllFields(row); + + const parsedFieldsAvailable = fields && fields.length > 0; return (
@@ -46,16 +127,13 @@ class UnThemedLogDetails extends PureComponent {
{Object.keys(labels).map(key => { const value = labels[key]; - const field = `${key}=${value}`; return ( calculateLogsLabelStats(getRows(), key)} onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterLabel={onClickFilterLabel} /> @@ -69,23 +147,22 @@ class UnThemedLogDetails extends PureComponent {
Parsed fields:
- {parsedFields && - parsedFields.map(field => { - const key = parser!.getLabelFromField(field); - const value = parser!.getValueFromField(field); - return ( - - ); - })} + {fields.map(field => { + const { key, value, links, fieldIndex } = field; + return ( + + fieldIndex === undefined + ? this.getStatsForParsedField(key) + : calculateStats(row.dataFrame.fields[fieldIndex].values.toArray()) + } + /> + ); + })}
)} {!parsedFieldsAvailable && !labelsAvailable &&
No details available
} diff --git a/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx b/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx index fa439a291a8..038ece69090 100644 --- a/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx +++ b/packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx @@ -1,18 +1,16 @@ import React from 'react'; import { LogDetailsRow, Props } from './LogDetailsRow'; -import { LogRowModel, LogsParser, GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme } from '@grafana/data'; import { mount } from 'enzyme'; +import { LogLabelStats } from './LogLabelStats'; -const setup = (propOverrides?: object) => { +const setup = (propOverrides?: Partial) => { const props: Props = { theme: {} as GrafanaTheme, parsedValue: '', parsedKey: '', - field: '', isLabel: true, - parser: {} as LogsParser, - row: {} as LogRowModel, - getRows: () => [], + getStats: () => null, onClickFilterLabel: () => {}, onClickFilterOutLabel: () => {}, }; @@ -27,11 +25,11 @@ 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 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); @@ -40,10 +38,36 @@ describe('LogDetailsRow', () => { 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); - }); + }); + it('should render filter out label button', () => { + const wrapper = setup(); + expect(wrapper.find('i.fa-search-minus')).toHaveLength(1); + }); + }); + + it('should render stats when stats icon is clicked', () => { + const wrapper = setup({ + parsedKey: 'key', + parsedValue: 'value', + getStats: () => { + return [ + { + count: 1, + proportion: 1 / 2, + value: 'value', + }, + { + count: 1, + proportion: 1 / 2, + value: 'another value', + }, + ]; + }, + }); + + expect(wrapper.find(LogLabelStats).length).toBe(0); + wrapper.find('[aria-label="Field stats"]').simulate('click'); + expect(wrapper.find(LogLabelStats).length).toBe(1); + expect(wrapper.find(LogLabelStats).contains('another value')).toBeTruthy(); }); }); diff --git a/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx b/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx index 4803905a980..f6d926d30d1 100644 --- a/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx @@ -1,11 +1,5 @@ import React, { PureComponent } from 'react'; -import { - LogRowModel, - LogsParser, - LogLabelStatsModel, - calculateFieldStats, - calculateLogsLabelStats, -} from '@grafana/data'; +import { LogLabelStatsModel } from '@grafana/data'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; @@ -17,30 +11,24 @@ import { LogLabelStats } from './LogLabelStats'; export interface Props extends Themeable { parsedValue: string; parsedKey: string; - field: string; - row: LogRowModel; - isLabel: boolean; - parser?: LogsParser; - getRows: () => LogRowModel[]; + isLabel?: boolean; onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; + links?: string[]; + getStats: () => LogLabelStatsModel[] | null; } 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 = () => { @@ -60,7 +48,9 @@ class UnThemedLogDetailsRow extends PureComponent { showStats = () => { const { showFieldsStats } = this.state; if (!showFieldsStats) { - this.createStatsForLabels(); + const fieldStats = this.props.getStats(); + const fieldCount = fieldStats ? fieldStats.reduce((sum, stat) => sum + stat.count, 0) : 0; + this.setState({ fieldStats, fieldCount }); } this.toggleFieldsStats(); }; @@ -73,30 +63,14 @@ class UnThemedLogDetailsRow extends PureComponent { }); } - 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 { theme, parsedKey, parsedValue, isLabel, links } = this.props; + const { showFieldsStats, fieldStats, fieldCount } = this.state; const style = getLogRowStyles(theme); return (
{/* Action buttons - show stats/filter results */} -
+
{isLabel ? ( @@ -120,12 +94,23 @@ class UnThemedLogDetailsRow extends PureComponent {
{parsedValue} + {links && + links.map(link => { + return ( + +   + + + + + ); + })} {showFieldsStats && (
diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx index bf970875a14..e719b052a04 100644 --- a/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx +++ b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx @@ -58,7 +58,7 @@ interface Props extends Themeable { label: string; value: string; rowCount: number; - isLabel: boolean; + isLabel?: boolean; } class UnThemedLogLabelStats extends PureComponent { diff --git a/packages/grafana-ui/src/components/Logs/LogRow.tsx b/packages/grafana-ui/src/components/Logs/LogRow.tsx index 1c12deaad10..e80828945bc 100644 --- a/packages/grafana-ui/src/components/Logs/LogRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRow.tsx @@ -1,5 +1,5 @@ import React, { PureComponent } from 'react'; -import { LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data'; +import { Field, LinkModel, LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data'; import { LogRowContextRows, @@ -27,6 +27,7 @@ interface Props extends Themeable { onClickFilterOutLabel?: (key: string, value: string) => void; onContextClick?: () => void; getRowContext: (row: LogRowModel, options?: any) => Promise; + getFieldLinks?: (field: Field, rowIndex: number) => Array>; } interface State { @@ -80,6 +81,7 @@ class UnThemedLogRow extends PureComponent { timeZone, showTime, theme, + getFieldLinks, } = this.props; const { showDetails, showContext } = this.state; const style = getLogRowStyles(theme, row.logLevel); @@ -124,6 +126,7 @@ class UnThemedLogRow extends PureComponent {
{this.state.showDetails && ( { ], }); const row: LogRowModel = { + entryFieldIndex: 0, + rowIndex: 0, + dataFrame: new MutableDataFrame(), entry: '4', labels: (null as any) as Labels, hasAnsi: false, @@ -54,6 +57,9 @@ describe('getRowContexts', () => { const firstError = new Error('Error 1'); const secondError = new Error('Error 2'); const row: LogRowModel = { + entryFieldIndex: 0, + rowIndex: 0, + dataFrame: new MutableDataFrame(), entry: '4', labels: (null as any) as Labels, hasAnsi: false, diff --git a/packages/grafana-ui/src/components/Logs/LogRows.test.tsx b/packages/grafana-ui/src/components/Logs/LogRows.test.tsx index 7956d1e02f8..e88eb2b78d0 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.test.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { range } from 'lodash'; import { LogRows, PREVIEW_LIMIT } from './LogRows'; import { mount } from 'enzyme'; -import { LogLevel, LogRowModel, LogsDedupStrategy } from '@grafana/data'; +import { LogLevel, LogRowModel, LogsDedupStrategy, MutableDataFrame } from '@grafana/data'; import { LogRow } from './LogRow'; describe('LogRows', () => { @@ -87,10 +87,14 @@ describe('LogRows', () => { }); }); -const makeLog = (overides: Partial): LogRowModel => { - const uid = overides.uid || '1'; +const makeLog = (overrides: Partial): LogRowModel => { + const uid = overrides.uid || '1'; const entry = `log message ${uid}`; return { + entryFieldIndex: 0, + rowIndex: 0, + // Does not need to be filled with current tests + dataFrame: new MutableDataFrame(), uid, logLevel: LogLevel.debug, entry, @@ -103,6 +107,6 @@ const makeLog = (overides: Partial): LogRowModel => { timeLocal: '', timeUtc: '', searchWords: [], - ...overides, + ...overrides, }; }; diff --git a/packages/grafana-ui/src/components/Logs/LogRows.tsx b/packages/grafana-ui/src/components/Logs/LogRows.tsx index 750d12480b8..81c5d4d62a3 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.tsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import memoizeOne from 'memoize-one'; -import { TimeZone, LogsDedupStrategy, LogRowModel } from '@grafana/data'; +import { TimeZone, LogsDedupStrategy, LogRowModel, Field, LinkModel } from '@grafana/data'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; @@ -25,6 +25,7 @@ export interface Props extends Themeable { onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; getRowContext?: (row: LogRowModel, options?: any) => Promise; + getFieldLinks?: (field: Field, rowIndex: number) => Array>; } interface State { @@ -80,6 +81,7 @@ class UnThemedLogRows extends PureComponent { theme, isLogsPanel, previewLimit, + getFieldLinks, } = this.props; const { renderAll } = this.state; const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows; @@ -116,6 +118,7 @@ class UnThemedLogRows extends PureComponent { isLogsPanel={isLogsPanel} onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} + getFieldLinks={getFieldLinks} /> ))} {hasData && @@ -132,6 +135,7 @@ class UnThemedLogRows extends PureComponent { isLogsPanel={isLogsPanel} onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} + getFieldLinks={getFieldLinks} /> ))} {hasData && !renderAll && Rendering {rowCount - previewLimit!} rows...} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 3cbd36056ef..a45e517b945 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -79,6 +79,7 @@ export { CallToActionCard } from './CallToActionCard/CallToActionCard'; export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu'; export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions'; export { DataLinksEditor } from './DataLinks/DataLinksEditor'; +export { DataLinkInput } from './DataLinks/DataLinkInput'; export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; export { SeriesIcon } from './Legend/SeriesIcon'; export { transformersUIRegistry } from './TransformersUI/transformers'; diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/logs_model.test.ts similarity index 99% rename from public/app/core/specs/logs_model.test.ts rename to public/app/core/logs_model.test.ts index d09134f9485..cc5116e1d99 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/logs_model.test.ts @@ -8,7 +8,7 @@ import { toDataFrame, LogRowModel, } from '@grafana/data'; -import { dedupLogRows, dataFrameToLogsModel } from '../logs_model'; +import { dedupLogRows, dataFrameToLogsModel } from './logs_model'; describe('dedupLogRows()', () => { test('should return rows as is when dedup is set to none', () => { diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 80e11d04f98..cc09e78d824 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -165,24 +165,22 @@ function isLogsData(series: DataFrame) { return series.fields.some(f => f.type === FieldType.time) && series.fields.some(f => f.type === FieldType.string); } +/** + * Convert dataFrame into LogsModel which consists of creating separate array of log rows and metrics series. Metrics + * series can be either already included in the dataFrame or will be computed from the log rows. + * @param dataFrame + * @param intervalMs In case there are no metrics series, we use this for computing it from log rows. + */ export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number): LogsModel { - const metricSeries: DataFrame[] = []; - const logSeries: DataFrame[] = []; - - for (const series of dataFrame) { - if (isLogsData(series)) { - logSeries.push(series); - continue; - } - - metricSeries.push(series); - } - + const { logSeries, metricSeries } = separateLogsAndMetrics(dataFrame); const logsModel = logSeriesToLogsModel(logSeries); + if (logsModel) { if (metricSeries.length === 0) { + // Create metrics from logs logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs); } else { + // We got metrics in the dataFrame so process those logsModel.series = getGraphSeriesModel( metricSeries, {}, @@ -206,23 +204,33 @@ export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number) }; } -export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { +function separateLogsAndMetrics(dataFrame: DataFrame[]) { + const metricSeries: DataFrame[] = []; + const logSeries: DataFrame[] = []; + + for (const series of dataFrame) { + if (isLogsData(series)) { + logSeries.push(series); + continue; + } + + metricSeries.push(series); + } + + return { logSeries, metricSeries }; +} + +const logTimeFormat = 'YYYY-MM-DD HH:mm:ss'; + +/** + * Converts dataFrames into LogsModel. This involves merging them into one list, sorting them and computing metadata + * like common labels. + */ +export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefined { if (logSeries.length === 0) { return undefined; } - - const allLabels: Labels[] = []; - for (let n = 0; n < logSeries.length; n++) { - const series = logSeries[n]; - if (series.labels) { - allLabels.push(series.labels); - } - } - - let commonLabels: Labels = {}; - if (allLabels.length > 0) { - commonLabels = findCommonLabels(allLabels); - } + const commonLabels = findCommonLabelsFromDataFrames(logSeries); const rows: LogRowModel[] = []; let hasUniqueLabels = false; @@ -236,6 +244,8 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { } const timeField = fieldCache.getFirstFieldOfType(FieldType.time); + // Assume the first string field in the dataFrame is the message. This was right so far but probably needs some + // more explicit checks. const stringField = fieldCache.getFirstFieldOfType(FieldType.string); const logLevelField = fieldCache.getFieldByName('level'); const idField = getIdField(fieldCache); @@ -248,14 +258,13 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { for (let j = 0; j < series.length; j++) { const ts = timeField.values.get(j); const time = dateTime(ts); - const timeEpochMs = time.valueOf(); - const timeFromNow = time.fromNow(); - const timeLocal = time.format('YYYY-MM-DD HH:mm:ss'); - const timeUtc = toUtc(ts).format('YYYY-MM-DD HH:mm:ss'); - let message = stringField.values.get(j); + const messageValue: unknown = stringField.values.get(j); // This should be string but sometimes isn't (eg elastic) because the dataFrame is not strongly typed. - message = typeof message === 'string' ? message : JSON.stringify(message); + const message: string = typeof messageValue === 'string' ? messageValue : JSON.stringify(messageValue); + + const hasAnsi = hasAnsiCodes(message); + const searchWords = series.meta && series.meta.searchWords ? series.meta.searchWords : []; let logLevel = LogLevel.unknown; if (logLevelField) { @@ -265,15 +274,16 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { } else { logLevel = getLogLevel(message); } - const hasAnsi = hasAnsiCodes(message); - const searchWords = series.meta && series.meta.searchWords ? series.meta.searchWords : []; rows.push({ + entryFieldIndex: stringField.index, + rowIndex: j, + dataFrame: series, logLevel, - timeFromNow, - timeEpochMs, - timeLocal, - timeUtc, + timeFromNow: time.fromNow(), + timeEpochMs: time.valueOf(), + timeLocal: time.format(logTimeFormat), + timeUtc: toUtc(ts).format(logTimeFormat), uniqueLabels, hasAnsi, searchWords, @@ -313,6 +323,21 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { }; } +function findCommonLabelsFromDataFrames(logSeries: DataFrame[]): Labels { + const allLabels: Labels[] = []; + for (let n = 0; n < logSeries.length; n++) { + const series = logSeries[n]; + if (series.labels) { + allLabels.push(series.labels); + } + } + + if (allLabels.length > 0) { + return findCommonLabels(allLabels); + } + return {}; +} + function getIdField(fieldCache: FieldCache): FieldWithIndex | undefined { const idFieldNames = ['id']; for (const fieldName of idFieldNames) { diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 819a46c5c65..8462748b891 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -15,7 +15,7 @@ import { } from './explore'; import { ExploreUrlState, ExploreMode } from 'app/types/explore'; import store from 'app/core/store'; -import { DataQueryError, LogsDedupStrategy, LogsModel, LogLevel, dateTime } from '@grafana/data'; +import { DataQueryError, LogsDedupStrategy, LogsModel, LogLevel, dateTime, MutableDataFrame } from '@grafana/data'; import { RefreshPicker } from '@grafana/ui'; const DEFAULT_EXPLORE_STATE: ExploreUrlState = { @@ -373,6 +373,9 @@ describe('refreshIntervalToSortOrder', () => { describe('sortLogsResult', () => { const firstRow = { + rowIndex: 0, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), timestamp: '2019-01-01T21:00:0.0000000Z', entry: '', hasAnsi: false, @@ -387,6 +390,9 @@ describe('sortLogsResult', () => { }; const sameAsFirstRow = firstRow; const secondRow = { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), timestamp: '2019-01-01T22:00:0.0000000Z', entry: '', hasAnsi: false, diff --git a/public/app/features/explore/LiveLogs.test.tsx b/public/app/features/explore/LiveLogs.test.tsx index 12bd1bc5d35..f2eae2e8b25 100644 --- a/public/app/features/explore/LiveLogs.test.tsx +++ b/public/app/features/explore/LiveLogs.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LogLevel, LogRowModel } from '@grafana/data'; +import { LogLevel, LogRowModel, MutableDataFrame } from '@grafana/data'; import { mount } from 'enzyme'; import { LiveLogsWithTheme } from './LiveLogs'; @@ -62,6 +62,9 @@ const makeLog = (overides: Partial): LogRowModel => { const entry = `log message ${uid}`; return { uid, + entryFieldIndex: 0, + rowIndex: 0, + dataFrame: new MutableDataFrame(), logLevel: LogLevel.debug, entry, hasAnsi: false, diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 46803d91787..6faebb0b03c 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -12,6 +12,8 @@ import { LogsDedupDescription, LogsMetaItem, GraphSeriesXY, + LinkModel, + Field, } from '@grafana/data'; import { Switch, LogLabels, ToggleButtonGroup, ToggleButton, LogRows } from '@grafana/ui'; @@ -50,6 +52,7 @@ interface Props { onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void; onToggleLogLevel: (hiddenLogLevels: LogLevel[]) => void; getRowContext?: (row: LogRowModel, options?: any) => Promise; + getFieldLinks: (field: Field, rowIndex: number) => Array>; } interface State { @@ -113,6 +116,7 @@ export class Logs extends PureComponent { dedupedRows, absoluteRange, onChangeTime, + getFieldLinks, } = this.props; if (!logRows) { @@ -199,6 +203,7 @@ export class Logs extends PureComponent { onClickFilterOutLabel={onClickFilterOutLabel} showTime={showTime} timeZone={timeZone} + getFieldLinks={getFieldLinks} /> {!loading && !hasData && !scanning && ( diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index a7df15cf537..085e35ffe04 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -27,6 +27,7 @@ import { LiveLogsWithTheme } from './LiveLogs'; import { Logs } from './Logs'; import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition'; import { LiveTailControls } from './useLiveTailControls'; +import { getLinksFromLogsField } from '../panel/panellinks/linkSuppliers'; interface LogsContainerProps { datasourceInstance: DataSourceApi | null; @@ -148,6 +149,7 @@ export class LogsContainer extends PureComponent { scanRange={range.raw} width={width} getRowContext={this.getLogRowContext} + getFieldLinks={getLinksFromLogsField} /> diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 8679c6ce51f..e2ffafa9df4 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -137,7 +137,8 @@ describe('ResultProcessor', () => { describe('when calling getLogsResult', () => { it('then it should return correct logs result', () => { - const { resultProcessor } = testContext({ mode: ExploreMode.Logs }); + const { resultProcessor, dataFrames } = testContext({ mode: ExploreMode.Logs }); + const logsDataFrame = dataFrames[1]; const theResult = resultProcessor.getLogsResult(); expect(theResult).toEqual({ @@ -145,7 +146,10 @@ describe('ResultProcessor', () => { meta: [], rows: [ { + rowIndex: 2, + dataFrame: logsDataFrame, entry: 'third', + entryFieldIndex: 2, hasAnsi: false, labels: undefined, logLevel: 'unknown', @@ -160,7 +164,10 @@ describe('ResultProcessor', () => { uniqueLabels: {}, }, { + rowIndex: 1, + dataFrame: logsDataFrame, entry: 'second message', + entryFieldIndex: 2, hasAnsi: false, labels: undefined, logLevel: 'unknown', @@ -175,7 +182,10 @@ describe('ResultProcessor', () => { uniqueLabels: {}, }, { + rowIndex: 0, + dataFrame: logsDataFrame, entry: 'this is a message', + entryFieldIndex: 2, hasAnsi: false, labels: undefined, logLevel: 'unknown', diff --git a/public/app/features/panel/panellinks/linkSuppliers.test.ts b/public/app/features/panel/panellinks/linkSuppliers.test.ts new file mode 100644 index 00000000000..5a4f97dd477 --- /dev/null +++ b/public/app/features/panel/panellinks/linkSuppliers.test.ts @@ -0,0 +1,61 @@ +import { getLinksFromLogsField } from './linkSuppliers'; +import { ArrayVector, dateTime, Field, FieldType } from '@grafana/data'; +import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from './link_srv'; +import { TemplateSrv } from '../../templating/template_srv'; +import { TimeSrv } from '../../dashboard/services/TimeSrv'; + +describe('getLinksFromLogsField', () => { + let originalLinkSrv: LinkService; + beforeAll(() => { + // We do not need more here and TimeSrv is hard to setup fully. + const timeSrvMock: TimeSrv = { + timeRangeForUrl() { + const from = dateTime().subtract(1, 'h'); + const to = dateTime(); + return { from, to, raw: { from, to } }; + }, + } as any; + const linkService = new LinkSrv(new TemplateSrv(), timeSrvMock); + originalLinkSrv = getLinkSrv(); + setLinkSrv(linkService); + }); + + afterAll(() => { + setLinkSrv(originalLinkSrv); + }); + + it('interpolates link from field', () => { + const field: Field = { + name: 'test field', + type: FieldType.number, + config: { + links: [ + { + title: 'title1', + url: 'domain.com/${__value.raw}', + }, + { + title: 'title2', + url: 'anotherdomain.sk/${__value.raw}', + }, + ], + }, + values: new ArrayVector([1, 2, 3]), + }; + const links = getLinksFromLogsField(field, 2); + expect(links.length).toBe(2); + expect(links[0].href).toBe('domain.com/3'); + expect(links[1].href).toBe('anotherdomain.sk/3'); + }); + + it('handles zero links', () => { + const field: Field = { + name: 'test field', + type: FieldType.number, + config: {}, + values: new ArrayVector([1, 2, 3]), + }; + const links = getLinksFromLogsField(field, 2); + expect(links.length).toBe(0); + }); +}); diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index 6c0392a394e..a6f2d215835 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -1,6 +1,14 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { FieldDisplay } from '@grafana/data'; -import { LinkModelSupplier, getTimeField, Labels, ScopedVars, ScopedVar } from '@grafana/data'; +import { + FieldDisplay, + LinkModelSupplier, + getTimeField, + Labels, + ScopedVars, + ScopedVar, + Field, + LinkModel, +} from '@grafana/data'; import { getLinkSrv } from './link_srv'; interface SeriesVars { @@ -112,3 +120,17 @@ export const getPanelLinksSupplier = (value: PanelModel): LinkModelSupplier> => { + const scopedVars: any = {}; + scopedVars['__value'] = { + value: { + raw: field.values.get(rowIndex), + }, + text: 'Raw value', + }; + + return field.config.links + ? field.config.links.map(link => getLinkSrv().getDataLinkUIModel(link, scopedVars, field)) + : []; +}; diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index 3be12a75621..279cfce44a4 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -152,7 +152,10 @@ export class LinkSrv implements LinkService { return info; } - getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, origin: T) => { + /** + * Returns LinkModel which is basically a DataLink with all values interpolated through the templateSrv. + */ + getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, origin: T): LinkModel => { const params: KeyValue = {}; const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl()); diff --git a/public/app/plugins/datasource/loki/components/ConfigEditor.test.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx similarity index 91% rename from public/app/plugins/datasource/loki/components/ConfigEditor.test.tsx rename to public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx index 697c90b8a07..39f2a3bcf07 100644 --- a/public/app/plugins/datasource/loki/components/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx @@ -3,6 +3,7 @@ import { mount } from 'enzyme'; import { ConfigEditor } from './ConfigEditor'; import { createDefaultConfigOptions } from '../mocks'; import { DataSourceHttpSettings } from '@grafana/ui'; +import { DerivedFields } from './DerivedFields'; describe('ConfigEditor', () => { it('should render without error', () => { @@ -13,6 +14,7 @@ describe('ConfigEditor', () => { const wrapper = mount( {}} options={createDefaultConfigOptions()} />); expect(wrapper.find(DataSourceHttpSettings).length).toBe(1); expect(wrapper.find({ label: 'Maximum lines' }).length).toBe(1); + expect(wrapper.find(DerivedFields).length).toBe(1); }); it('should pass correct data to onChange', () => { diff --git a/public/app/plugins/datasource/loki/components/ConfigEditor.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx similarity index 55% rename from public/app/plugins/datasource/loki/components/ConfigEditor.tsx rename to public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx index 495b7c4804c..9244c854867 100644 --- a/public/app/plugins/datasource/loki/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; -import { FormField, DataSourceHttpSettings } from '@grafana/ui'; +import { DataSourceHttpSettings } from '@grafana/ui'; import { LokiOptions } from '../types'; +import { MaxLinesField } from './MaxLinesField'; +import { DerivedFields } from './DerivedFields'; export type Props = DataSourcePluginOptionsEditorProps; @@ -19,6 +21,7 @@ const makeJsonUpdater = (field: keyof LokiOptions) => ( }; const setMaxLines = makeJsonUpdater('maxLines'); +const setDerivedFields = makeJsonUpdater('derivedFields'); export const ConfigEditor = (props: Props) => { const { options, onOptionsChange } = props; @@ -42,39 +45,11 @@ export const ConfigEditor = (props: Props) => {
+ + onOptionsChange(setDerivedFields(options, value))} + /> ); }; - -type MaxLinesFieldProps = { - value: string; - onChange: (value: string) => void; -}; - -const MaxLinesField = (props: MaxLinesFieldProps) => { - const { value, onChange } = props; - return ( - onChange(event.currentTarget.value)} - spellCheck={false} - placeholder="1000" - /> - } - tooltip={ - <> - Loki queries must contain a limit of the maximum number of lines returned (default: 1000). Increase this limit - to have a bigger result set for ad-hoc analysis. Decrease this limit if your browser becomes sluggish when - displaying the log results. - - } - /> - ); -}; diff --git a/public/app/plugins/datasource/loki/configuration/DebugSection.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.tsx new file mode 100644 index 00000000000..3ec39d5161d --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DebugSection.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; +import { FormField } from '@grafana/ui'; +import { DerivedFieldConfig } from '../types'; +import { getLinksFromLogsField } from '../../../../features/panel/panellinks/linkSuppliers'; +import { ArrayVector, FieldType } from '@grafana/data'; + +type Props = { + derivedFields: DerivedFieldConfig[]; + className?: string; +}; +export const DebugSection = (props: Props) => { + const { derivedFields, className } = props; + const [debugText, setDebugText] = useState(''); + + let debugFields: DebugField[] = []; + if (debugText && derivedFields) { + debugFields = makeDebugFields(derivedFields, debugText); + } + + return ( +
+ setDebugText(event.currentTarget.value)} + /> + } + /> + {!!debugFields.length && } +
+ ); +}; + +type DebugFieldItemProps = { + fields: DebugField[]; +}; +const DebugFields = ({ fields }: DebugFieldItemProps) => { + return ( + + + + + + + + + + {fields.map(field => { + let value: any = field.value; + if (field.error) { + value = field.error.message; + } else if (field.href) { + value = {value}; + } + return ( + + + + + + ); + })} + +
NameValueUrl
{field.name}{value}{field.href ? {field.href} : ''}
+ ); +}; + +type DebugField = { + name: string; + error?: any; + value?: string; + href?: string; +}; +function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string): DebugField[] { + return derivedFields + .filter(field => field.name && field.matcherRegex) + .map(field => { + try { + const testMatch = debugText.match(field.matcherRegex); + const value = testMatch && testMatch[1]; + let link; + + if (field.url && value) { + link = getLinksFromLogsField( + { + name: '', + type: FieldType.string, + values: new ArrayVector([value]), + config: { + links: [{ title: '', url: field.url }], + }, + }, + 0 + )[0]; + } + + return { + name: field.name, + value: value || '', + href: link && link.href, + } as DebugField; + } catch (error) { + return { + name: field.name, + error, + } as DebugField; + } + }); +} diff --git a/public/app/plugins/datasource/loki/configuration/DebugSections.test.tsx b/public/app/plugins/datasource/loki/configuration/DebugSections.test.tsx new file mode 100644 index 00000000000..abcdfd9cf38 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DebugSections.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { DebugSection } from './DebugSection'; +import { mount } from 'enzyme'; +import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from '../../../../features/panel/panellinks/link_srv'; +import { TimeSrv } from '../../../../features/dashboard/services/TimeSrv'; +import { dateTime } from '@grafana/data'; +import { TemplateSrv } from '../../../../features/templating/template_srv'; + +describe('DebugSection', () => { + let originalLinkSrv: LinkService; + + // This needs to be setup so we can test interpolation in the debugger + beforeAll(() => { + // We do not need more here and TimeSrv is hard to setup fully. + const timeSrvMock: TimeSrv = { + timeRangeForUrl() { + const from = dateTime().subtract(1, 'h'); + const to = dateTime(); + return { from, to, raw: { from, to } }; + }, + } as any; + const linkService = new LinkSrv(new TemplateSrv(), timeSrvMock); + originalLinkSrv = getLinkSrv(); + setLinkSrv(linkService); + }); + + afterAll(() => { + setLinkSrv(originalLinkSrv); + }); + + it('does not render any field if no debug text', () => { + const wrapper = mount(); + expect(wrapper.find('DebugFieldItem').length).toBe(0); + }); + + it('does not render any field if no derived fields', () => { + const wrapper = mount(); + const textarea = wrapper.find('textarea'); + (textarea.getDOMNode() as HTMLTextAreaElement).value = 'traceId=1234'; + textarea.simulate('change'); + expect(wrapper.find('DebugFieldItem').length).toBe(0); + }); + + it('renders derived fields', () => { + const derivedFields = [ + { + matcherRegex: 'traceId=(\\w+)', + name: 'traceIdLink', + url: 'localhost/trace/${__value.raw}', + }, + { + matcherRegex: 'traceId=(\\w+)', + name: 'traceId', + }, + { + matcherRegex: 'traceId=(', + name: 'error', + }, + ]; + + const wrapper = mount(); + const textarea = wrapper.find('textarea'); + (textarea.getDOMNode() as HTMLTextAreaElement).value = 'traceId=1234'; + textarea.simulate('change'); + + expect(wrapper.find('table').length).toBe(1); + // 3 rows + one header + expect(wrapper.find('tr').length).toBe(4); + expect( + wrapper + .find('tr') + .at(1) + .contains('localhost/trace/1234') + ).toBeTruthy(); + }); +}); diff --git a/public/app/plugins/datasource/loki/configuration/DerivedField.tsx b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx new file mode 100644 index 00000000000..6a173efb5e2 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { css } from 'emotion'; +import { Button, FormField, VariableSuggestion, DataLinkInput, stylesFactory } from '@grafana/ui'; +import { DerivedFieldConfig } from '../types'; + +const getStyles = stylesFactory(() => ({ + firstRow: css` + display: flex; + `, + nameField: css` + flex: 2; + `, + regexField: css` + flex: 3; + `, +})); + +type Props = { + value: DerivedFieldConfig; + onChange: (value: DerivedFieldConfig) => void; + onDelete: () => void; + suggestions: VariableSuggestion[]; + className?: string; +}; +export const DerivedField = (props: Props) => { + const { value, onChange, onDelete, suggestions, className } = props; + const styles = getStyles(); + + const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent) => { + onChange({ + ...value, + [field]: event.currentTarget.value, + }); + }; + + return ( +
+
+ + +
+ + + onChange({ + ...value, + url: newValue, + }) + } + suggestions={suggestions} + /> + } + className={css` + width: 100%; + `} + /> +
+ ); +}; diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx new file mode 100644 index 00000000000..29c30d39473 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DerivedFields } from './DerivedFields'; +import { Button } from '@grafana/ui'; +import { DerivedField } from './DerivedField'; + +describe('DerivedFields', () => { + let originalGetSelection: typeof window.getSelection; + beforeAll(() => { + originalGetSelection = window.getSelection; + window.getSelection = () => null; + }); + + afterAll(() => { + window.getSelection = originalGetSelection; + }); + + it('renders correctly when no fields', () => { + const wrapper = mount( {}} />); + expect(wrapper.find(Button).length).toBe(1); + expect(wrapper.find(Button).contains('Add')).toBeTruthy(); + expect(wrapper.find(DerivedField).length).toBe(0); + }); + + it('renders correctly when there are fields', () => { + const wrapper = mount( {}} />); + + expect(wrapper.find(Button).filterWhere(button => button.contains('Add')).length).toBe(1); + expect(wrapper.find(Button).filterWhere(button => button.contains('Show example log message')).length).toBe(1); + expect(wrapper.find(DerivedField).length).toBe(2); + }); + + it('adds new field', () => { + const onChangeMock = jest.fn(); + const wrapper = mount(); + const addButton = wrapper.find(Button).filterWhere(button => button.contains('Add')); + addButton.simulate('click'); + expect(onChangeMock.mock.calls[0][0].length).toBe(1); + }); + + it('removes field', () => { + const onChangeMock = jest.fn(); + const wrapper = mount(); + const removeButton = wrapper + .find(DerivedField) + .at(0) + .find(Button); + removeButton.simulate('click'); + const newValue = onChangeMock.mock.calls[0][0]; + expect(newValue.length).toBe(1); + expect(newValue[0]).toMatchObject({ + matcherRegex: 'regex2', + name: 'test2', + url: 'localhost2', + }); + }); +}); + +const testValue = [ + { + matcherRegex: 'regex1', + name: 'test1', + url: 'localhost1', + }, + { + matcherRegex: 'regex2', + name: 'test2', + url: 'localhost2', + }, +]; diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx new file mode 100644 index 00000000000..e634ab364ff --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import { Button, DataLinkBuiltInVars, stylesFactory, useTheme, VariableOrigin } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { DerivedFieldConfig } from '../types'; +import { DerivedField } from './DerivedField'; +import { DebugSection } from './DebugSection'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + infoText: css` + padding-bottom: ${theme.spacing.md}; + color: ${theme.colors.textWeak}; + `, + derivedField: css` + margin-bottom: ${theme.spacing.sm}; + `, +})); + +type Props = { + value?: DerivedFieldConfig[]; + onChange: (value: DerivedFieldConfig[]) => void; +}; +export const DerivedFields = (props: Props) => { + const { value, onChange } = props; + const theme = useTheme(); + const styles = getStyles(theme); + + const [showDebug, setShowDebug] = useState(false); + + return ( + <> +

Derived fields

+ +
+ Derived fields can be used to extract new fields from the log message and create link from it's value. +
+ +
+ {value && + value.map((field, index) => { + return ( + { + const newDerivedFields = [...value]; + newDerivedFields.splice(index, 1, newField); + onChange(newDerivedFields); + }} + onDelete={() => { + const newDerivedFields = [...value]; + newDerivedFields.splice(index, 1); + onChange(newDerivedFields); + }} + suggestions={[ + { + value: DataLinkBuiltInVars.valueRaw, + label: 'Raw value', + documentation: 'Exact string captured by the regular expression', + origin: VariableOrigin.Value, + }, + ]} + /> + ); + })} +
+ + + {value && value.length > 0 && ( + + )} +
+
+ + {showDebug && ( +
+ +
+ )} + + ); +}; diff --git a/public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx b/public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx new file mode 100644 index 00000000000..a0b3bbe72a1 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { FormField } from '@grafana/ui'; + +type Props = { + value: string; + onChange: (value: string) => void; +}; + +export const MaxLinesField = (props: Props) => { + const { value, onChange } = props; + return ( + onChange(event.currentTarget.value)} + spellCheck={false} + placeholder="1000" + /> + } + tooltip={ + <> + Loki queries must contain a limit of the maximum number of lines returned (default: 1000). Increase this limit + to have a bigger result set for ad-hoc analysis. Decrease this limit if your browser becomes sluggish when + displaying the log results. + + } + /> + ); +}; diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index bbffe4b5615..6a9f62d26d9 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -1,5 +1,5 @@ // Libraries -import { isEmpty, isString } from 'lodash'; +import { isEmpty, isString, fromPairs } from 'lodash'; // Services & Utils import { dateMath, @@ -9,6 +9,16 @@ import { AnnotationEvent, DataFrameView, LoadingState, + ArrayVector, + FieldType, + FieldConfig, +} from '@grafana/data'; +import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query'; +import LanguageProvider from './language_provider'; +import { logStreamToDataFrame } from './result_transformer'; +import { formatQuery, parseQuery, getHighlighterExpressionsFromQuery } from './query_utils'; +// Types +import { PluginMeta, DataSourceApi, DataSourceInstanceSettings, @@ -17,11 +27,6 @@ import { DataQueryResponse, AnnotationQueryRequest, } from '@grafana/data'; -import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query'; -import LanguageProvider from './language_provider'; -import { logStreamToDataFrame } from './result_transformer'; -import { formatQuery, parseQuery, getHighlighterExpressionsFromQuery } from './query_utils'; - import { LokiQuery, LokiOptions, LokiLogsStream, LokiResponse } from './types'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -154,6 +159,7 @@ export class LokiDatasource extends DataSourceApi { data = data as LokiResponse; for (const stream of data.streams || []) { const dataFrame = logStreamToDataFrame(stream); + this.enhanceDataFrame(dataFrame); dataFrame.refId = target.refId; dataFrame.meta = { searchWords: getHighlighterExpressionsFromQuery(formatQuery(target.query, target.regexp)), @@ -405,6 +411,51 @@ export class LokiDatasource extends DataSourceApi { return annotations; } + + /** + * Adds new fields and DataLinks to DataFrame based on DataSource instance config. + * @param dataFrame + */ + enhanceDataFrame(dataFrame: DataFrame): void { + if (!this.instanceSettings.jsonData) { + return; + } + + const derivedFields = this.instanceSettings.jsonData.derivedFields || []; + if (derivedFields.length) { + const fields = fromPairs( + derivedFields.map(field => { + const config: FieldConfig = {}; + if (field.url) { + config.links = [ + { + url: field.url, + title: '', + }, + ]; + } + const dataFrameField = { + name: field.name, + type: FieldType.string, + config, + values: new ArrayVector([]), + }; + + return [field.name, dataFrameField]; + }) + ); + + const view = new DataFrameView(dataFrame); + view.forEachRow((row: { line: string }) => { + for (const field of derivedFields) { + const logMatch = row.line.match(field.matcherRegex); + fields[field.name].values.add(logMatch && logMatch[1]); + } + }); + + dataFrame.fields = [...dataFrame.fields, ...Object.values(fields)]; + } + } } function queryRequestFromAnnotationOptions(options: AnnotationQueryRequest): DataQueryRequest { diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts index 3b8349dabb6..756408df662 100644 --- a/public/app/plugins/datasource/loki/module.ts +++ b/public/app/plugins/datasource/loki/module.ts @@ -5,7 +5,7 @@ import LokiCheatSheet from './components/LokiCheatSheet'; import LokiQueryField from './components/LokiQueryField'; import LokiQueryEditor from './components/LokiQueryEditor'; import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl'; -import { ConfigEditor } from './components/ConfigEditor'; +import { ConfigEditor } from './configuration/ConfigEditor'; export const plugin = new DataSourcePlugin(Datasource) .setQueryEditor(LokiQueryEditor) diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index efc786cb6f0..2387fda0688 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -9,6 +9,7 @@ export interface LokiQuery extends DataQuery { export interface LokiOptions extends DataSourceJsonData { maxLines?: string; + derivedFields?: DerivedFieldConfig[]; } export interface LokiResponse { @@ -34,3 +35,9 @@ export interface LokiExpression { regexp: string; query: string; } + +export type DerivedFieldConfig = { + matcherRegex: string; + name: string; + url?: string; +};