Commit 0a786524 by Andrej Ocenas Committed by GitHub

Explore: Add custom DataLinks on datasource level for Loki (#20060)

Adds a config section with derived fields which is a config that allows you to create a new field based on a regex matcher run on a log message create DataLink to it which is the clickable in the log detail.
parent 9507eda9
......@@ -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",
......
......@@ -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
......
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;
......
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();
......
......@@ -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;
......
......@@ -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<DataLinkInputProps> = memo(({ value, onChange, suggestions }) => {
const editorRef = useRef<Editor>() as RefObject<Editor>;
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
const prevLinkUrl = usePrevious<Value>(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 };
// 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);
export const DataLinkInput: React.FC<DataLinkInputProps> = memo(
({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => {
const editorRef = useRef<Editor>() as RefObject<Editor>;
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
const prevLinkUrl = usePrevious<Value>(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 };
// 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();
}
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:
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]);
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
setLinkUrl(value);
}, []);
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}}`);
}
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:
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]);
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
setLinkUrl(value);
}, []);
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);
onChange(Plain.serialize(editor.value));
};
return (
<div
className={cx(
'gf-form-input',
css`
position: relative;
height: auto;
`
)}
>
<div className="slate-query-field">
{showingSuggestions && (
<Portal>
<ReactPopper
referenceElement={selectionRef}
placement="top-end"
modifiers={{
preventOverflow: { enabled: true, boundariesElement: 'window' },
arrow: { enabled: false },
offset: { offset: 250 }, // width of the suggestions menu
}}
>
{({ ref, style, placement }) => {
return (
<div ref={ref} style={style} data-placement={placement}>
<DataLinkSuggestions
suggestions={stateRef.current.suggestions}
onSuggestionSelect={onVariableSelect}
onClose={() => setShowingSuggestions(false)}
activeIndex={suggestionsIndex}
/>
</div>
);
}}
</ReactPopper>
</Portal>
setLinkUrl(editor.value);
setShowingSuggestions(false);
setSuggestionsIndex(0);
stateRef.current.onChange(Plain.serialize(editor.value));
};
return (
<div
className={cx(
'gf-form-input',
css`
position: relative;
height: auto;
`
)}
<Editor
schema={SCHEMA}
ref={editorRef}
placeholder="http://your-grafana.com/d/000000010/annotations"
value={stateRef.current.linkUrl}
onChange={onUrlChange}
onKeyDown={(event, _editor, next) => onKeyDown(event as KeyboardEvent, next)}
plugins={plugins}
className={styles.editor}
/>
>
<div className="slate-query-field">
{showingSuggestions && (
<Portal>
<ReactPopper
referenceElement={selectionRef}
placement="top-end"
modifiers={{
preventOverflow: { enabled: true, boundariesElement: 'window' },
arrow: { enabled: false },
offset: { offset: 250 }, // width of the suggestions menu
}}
>
{({ ref, style, placement }) => {
return (
<div ref={ref} style={style} data-placement={placement}>
<DataLinkSuggestions
suggestions={stateRef.current.suggestions}
onSuggestionSelect={onVariableSelect}
onClose={() => setShowingSuggestions(false)}
activeIndex={suggestionsIndex}
/>
</div>
);
}}
</ReactPopper>
</Portal>
)}
<Editor
schema={SCHEMA}
ref={editorRef}
placeholder={placeholder}
value={stateRef.current.linkUrl}
onChange={onUrlChange}
onKeyDown={(event, _editor, next) => onKeyDown(event as KeyboardEvent, next)}
plugins={plugins}
className={styles.editor}
/>
</div>
</div>
</div>
);
});
);
}
);
DataLinkInput.displayName = 'DataLinkInput';
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<Props>, rowOverrides?: Partial<LogRowModel>) => {
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(<LogDetails {...props} />);
return wrapper;
return mount(<LogDetails {...props} />);
};
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(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);
}),
it('should render labels', () => {
const wrapper = setup({ row: { labels: { key1: 'label1', key2: 'label2' } } });
expect(wrapper.text().includes('key1label1key2label2')).toBe(true);
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' },
});
}),
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);
});
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');
});
});
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<LinkModel<Field>>;
}
class UnThemedLogDetails extends PureComponent<Props> {
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 (
<div className={style.logsRowDetailsTable}>
......@@ -46,16 +127,13 @@ class UnThemedLogDetails extends PureComponent<Props> {
</div>
{Object.keys(labels).map(key => {
const value = labels[key];
const field = `${key}=${value}`;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
field={field}
row={row}
getRows={getRows}
isLabel={true}
getStats={() => calculateLogsLabelStats(getRows(), key)}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickFilterLabel={onClickFilterLabel}
/>
......@@ -69,23 +147,22 @@ class UnThemedLogDetails extends PureComponent<Props> {
<div className={style.logsRowDetailsHeading} aria-label="Parsed fields">
Parsed fields:
</div>
{parsedFields &&
parsedFields.map(field => {
const key = parser!.getLabelFromField(field);
const value = parser!.getValueFromField(field);
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
field={field}
row={row}
isLabel={false}
getRows={getRows}
parser={parser}
/>
);
})}
{fields.map(field => {
const { key, value, links, fieldIndex } = field;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
links={links}
getStats={() =>
fieldIndex === undefined
? this.getStatsForParsedField(key)
: calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())
}
/>
);
})}
</div>
)}
{!parsedFieldsAvailable && !labelsAvailable && <div aria-label="No details">No details available</div>}
......
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<Props>) => {
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();
});
});
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<Props, State> {
state: State = {
showFieldsStats: false,
fieldCount: 0,
fieldLabel: null,
fieldStats: null,
fieldValue: null,
};
filterLabel = () => {
......@@ -60,7 +48,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
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<Props, State> {
});
}
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 (
<div className={style.logsRowDetailsValue}>
{/* Action buttons - show stats/filter results */}
<div onClick={this.showStats} className={style.logsRowDetailsIcon}>
<div onClick={this.showStats} aria-label={'Field stats'} className={style.logsRowDetailsIcon}>
<i className={'fa fa-signal'} />
</div>
{isLabel ? (
......@@ -120,12 +94,23 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
</div>
<div className={style.logsRowCell}>
<span>{parsedValue}</span>
{links &&
links.map(link => {
return (
<span key={link}>
&nbsp;
<a href={link} target={'_blank'}>
<i className={'fa fa-external-link'} />
</a>
</span>
);
})}
{showFieldsStats && (
<div className={style.logsRowCell}>
<LogLabelStats
stats={fieldStats!}
label={fieldLabel!}
value={fieldValue!}
label={parsedKey}
value={parsedValue}
rowCount={fieldCount}
isLabel={isLabel}
/>
......
......@@ -58,7 +58,7 @@ interface Props extends Themeable {
label: string;
value: string;
rowCount: number;
isLabel: boolean;
isLabel?: boolean;
}
class UnThemedLogLabelStats extends PureComponent<Props> {
......
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<DataQueryResponse>;
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
}
interface State {
......@@ -80,6 +81,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
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<Props, State> {
</div>
{this.state.showDetails && (
<LogDetails
getFieldLinks={getFieldLinks}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
getRows={getRows}
......
......@@ -21,6 +21,9 @@ describe('getRowContexts', () => {
],
});
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,
......
......@@ -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>): LogRowModel => {
const uid = overides.uid || '1';
const makeLog = (overrides: Partial<LogRowModel>): 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>): LogRowModel => {
timeLocal: '',
timeUtc: '',
searchWords: [],
...overides,
...overrides,
};
};
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<any>;
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
}
interface State {
......@@ -80,6 +81,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
theme,
isLogsPanel,
previewLimit,
getFieldLinks,
} = this.props;
const { renderAll } = this.state;
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
......@@ -116,6 +118,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
isLogsPanel={isLogsPanel}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
getFieldLinks={getFieldLinks}
/>
))}
{hasData &&
......@@ -132,6 +135,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
isLogsPanel={isLogsPanel}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
getFieldLinks={getFieldLinks}
/>
))}
{hasData && !renderAll && <span>Rendering {rowCount - previewLimit!} rows...</span>}
......
......@@ -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';
......
......@@ -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', () => {
......
......@@ -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 {
if (logSeries.length === 0) {
return undefined;
}
function separateLogsAndMetrics(dataFrame: DataFrame[]) {
const metricSeries: DataFrame[] = [];
const logSeries: DataFrame[] = [];
const allLabels: Labels[] = [];
for (let n = 0; n < logSeries.length; n++) {
const series = logSeries[n];
if (series.labels) {
allLabels.push(series.labels);
for (const series of dataFrame) {
if (isLogsData(series)) {
logSeries.push(series);
continue;
}
metricSeries.push(series);
}
let commonLabels: Labels = {};
if (allLabels.length > 0) {
commonLabels = findCommonLabels(allLabels);
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 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) {
......
......@@ -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,
......
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>): LogRowModel => {
const entry = `log message ${uid}`;
return {
uid,
entryFieldIndex: 0,
rowIndex: 0,
dataFrame: new MutableDataFrame(),
logLevel: LogLevel.debug,
entry,
hasAnsi: false,
......
......@@ -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<any>;
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
}
interface State {
......@@ -113,6 +116,7 @@ export class Logs extends PureComponent<Props, State> {
dedupedRows,
absoluteRange,
onChangeTime,
getFieldLinks,
} = this.props;
if (!logRows) {
......@@ -199,6 +203,7 @@ export class Logs extends PureComponent<Props, State> {
onClickFilterOutLabel={onClickFilterOutLabel}
showTime={showTime}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
/>
{!loading && !hasData && !scanning && (
......
......@@ -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<LogsContainerProps> {
scanRange={range.raw}
width={width}
getRowContext={this.getLogRowContext}
getFieldLinks={getLinksFromLogsField}
/>
</Collapse>
</LogsCrossFadeTransition>
......
......@@ -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',
......
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);
});
});
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<Pane
},
};
};
export const getLinksFromLogsField = (field: Field, rowIndex: number): Array<LinkModel<Field>> => {
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))
: [];
};
......@@ -152,7 +152,10 @@ export class LinkSrv implements LinkService {
return info;
}
getDataLinkUIModel = <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => {
/**
* Returns LinkModel which is basically a DataLink with all values interpolated through the templateSrv.
*/
getDataLinkUIModel = <T>(link: DataLink, scopedVars: ScopedVars, origin: T): LinkModel<T> => {
const params: KeyValue = {};
const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
......
......@@ -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(<ConfigEditor onOptionsChange={() => {}} 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', () => {
......
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<LokiOptions>;
......@@ -19,6 +21,7 @@ const makeJsonUpdater = <T extends any>(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) => {
</div>
</div>
</div>
</>
);
};
type MaxLinesFieldProps = {
value: string;
onChange: (value: string) => void;
};
const MaxLinesField = (props: MaxLinesFieldProps) => {
const { value, onChange } = props;
return (
<FormField
label="Maximum lines"
labelWidth={11}
inputWidth={20}
inputEl={
<input
type="number"
className="gf-form-input width-8 gf-form-input--has-help-icon"
value={value}
onChange={event => 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.
</>
}
/>
<DerivedFields
value={options.jsonData.derivedFields}
onChange={value => onOptionsChange(setDerivedFields(options, value))}
/>
</>
);
};
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 (
<div className={className}>
<FormField
labelWidth={12}
label={'Debug log message'}
inputEl={
<textarea
placeholder={'Paste an example log line here to test the regular expressions of your derived fields'}
className={cx(
'gf-form-input gf-form-textarea',
css`
width: 100%;
`
)}
value={debugText}
onChange={event => setDebugText(event.currentTarget.value)}
/>
}
/>
{!!debugFields.length && <DebugFields fields={debugFields} />}
</div>
);
};
type DebugFieldItemProps = {
fields: DebugField[];
};
const DebugFields = ({ fields }: DebugFieldItemProps) => {
return (
<table className={'filter-table'}>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
<th>Url</th>
</tr>
</thead>
<tbody>
{fields.map(field => {
let value: any = field.value;
if (field.error) {
value = field.error.message;
} else if (field.href) {
value = <a href={field.href}>{value}</a>;
}
return (
<tr key={`${field.name}=${field.value}`}>
<td>{field.name}</td>
<td>{value}</td>
<td>{field.href ? <a href={field.href}>{field.href}</a> : ''}</td>
</tr>
);
})}
</tbody>
</table>
);
};
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 || '<no match>',
href: link && link.href,
} as DebugField;
} catch (error) {
return {
name: field.name,
error,
} as DebugField;
}
});
}
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(<DebugSection derivedFields={[]} />);
expect(wrapper.find('DebugFieldItem').length).toBe(0);
});
it('does not render any field if no derived fields', () => {
const wrapper = mount(<DebugSection derivedFields={[]} />);
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(<DebugSection derivedFields={derivedFields} />);
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();
});
});
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<HTMLInputElement>) => {
onChange({
...value,
[field]: event.currentTarget.value,
});
};
return (
<div className={className}>
<div className={styles.firstRow}>
<FormField
className={styles.nameField}
labelWidth={5}
// A bit of a hack to prevent using default value for the width from FormField
inputWidth={null}
label="Name"
type="text"
value={value.name}
onChange={handleChange('name')}
/>
<FormField
className={styles.regexField}
inputWidth={null}
label="Regex"
type="text"
value={value.matcherRegex}
onChange={handleChange('matcherRegex')}
tooltip={
'Use to parse and capture some part of the log message. You can use the captured groups in the template.'
}
/>
<Button
variant={'inverse'}
title="Remove field"
icon={'fa fa-times'}
onClick={event => {
event.preventDefault();
onDelete();
}}
/>
</div>
<FormField
label="URL"
labelWidth={5}
inputEl={
<DataLinkInput
placeholder={'http://example.com/${__value.raw}'}
value={value.url || ''}
onChange={newValue =>
onChange({
...value,
url: newValue,
})
}
suggestions={suggestions}
/>
}
className={css`
width: 100%;
`}
/>
</div>
);
};
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(<DerivedFields onChange={() => {}} />);
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(<DerivedFields value={testValue} onChange={() => {}} />);
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(<DerivedFields onChange={onChangeMock} />);
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(<DerivedFields value={testValue} onChange={onChangeMock} />);
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',
},
];
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 (
<>
<h3 className="page-heading">Derived fields</h3>
<div className={styles.infoText}>
Derived fields can be used to extract new fields from the log message and create link from it's value.
</div>
<div className="gf-form-group">
{value &&
value.map((field, index) => {
return (
<DerivedField
className={styles.derivedField}
key={index}
value={field}
onChange={newField => {
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,
},
]}
/>
);
})}
<div>
<Button
variant={'inverse'}
className={css`
margin-right: 10px;
`}
icon="fa fa-plus"
onClick={event => {
event.preventDefault();
const newDerivedFields = [...(value || []), { name: '', matcherRegex: '' }];
onChange(newDerivedFields);
}}
>
Add
</Button>
{value && value.length > 0 && (
<Button variant="inverse" onClick={() => setShowDebug(!showDebug)}>
{showDebug ? 'Hide example log message' : 'Show example log message'}
</Button>
)}
</div>
</div>
{showDebug && (
<div className="gf-form-group">
<DebugSection
className={css`
margin-bottom: 10px;
`}
derivedFields={value}
/>
</div>
)}
</>
);
};
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 (
<FormField
label="Maximum lines"
labelWidth={11}
inputWidth={20}
inputEl={
<input
type="number"
className="gf-form-input width-8 gf-form-input--has-help-icon"
value={value}
onChange={event => 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.
</>
}
/>
);
};
// 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<LokiQuery, LokiOptions> {
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<LokiQuery, LokiOptions> {
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<string>([]),
};
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<LokiQuery>): DataQueryRequest<LokiQuery> {
......
......@@ -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)
......
......@@ -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;
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment