mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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.
This commit is contained in:
parent
9507eda9d1
commit
0a78652404
@ -28,11 +28,7 @@
|
|||||||
"value": "triggered"
|
"value": "triggered"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"colors": [
|
"colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"],
|
||||||
"#299c46",
|
|
||||||
"rgba(237, 129, 40, 0.89)",
|
|
||||||
"#d44a3a"
|
|
||||||
],
|
|
||||||
"d3DivId": "d3_svg_4",
|
"d3DivId": "d3_svg_4",
|
||||||
"datasource": "gdev-testdata",
|
"datasource": "gdev-testdata",
|
||||||
"decimals": 2,
|
"decimals": 2,
|
||||||
@ -115,11 +111,7 @@
|
|||||||
},
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"links": [],
|
"links": [],
|
||||||
"notcolors": [
|
"notcolors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"],
|
||||||
"rgba(245, 54, 54, 0.9)",
|
|
||||||
"rgba(237, 129, 40, 0.89)",
|
|
||||||
"rgba(50, 172, 45, 0.97)"
|
|
||||||
],
|
|
||||||
"operatorName": "avg",
|
"operatorName": "avg",
|
||||||
"operatorOptions": [
|
"operatorOptions": [
|
||||||
{
|
{
|
||||||
@ -1114,11 +1106,7 @@
|
|||||||
"value": "triggered"
|
"value": "triggered"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"colors": [
|
"colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"],
|
||||||
"#299c46",
|
|
||||||
"rgba(237, 129, 40, 0.89)",
|
|
||||||
"#d44a3a"
|
|
||||||
],
|
|
||||||
"d3DivId": "d3_svg_5",
|
"d3DivId": "d3_svg_5",
|
||||||
"datasource": "gdev-testdata",
|
"datasource": "gdev-testdata",
|
||||||
"decimals": 2,
|
"decimals": 2,
|
||||||
@ -1201,11 +1189,7 @@
|
|||||||
},
|
},
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"links": [],
|
"links": [],
|
||||||
"notcolors": [
|
"notcolors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"],
|
||||||
"rgba(245, 54, 54, 0.9)",
|
|
||||||
"rgba(237, 129, 40, 0.89)",
|
|
||||||
"rgba(50, 172, 45, 0.97)"
|
|
||||||
],
|
|
||||||
"operatorName": "avg",
|
"operatorName": "avg",
|
||||||
"operatorOptions": [
|
"operatorOptions": [
|
||||||
{
|
{
|
||||||
@ -2221,11 +2205,7 @@
|
|||||||
"value": "triggered"
|
"value": "triggered"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"colors": [
|
"colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"],
|
||||||
"#299c46",
|
|
||||||
"rgba(237, 129, 40, 0.89)",
|
|
||||||
"#d44a3a"
|
|
||||||
],
|
|
||||||
"d3DivId": "d3_svg_2",
|
"d3DivId": "d3_svg_2",
|
||||||
"datasource": "gdev-testdata",
|
"datasource": "gdev-testdata",
|
||||||
"decimals": 2,
|
"decimals": 2,
|
||||||
@ -2308,11 +2288,7 @@
|
|||||||
},
|
},
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"links": [],
|
"links": [],
|
||||||
"notcolors": [
|
"notcolors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"],
|
||||||
"rgba(245, 54, 54, 0.9)",
|
|
||||||
"rgba(237, 129, 40, 0.89)",
|
|
||||||
"rgba(50, 172, 45, 0.97)"
|
|
||||||
],
|
|
||||||
"operatorName": "avg",
|
"operatorName": "avg",
|
||||||
"operatorOptions": [
|
"operatorOptions": [
|
||||||
{
|
{
|
||||||
@ -3300,10 +3276,7 @@
|
|||||||
],
|
],
|
||||||
"schemaVersion": 16,
|
"schemaVersion": 16,
|
||||||
"style": "dark",
|
"style": "dark",
|
||||||
"tags": [
|
"tags": ["panel-test", "gdev"],
|
||||||
"panel-test",
|
|
||||||
"gdev"
|
|
||||||
],
|
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": []
|
"list": []
|
||||||
},
|
},
|
||||||
@ -3312,29 +3285,8 @@
|
|||||||
"to": "now"
|
"to": "now"
|
||||||
},
|
},
|
||||||
"timepicker": {
|
"timepicker": {
|
||||||
"refresh_intervals": [
|
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
|
||||||
"5s",
|
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
|
||||||
"10s",
|
|
||||||
"30s",
|
|
||||||
"1m",
|
|
||||||
"5m",
|
|
||||||
"15m",
|
|
||||||
"30m",
|
|
||||||
"1h",
|
|
||||||
"2h",
|
|
||||||
"1d"
|
|
||||||
],
|
|
||||||
"time_options": [
|
|
||||||
"5m",
|
|
||||||
"15m",
|
|
||||||
"1h",
|
|
||||||
"6h",
|
|
||||||
"12h",
|
|
||||||
"24h",
|
|
||||||
"2d",
|
|
||||||
"7d",
|
|
||||||
"30d"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "Panel Tests - Polystat",
|
"title": "Panel Tests - Polystat",
|
||||||
|
@ -17,7 +17,7 @@ export enum FieldType {
|
|||||||
/**
|
/**
|
||||||
* Every property is optional
|
* 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 {
|
export interface FieldConfig {
|
||||||
title?: string; // The display value for this field. This supports template variables blank is auto
|
title?: string; // The display value for this field. This supports template variables blank is auto
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Labels } from './data';
|
import { Labels } from './data';
|
||||||
import { GraphSeriesXY } from './graph';
|
import { GraphSeriesXY } from './graph';
|
||||||
|
import { DataFrame } from './dataFrame';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of log level abbreviation to canonical log level.
|
* Mapping of log level abbreviation to canonical log level.
|
||||||
@ -36,7 +37,19 @@ export interface LogsMetaItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LogRowModel {
|
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;
|
duplicates?: number;
|
||||||
|
|
||||||
|
// Actual log line
|
||||||
entry: string;
|
entry: string;
|
||||||
hasAnsi: boolean;
|
hasAnsi: boolean;
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
|
@ -1,5 +1,12 @@
|
|||||||
import { LogLevel } from '../types/logs';
|
import { LogLevel } from '../types/logs';
|
||||||
import { getLogLevel, calculateLogsLabelStats, calculateFieldStats, getParser, LogsParsers } from './logs';
|
import {
|
||||||
|
getLogLevel,
|
||||||
|
calculateLogsLabelStats,
|
||||||
|
calculateFieldStats,
|
||||||
|
getParser,
|
||||||
|
LogsParsers,
|
||||||
|
calculateStats,
|
||||||
|
} from './logs';
|
||||||
|
|
||||||
describe('getLoglevel()', () => {
|
describe('getLoglevel()', () => {
|
||||||
it('returns no log level on empty line', () => {
|
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()', () => {
|
describe('getParser()', () => {
|
||||||
test('should return no parser on empty line', () => {
|
test('should return no parser on empty line', () => {
|
||||||
expect(getParser('')).toBeUndefined();
|
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 } = {
|
export const LogsParsers: { [name: string]: LogsParser } = {
|
||||||
JSON: {
|
JSON: {
|
||||||
buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`),
|
buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`),
|
||||||
@ -128,14 +112,32 @@ export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): Log
|
|||||||
|
|
||||||
return match ? match[1] : null;
|
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 }))
|
.map((count, value) => ({ count, value, proportion: count / rowCount }))
|
||||||
.sortBy('count')
|
.sortBy('count')
|
||||||
.reverse()
|
.reverse()
|
||||||
.value();
|
.value();
|
||||||
|
};
|
||||||
return sortedCounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getParser(line: string): LogsParser | undefined {
|
export function getParser(line: string): LogsParser | undefined {
|
||||||
let parser;
|
let parser;
|
||||||
|
@ -22,6 +22,7 @@ interface DataLinkInputProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (url: string, callback?: () => void) => void;
|
onChange: (url: string, callback?: () => void) => void;
|
||||||
suggestions: VariableSuggestion[];
|
suggestions: VariableSuggestion[];
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = [
|
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
|
// 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.
|
// was used and changes to different state were propagated here.
|
||||||
export const DataLinkInput: React.FC<DataLinkInputProps> = memo(({ value, onChange, suggestions }) => {
|
export const DataLinkInput: React.FC<DataLinkInputProps> = memo(
|
||||||
const editorRef = useRef<Editor>() as RefObject<Editor>;
|
({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => {
|
||||||
const theme = useContext(ThemeContext);
|
const editorRef = useRef<Editor>() as RefObject<Editor>;
|
||||||
const styles = getStyles(theme);
|
const theme = useContext(ThemeContext);
|
||||||
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
const styles = getStyles(theme);
|
||||||
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
||||||
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
|
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
||||||
const prevLinkUrl = usePrevious<Value>(linkUrl);
|
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
|
||||||
|
const prevLinkUrl = usePrevious<Value>(linkUrl);
|
||||||
|
|
||||||
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
|
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
|
||||||
const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange });
|
const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange });
|
||||||
stateRef.current = { 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
|
// SelectionReference is used to position the variables suggestion relatively to current DOM selection
|
||||||
const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions, linkUrl]);
|
const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions, linkUrl]);
|
||||||
|
|
||||||
const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => {
|
const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => {
|
||||||
if (!stateRef.current.showingSuggestions) {
|
if (!stateRef.current.showingSuggestions) {
|
||||||
if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
|
if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
|
||||||
return setShowingSuggestions(true);
|
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:
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
switch (event.key) {
|
||||||
// Update the state of the link in the parent. This is basically done on blur but we need to do it after
|
case 'Backspace':
|
||||||
// our state have been updated. The duplicity of state is done for perf reasons and also because local
|
case 'Escape':
|
||||||
// state also contains things like selection and formating.
|
setShowingSuggestions(false);
|
||||||
if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) {
|
return setSuggestionsIndex(0);
|
||||||
stateRef.current.onChange(Plain.serialize(linkUrl));
|
|
||||||
}
|
|
||||||
}, [linkUrl, prevLinkUrl]);
|
|
||||||
|
|
||||||
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
|
case 'Enter':
|
||||||
setLinkUrl(value);
|
event.preventDefault();
|
||||||
}, []);
|
return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]);
|
||||||
|
|
||||||
const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => {
|
case 'ArrowDown':
|
||||||
const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$';
|
case 'ArrowUp':
|
||||||
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
|
event.preventDefault();
|
||||||
editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
|
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||||
} else {
|
return setSuggestionsIndex(index => modulo(index + direction, stateRef.current.suggestions.length));
|
||||||
editor.insertText(`var-${item.value}=$\{${item.value}}`);
|
default:
|
||||||
}
|
return next();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
setLinkUrl(editor.value);
|
useEffect(() => {
|
||||||
setShowingSuggestions(false);
|
// 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);
|
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
|
||||||
onChange(Plain.serialize(editor.value));
|
setLinkUrl(value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => {
|
||||||
<div
|
const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$';
|
||||||
className={cx(
|
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
|
||||||
'gf-form-input',
|
editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
|
||||||
css`
|
} else {
|
||||||
position: relative;
|
editor.insertText(`var-${item.value}=$\{${item.value}}`);
|
||||||
height: auto;
|
}
|
||||||
`
|
|
||||||
)}
|
setLinkUrl(editor.value);
|
||||||
>
|
setShowingSuggestions(false);
|
||||||
<div className="slate-query-field">
|
|
||||||
{showingSuggestions && (
|
setSuggestionsIndex(0);
|
||||||
<Portal>
|
stateRef.current.onChange(Plain.serialize(editor.value));
|
||||||
<ReactPopper
|
};
|
||||||
referenceElement={selectionRef}
|
|
||||||
placement="top-end"
|
return (
|
||||||
modifiers={{
|
<div
|
||||||
preventOverflow: { enabled: true, boundariesElement: 'window' },
|
className={cx(
|
||||||
arrow: { enabled: false },
|
'gf-form-input',
|
||||||
offset: { offset: 250 }, // width of the suggestions menu
|
css`
|
||||||
}}
|
position: relative;
|
||||||
>
|
height: auto;
|
||||||
{({ 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}
|
<div className="slate-query-field">
|
||||||
ref={editorRef}
|
{showingSuggestions && (
|
||||||
placeholder="http://your-grafana.com/d/000000010/annotations"
|
<Portal>
|
||||||
value={stateRef.current.linkUrl}
|
<ReactPopper
|
||||||
onChange={onUrlChange}
|
referenceElement={selectionRef}
|
||||||
onKeyDown={(event, _editor, next) => onKeyDown(event as KeyboardEvent, next)}
|
placement="top-end"
|
||||||
plugins={plugins}
|
modifiers={{
|
||||||
className={styles.editor}
|
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>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
DataLinkInput.displayName = 'DataLinkInput';
|
DataLinkInput.displayName = 'DataLinkInput';
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LogDetails, Props } from './LogDetails';
|
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 { mount } from 'enzyme';
|
||||||
|
import { LogDetailsRow } from './LogDetailsRow';
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
const setup = (propOverrides?: Partial<Props>, rowOverrides?: Partial<LogRowModel>) => {
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
theme: {} as GrafanaTheme,
|
theme: {} as GrafanaTheme,
|
||||||
row: {
|
row: {
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
logLevel: 'error' as LogLevel,
|
logLevel: 'error' as LogLevel,
|
||||||
timeFromNow: '',
|
timeFromNow: '',
|
||||||
timeEpochMs: 1546297200000,
|
timeEpochMs: 1546297200000,
|
||||||
@ -17,72 +21,102 @@ const setup = (propOverrides?: object) => {
|
|||||||
raw: '',
|
raw: '',
|
||||||
timestamp: '',
|
timestamp: '',
|
||||||
uid: '0',
|
uid: '0',
|
||||||
} as LogRowModel,
|
labels: {},
|
||||||
|
...(rowOverrides || {}),
|
||||||
|
},
|
||||||
getRows: () => [],
|
getRows: () => [],
|
||||||
onClickFilterLabel: () => {},
|
onClickFilterLabel: () => {},
|
||||||
onClickFilterOutLabel: () => {},
|
onClickFilterOutLabel: () => {},
|
||||||
|
...(propOverrides || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
return mount(<LogDetails {...props} />);
|
||||||
|
|
||||||
const wrapper = mount(<LogDetails {...props} />);
|
|
||||||
return wrapper;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('LogDetails', () => {
|
describe('LogDetails', () => {
|
||||||
describe('when labels are present', () => {
|
describe('when labels are present', () => {
|
||||||
it('should render heading', () => {
|
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);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import memoizeOne from 'memoize-one';
|
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 { Themeable } from '../../types/theme';
|
||||||
import { withTheme } from '../../themes/index';
|
import { withTheme } from '../../themes/index';
|
||||||
@ -9,33 +17,106 @@ import { getLogRowStyles } from './getLogRowStyles';
|
|||||||
//Components
|
//Components
|
||||||
import { LogDetailsRow } from './LogDetailsRow';
|
import { LogDetailsRow } from './LogDetailsRow';
|
||||||
|
|
||||||
|
type FieldDef = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
links?: string[];
|
||||||
|
fieldIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Props extends Themeable {
|
export interface Props extends Themeable {
|
||||||
row: LogRowModel;
|
row: LogRowModel;
|
||||||
getRows: () => LogRowModel[];
|
getRows: () => LogRowModel[];
|
||||||
onClickFilterLabel?: (key: string, value: string) => void;
|
onClickFilterLabel?: (key: string, value: string) => void;
|
||||||
onClickFilterOutLabel?: (key: string, value: string) => void;
|
onClickFilterOutLabel?: (key: string, value: string) => void;
|
||||||
|
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnThemedLogDetails extends PureComponent<Props> {
|
class UnThemedLogDetails extends PureComponent<Props> {
|
||||||
|
getParser = memoizeOne(getParser);
|
||||||
|
|
||||||
parseMessage = memoizeOne(
|
parseMessage = memoizeOne(
|
||||||
(rowEntry): { parsedFields: string[]; parser?: LogsParser } => {
|
(rowEntry): FieldDef[] => {
|
||||||
const parser = getParser(rowEntry);
|
const parser = this.getParser(rowEntry);
|
||||||
if (!parser) {
|
if (!parser) {
|
||||||
return { parsedFields: [] };
|
return [];
|
||||||
}
|
}
|
||||||
// Use parser to highlight detected fields
|
// Use parser to highlight detected fields
|
||||||
const parsedFields = parser.getFields(rowEntry);
|
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() {
|
render() {
|
||||||
const { row, theme, onClickFilterOutLabel, onClickFilterLabel, getRows } = this.props;
|
const { row, theme, onClickFilterOutLabel, onClickFilterLabel, getRows } = this.props;
|
||||||
const style = getLogRowStyles(theme, row.logLevel);
|
const style = getLogRowStyles(theme, row.logLevel);
|
||||||
const labels = row.labels ? row.labels : {};
|
const labels = row.labels ? row.labels : {};
|
||||||
const labelsAvailable = Object.keys(labels).length > 0;
|
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 (
|
return (
|
||||||
<div className={style.logsRowDetailsTable}>
|
<div className={style.logsRowDetailsTable}>
|
||||||
@ -46,16 +127,13 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
|||||||
</div>
|
</div>
|
||||||
{Object.keys(labels).map(key => {
|
{Object.keys(labels).map(key => {
|
||||||
const value = labels[key];
|
const value = labels[key];
|
||||||
const field = `${key}=${value}`;
|
|
||||||
return (
|
return (
|
||||||
<LogDetailsRow
|
<LogDetailsRow
|
||||||
key={`${key}=${value}`}
|
key={`${key}=${value}`}
|
||||||
parsedKey={key}
|
parsedKey={key}
|
||||||
parsedValue={value}
|
parsedValue={value}
|
||||||
field={field}
|
|
||||||
row={row}
|
|
||||||
getRows={getRows}
|
|
||||||
isLabel={true}
|
isLabel={true}
|
||||||
|
getStats={() => calculateLogsLabelStats(getRows(), key)}
|
||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||||
onClickFilterLabel={onClickFilterLabel}
|
onClickFilterLabel={onClickFilterLabel}
|
||||||
/>
|
/>
|
||||||
@ -69,23 +147,22 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
|||||||
<div className={style.logsRowDetailsHeading} aria-label="Parsed fields">
|
<div className={style.logsRowDetailsHeading} aria-label="Parsed fields">
|
||||||
Parsed fields:
|
Parsed fields:
|
||||||
</div>
|
</div>
|
||||||
{parsedFields &&
|
{fields.map(field => {
|
||||||
parsedFields.map(field => {
|
const { key, value, links, fieldIndex } = field;
|
||||||
const key = parser!.getLabelFromField(field);
|
return (
|
||||||
const value = parser!.getValueFromField(field);
|
<LogDetailsRow
|
||||||
return (
|
key={`${key}=${value}`}
|
||||||
<LogDetailsRow
|
parsedKey={key}
|
||||||
key={`${key}=${value}`}
|
parsedValue={value}
|
||||||
parsedKey={key}
|
links={links}
|
||||||
parsedValue={value}
|
getStats={() =>
|
||||||
field={field}
|
fieldIndex === undefined
|
||||||
row={row}
|
? this.getStatsForParsedField(key)
|
||||||
isLabel={false}
|
: calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())
|
||||||
getRows={getRows}
|
}
|
||||||
parser={parser}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!parsedFieldsAvailable && !labelsAvailable && <div aria-label="No details">No details available</div>}
|
{!parsedFieldsAvailable && !labelsAvailable && <div aria-label="No details">No details available</div>}
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LogDetailsRow, Props } from './LogDetailsRow';
|
import { LogDetailsRow, Props } from './LogDetailsRow';
|
||||||
import { LogRowModel, LogsParser, GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { mount } from 'enzyme';
|
import { mount } from 'enzyme';
|
||||||
|
import { LogLabelStats } from './LogLabelStats';
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
const setup = (propOverrides?: Partial<Props>) => {
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
theme: {} as GrafanaTheme,
|
theme: {} as GrafanaTheme,
|
||||||
parsedValue: '',
|
parsedValue: '',
|
||||||
parsedKey: '',
|
parsedKey: '',
|
||||||
field: '',
|
|
||||||
isLabel: true,
|
isLabel: true,
|
||||||
parser: {} as LogsParser,
|
getStats: () => null,
|
||||||
row: {} as LogRowModel,
|
|
||||||
getRows: () => [],
|
|
||||||
onClickFilterLabel: () => {},
|
onClickFilterLabel: () => {},
|
||||||
onClickFilterOutLabel: () => {},
|
onClickFilterOutLabel: () => {},
|
||||||
};
|
};
|
||||||
@ -27,11 +25,11 @@ describe('LogDetailsRow', () => {
|
|||||||
it('should render parsed key', () => {
|
it('should render parsed key', () => {
|
||||||
const wrapper = setup({ parsedKey: 'test key' });
|
const wrapper = setup({ parsedKey: 'test key' });
|
||||||
expect(wrapper.text().includes('test key')).toBe(true);
|
expect(wrapper.text().includes('test key')).toBe(true);
|
||||||
}),
|
});
|
||||||
it('should render parsed value', () => {
|
it('should render parsed value', () => {
|
||||||
const wrapper = setup({ parsedValue: 'test value' });
|
const wrapper = setup({ parsedValue: 'test value' });
|
||||||
expect(wrapper.text().includes('test value')).toBe(true);
|
expect(wrapper.text().includes('test value')).toBe(true);
|
||||||
});
|
});
|
||||||
it('should render metrics button', () => {
|
it('should render metrics button', () => {
|
||||||
const wrapper = setup();
|
const wrapper = setup();
|
||||||
expect(wrapper.find('i.fa-signal')).toHaveLength(1);
|
expect(wrapper.find('i.fa-signal')).toHaveLength(1);
|
||||||
@ -40,10 +38,36 @@ describe('LogDetailsRow', () => {
|
|||||||
it('should render filter label button', () => {
|
it('should render filter label button', () => {
|
||||||
const wrapper = setup();
|
const wrapper = setup();
|
||||||
expect(wrapper.find('i.fa-search-plus')).toHaveLength(1);
|
expect(wrapper.find('i.fa-search-plus')).toHaveLength(1);
|
||||||
}),
|
});
|
||||||
it('should render filte out label button', () => {
|
it('should render filter out label button', () => {
|
||||||
const wrapper = setup();
|
const wrapper = setup();
|
||||||
expect(wrapper.find('i.fa-search-minus')).toHaveLength(1);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,5 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import {
|
import { LogLabelStatsModel } from '@grafana/data';
|
||||||
LogRowModel,
|
|
||||||
LogsParser,
|
|
||||||
LogLabelStatsModel,
|
|
||||||
calculateFieldStats,
|
|
||||||
calculateLogsLabelStats,
|
|
||||||
} from '@grafana/data';
|
|
||||||
|
|
||||||
import { Themeable } from '../../types/theme';
|
import { Themeable } from '../../types/theme';
|
||||||
import { withTheme } from '../../themes/index';
|
import { withTheme } from '../../themes/index';
|
||||||
@ -17,30 +11,24 @@ import { LogLabelStats } from './LogLabelStats';
|
|||||||
export interface Props extends Themeable {
|
export interface Props extends Themeable {
|
||||||
parsedValue: string;
|
parsedValue: string;
|
||||||
parsedKey: string;
|
parsedKey: string;
|
||||||
field: string;
|
isLabel?: boolean;
|
||||||
row: LogRowModel;
|
|
||||||
isLabel: boolean;
|
|
||||||
parser?: LogsParser;
|
|
||||||
getRows: () => LogRowModel[];
|
|
||||||
onClickFilterLabel?: (key: string, value: string) => void;
|
onClickFilterLabel?: (key: string, value: string) => void;
|
||||||
onClickFilterOutLabel?: (key: string, value: string) => void;
|
onClickFilterOutLabel?: (key: string, value: string) => void;
|
||||||
|
links?: string[];
|
||||||
|
getStats: () => LogLabelStatsModel[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
showFieldsStats: boolean;
|
showFieldsStats: boolean;
|
||||||
fieldCount: number;
|
fieldCount: number;
|
||||||
fieldLabel: string | null;
|
|
||||||
fieldStats: LogLabelStatsModel[] | null;
|
fieldStats: LogLabelStatsModel[] | null;
|
||||||
fieldValue: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
||||||
state: State = {
|
state: State = {
|
||||||
showFieldsStats: false,
|
showFieldsStats: false,
|
||||||
fieldCount: 0,
|
fieldCount: 0,
|
||||||
fieldLabel: null,
|
|
||||||
fieldStats: null,
|
fieldStats: null,
|
||||||
fieldValue: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
filterLabel = () => {
|
filterLabel = () => {
|
||||||
@ -60,7 +48,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
|||||||
showStats = () => {
|
showStats = () => {
|
||||||
const { showFieldsStats } = this.state;
|
const { showFieldsStats } = this.state;
|
||||||
if (!showFieldsStats) {
|
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();
|
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() {
|
render() {
|
||||||
const { theme, parsedKey, parsedValue, isLabel } = this.props;
|
const { theme, parsedKey, parsedValue, isLabel, links } = this.props;
|
||||||
const { showFieldsStats, fieldStats, fieldLabel, fieldValue, fieldCount } = this.state;
|
const { showFieldsStats, fieldStats, fieldCount } = this.state;
|
||||||
const style = getLogRowStyles(theme);
|
const style = getLogRowStyles(theme);
|
||||||
return (
|
return (
|
||||||
<div className={style.logsRowDetailsValue}>
|
<div className={style.logsRowDetailsValue}>
|
||||||
{/* Action buttons - show stats/filter results */}
|
{/* 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'} />
|
<i className={'fa fa-signal'} />
|
||||||
</div>
|
</div>
|
||||||
{isLabel ? (
|
{isLabel ? (
|
||||||
@ -120,12 +94,23 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.logsRowCell}>
|
<div className={style.logsRowCell}>
|
||||||
<span>{parsedValue}</span>
|
<span>{parsedValue}</span>
|
||||||
|
{links &&
|
||||||
|
links.map(link => {
|
||||||
|
return (
|
||||||
|
<span key={link}>
|
||||||
|
|
||||||
|
<a href={link} target={'_blank'}>
|
||||||
|
<i className={'fa fa-external-link'} />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{showFieldsStats && (
|
{showFieldsStats && (
|
||||||
<div className={style.logsRowCell}>
|
<div className={style.logsRowCell}>
|
||||||
<LogLabelStats
|
<LogLabelStats
|
||||||
stats={fieldStats!}
|
stats={fieldStats!}
|
||||||
label={fieldLabel!}
|
label={parsedKey}
|
||||||
value={fieldValue!}
|
value={parsedValue}
|
||||||
rowCount={fieldCount}
|
rowCount={fieldCount}
|
||||||
isLabel={isLabel}
|
isLabel={isLabel}
|
||||||
/>
|
/>
|
||||||
|
@ -58,7 +58,7 @@ interface Props extends Themeable {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
isLabel: boolean;
|
isLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnThemedLogLabelStats extends PureComponent<Props> {
|
class UnThemedLogLabelStats extends PureComponent<Props> {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data';
|
import { Field, LinkModel, LogRowModel, TimeZone, DataQueryResponse } from '@grafana/data';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LogRowContextRows,
|
LogRowContextRows,
|
||||||
@ -27,6 +27,7 @@ interface Props extends Themeable {
|
|||||||
onClickFilterOutLabel?: (key: string, value: string) => void;
|
onClickFilterOutLabel?: (key: string, value: string) => void;
|
||||||
onContextClick?: () => void;
|
onContextClick?: () => void;
|
||||||
getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
|
getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
|
||||||
|
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -80,6 +81,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
timeZone,
|
timeZone,
|
||||||
showTime,
|
showTime,
|
||||||
theme,
|
theme,
|
||||||
|
getFieldLinks,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { showDetails, showContext } = this.state;
|
const { showDetails, showContext } = this.state;
|
||||||
const style = getLogRowStyles(theme, row.logLevel);
|
const style = getLogRowStyles(theme, row.logLevel);
|
||||||
@ -124,6 +126,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
{this.state.showDetails && (
|
{this.state.showDetails && (
|
||||||
<LogDetails
|
<LogDetails
|
||||||
|
getFieldLinks={getFieldLinks}
|
||||||
onClickFilterLabel={onClickFilterLabel}
|
onClickFilterLabel={onClickFilterLabel}
|
||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||||
getRows={getRows}
|
getRows={getRows}
|
||||||
|
@ -21,6 +21,9 @@ describe('getRowContexts', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const row: LogRowModel = {
|
const row: LogRowModel = {
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
entry: '4',
|
entry: '4',
|
||||||
labels: (null as any) as Labels,
|
labels: (null as any) as Labels,
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
@ -54,6 +57,9 @@ describe('getRowContexts', () => {
|
|||||||
const firstError = new Error('Error 1');
|
const firstError = new Error('Error 1');
|
||||||
const secondError = new Error('Error 2');
|
const secondError = new Error('Error 2');
|
||||||
const row: LogRowModel = {
|
const row: LogRowModel = {
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
entry: '4',
|
entry: '4',
|
||||||
labels: (null as any) as Labels,
|
labels: (null as any) as Labels,
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { range } from 'lodash';
|
import { range } from 'lodash';
|
||||||
import { LogRows, PREVIEW_LIMIT } from './LogRows';
|
import { LogRows, PREVIEW_LIMIT } from './LogRows';
|
||||||
import { mount } from 'enzyme';
|
import { mount } from 'enzyme';
|
||||||
import { LogLevel, LogRowModel, LogsDedupStrategy } from '@grafana/data';
|
import { LogLevel, LogRowModel, LogsDedupStrategy, MutableDataFrame } from '@grafana/data';
|
||||||
import { LogRow } from './LogRow';
|
import { LogRow } from './LogRow';
|
||||||
|
|
||||||
describe('LogRows', () => {
|
describe('LogRows', () => {
|
||||||
@ -87,10 +87,14 @@ describe('LogRows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeLog = (overides: Partial<LogRowModel>): LogRowModel => {
|
const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => {
|
||||||
const uid = overides.uid || '1';
|
const uid = overrides.uid || '1';
|
||||||
const entry = `log message ${uid}`;
|
const entry = `log message ${uid}`;
|
||||||
return {
|
return {
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
// Does not need to be filled with current tests
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
uid,
|
uid,
|
||||||
logLevel: LogLevel.debug,
|
logLevel: LogLevel.debug,
|
||||||
entry,
|
entry,
|
||||||
@ -103,6 +107,6 @@ const makeLog = (overides: Partial<LogRowModel>): LogRowModel => {
|
|||||||
timeLocal: '',
|
timeLocal: '',
|
||||||
timeUtc: '',
|
timeUtc: '',
|
||||||
searchWords: [],
|
searchWords: [],
|
||||||
...overides,
|
...overrides,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import memoizeOne from 'memoize-one';
|
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 { Themeable } from '../../types/theme';
|
||||||
import { withTheme } from '../../themes/index';
|
import { withTheme } from '../../themes/index';
|
||||||
@ -25,6 +25,7 @@ export interface Props extends Themeable {
|
|||||||
onClickFilterLabel?: (key: string, value: string) => void;
|
onClickFilterLabel?: (key: string, value: string) => void;
|
||||||
onClickFilterOutLabel?: (key: string, value: string) => void;
|
onClickFilterOutLabel?: (key: string, value: string) => void;
|
||||||
getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
|
getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
|
||||||
|
getFieldLinks?: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -80,6 +81,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
theme,
|
theme,
|
||||||
isLogsPanel,
|
isLogsPanel,
|
||||||
previewLimit,
|
previewLimit,
|
||||||
|
getFieldLinks,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { renderAll } = this.state;
|
const { renderAll } = this.state;
|
||||||
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
|
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
|
||||||
@ -116,6 +118,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
isLogsPanel={isLogsPanel}
|
isLogsPanel={isLogsPanel}
|
||||||
onClickFilterLabel={onClickFilterLabel}
|
onClickFilterLabel={onClickFilterLabel}
|
||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||||
|
getFieldLinks={getFieldLinks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasData &&
|
{hasData &&
|
||||||
@ -132,6 +135,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
|||||||
isLogsPanel={isLogsPanel}
|
isLogsPanel={isLogsPanel}
|
||||||
onClickFilterLabel={onClickFilterLabel}
|
onClickFilterLabel={onClickFilterLabel}
|
||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||||
|
getFieldLinks={getFieldLinks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasData && !renderAll && <span>Rendering {rowCount - previewLimit!} rows...</span>}
|
{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 { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu';
|
||||||
export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions';
|
export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions';
|
||||||
export { DataLinksEditor } from './DataLinks/DataLinksEditor';
|
export { DataLinksEditor } from './DataLinks/DataLinksEditor';
|
||||||
|
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||||
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
|
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
|
||||||
export { SeriesIcon } from './Legend/SeriesIcon';
|
export { SeriesIcon } from './Legend/SeriesIcon';
|
||||||
export { transformersUIRegistry } from './TransformersUI/transformers';
|
export { transformersUIRegistry } from './TransformersUI/transformers';
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
toDataFrame,
|
toDataFrame,
|
||||||
LogRowModel,
|
LogRowModel,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { dedupLogRows, dataFrameToLogsModel } from '../logs_model';
|
import { dedupLogRows, dataFrameToLogsModel } from './logs_model';
|
||||||
|
|
||||||
describe('dedupLogRows()', () => {
|
describe('dedupLogRows()', () => {
|
||||||
test('should return rows as is when dedup is set to none', () => {
|
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);
|
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 {
|
export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number): LogsModel {
|
||||||
const metricSeries: DataFrame[] = [];
|
const { logSeries, metricSeries } = separateLogsAndMetrics(dataFrame);
|
||||||
const logSeries: DataFrame[] = [];
|
|
||||||
|
|
||||||
for (const series of dataFrame) {
|
|
||||||
if (isLogsData(series)) {
|
|
||||||
logSeries.push(series);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
metricSeries.push(series);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logsModel = logSeriesToLogsModel(logSeries);
|
const logsModel = logSeriesToLogsModel(logSeries);
|
||||||
|
|
||||||
if (logsModel) {
|
if (logsModel) {
|
||||||
if (metricSeries.length === 0) {
|
if (metricSeries.length === 0) {
|
||||||
|
// Create metrics from logs
|
||||||
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs);
|
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs);
|
||||||
} else {
|
} else {
|
||||||
|
// We got metrics in the dataFrame so process those
|
||||||
logsModel.series = getGraphSeriesModel(
|
logsModel.series = getGraphSeriesModel(
|
||||||
metricSeries,
|
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) {
|
if (logSeries.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const commonLabels = findCommonLabelsFromDataFrames(logSeries);
|
||||||
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 rows: LogRowModel[] = [];
|
const rows: LogRowModel[] = [];
|
||||||
let hasUniqueLabels = false;
|
let hasUniqueLabels = false;
|
||||||
@ -236,6 +244,8 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
|
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 stringField = fieldCache.getFirstFieldOfType(FieldType.string);
|
||||||
const logLevelField = fieldCache.getFieldByName('level');
|
const logLevelField = fieldCache.getFieldByName('level');
|
||||||
const idField = getIdField(fieldCache);
|
const idField = getIdField(fieldCache);
|
||||||
@ -248,14 +258,13 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel {
|
|||||||
for (let j = 0; j < series.length; j++) {
|
for (let j = 0; j < series.length; j++) {
|
||||||
const ts = timeField.values.get(j);
|
const ts = timeField.values.get(j);
|
||||||
const time = dateTime(ts);
|
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.
|
// 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;
|
let logLevel = LogLevel.unknown;
|
||||||
if (logLevelField) {
|
if (logLevelField) {
|
||||||
@ -265,15 +274,16 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel {
|
|||||||
} else {
|
} else {
|
||||||
logLevel = getLogLevel(message);
|
logLevel = getLogLevel(message);
|
||||||
}
|
}
|
||||||
const hasAnsi = hasAnsiCodes(message);
|
|
||||||
const searchWords = series.meta && series.meta.searchWords ? series.meta.searchWords : [];
|
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
|
entryFieldIndex: stringField.index,
|
||||||
|
rowIndex: j,
|
||||||
|
dataFrame: series,
|
||||||
logLevel,
|
logLevel,
|
||||||
timeFromNow,
|
timeFromNow: time.fromNow(),
|
||||||
timeEpochMs,
|
timeEpochMs: time.valueOf(),
|
||||||
timeLocal,
|
timeLocal: time.format(logTimeFormat),
|
||||||
timeUtc,
|
timeUtc: toUtc(ts).format(logTimeFormat),
|
||||||
uniqueLabels,
|
uniqueLabels,
|
||||||
hasAnsi,
|
hasAnsi,
|
||||||
searchWords,
|
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 {
|
function getIdField(fieldCache: FieldCache): FieldWithIndex | undefined {
|
||||||
const idFieldNames = ['id'];
|
const idFieldNames = ['id'];
|
||||||
for (const fieldName of idFieldNames) {
|
for (const fieldName of idFieldNames) {
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
} from './explore';
|
} from './explore';
|
||||||
import { ExploreUrlState, ExploreMode } from 'app/types/explore';
|
import { ExploreUrlState, ExploreMode } from 'app/types/explore';
|
||||||
import store from 'app/core/store';
|
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';
|
import { RefreshPicker } from '@grafana/ui';
|
||||||
|
|
||||||
const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
|
const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
|
||||||
@ -373,6 +373,9 @@ describe('refreshIntervalToSortOrder', () => {
|
|||||||
|
|
||||||
describe('sortLogsResult', () => {
|
describe('sortLogsResult', () => {
|
||||||
const firstRow = {
|
const firstRow = {
|
||||||
|
rowIndex: 0,
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
timestamp: '2019-01-01T21:00:0.0000000Z',
|
timestamp: '2019-01-01T21:00:0.0000000Z',
|
||||||
entry: '',
|
entry: '',
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
@ -387,6 +390,9 @@ describe('sortLogsResult', () => {
|
|||||||
};
|
};
|
||||||
const sameAsFirstRow = firstRow;
|
const sameAsFirstRow = firstRow;
|
||||||
const secondRow = {
|
const secondRow = {
|
||||||
|
rowIndex: 1,
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
timestamp: '2019-01-01T22:00:0.0000000Z',
|
timestamp: '2019-01-01T22:00:0.0000000Z',
|
||||||
entry: '',
|
entry: '',
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LogLevel, LogRowModel } from '@grafana/data';
|
import { LogLevel, LogRowModel, MutableDataFrame } from '@grafana/data';
|
||||||
import { mount } from 'enzyme';
|
import { mount } from 'enzyme';
|
||||||
import { LiveLogsWithTheme } from './LiveLogs';
|
import { LiveLogsWithTheme } from './LiveLogs';
|
||||||
|
|
||||||
@ -62,6 +62,9 @@ const makeLog = (overides: Partial<LogRowModel>): LogRowModel => {
|
|||||||
const entry = `log message ${uid}`;
|
const entry = `log message ${uid}`;
|
||||||
return {
|
return {
|
||||||
uid,
|
uid,
|
||||||
|
entryFieldIndex: 0,
|
||||||
|
rowIndex: 0,
|
||||||
|
dataFrame: new MutableDataFrame(),
|
||||||
logLevel: LogLevel.debug,
|
logLevel: LogLevel.debug,
|
||||||
entry,
|
entry,
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
|
@ -12,6 +12,8 @@ import {
|
|||||||
LogsDedupDescription,
|
LogsDedupDescription,
|
||||||
LogsMetaItem,
|
LogsMetaItem,
|
||||||
GraphSeriesXY,
|
GraphSeriesXY,
|
||||||
|
LinkModel,
|
||||||
|
Field,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Switch, LogLabels, ToggleButtonGroup, ToggleButton, LogRows } from '@grafana/ui';
|
import { Switch, LogLabels, ToggleButtonGroup, ToggleButton, LogRows } from '@grafana/ui';
|
||||||
|
|
||||||
@ -50,6 +52,7 @@ interface Props {
|
|||||||
onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void;
|
onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void;
|
||||||
onToggleLogLevel: (hiddenLogLevels: LogLevel[]) => void;
|
onToggleLogLevel: (hiddenLogLevels: LogLevel[]) => void;
|
||||||
getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
|
getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
|
||||||
|
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -113,6 +116,7 @@ export class Logs extends PureComponent<Props, State> {
|
|||||||
dedupedRows,
|
dedupedRows,
|
||||||
absoluteRange,
|
absoluteRange,
|
||||||
onChangeTime,
|
onChangeTime,
|
||||||
|
getFieldLinks,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!logRows) {
|
if (!logRows) {
|
||||||
@ -199,6 +203,7 @@ export class Logs extends PureComponent<Props, State> {
|
|||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
|
getFieldLinks={getFieldLinks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!loading && !hasData && !scanning && (
|
{!loading && !hasData && !scanning && (
|
||||||
|
@ -27,6 +27,7 @@ import { LiveLogsWithTheme } from './LiveLogs';
|
|||||||
import { Logs } from './Logs';
|
import { Logs } from './Logs';
|
||||||
import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition';
|
import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition';
|
||||||
import { LiveTailControls } from './useLiveTailControls';
|
import { LiveTailControls } from './useLiveTailControls';
|
||||||
|
import { getLinksFromLogsField } from '../panel/panellinks/linkSuppliers';
|
||||||
|
|
||||||
interface LogsContainerProps {
|
interface LogsContainerProps {
|
||||||
datasourceInstance: DataSourceApi | null;
|
datasourceInstance: DataSourceApi | null;
|
||||||
@ -148,6 +149,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
|
|||||||
scanRange={range.raw}
|
scanRange={range.raw}
|
||||||
width={width}
|
width={width}
|
||||||
getRowContext={this.getLogRowContext}
|
getRowContext={this.getLogRowContext}
|
||||||
|
getFieldLinks={getLinksFromLogsField}
|
||||||
/>
|
/>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</LogsCrossFadeTransition>
|
</LogsCrossFadeTransition>
|
||||||
|
@ -137,7 +137,8 @@ describe('ResultProcessor', () => {
|
|||||||
|
|
||||||
describe('when calling getLogsResult', () => {
|
describe('when calling getLogsResult', () => {
|
||||||
it('then it should return correct logs result', () => {
|
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();
|
const theResult = resultProcessor.getLogsResult();
|
||||||
|
|
||||||
expect(theResult).toEqual({
|
expect(theResult).toEqual({
|
||||||
@ -145,7 +146,10 @@ describe('ResultProcessor', () => {
|
|||||||
meta: [],
|
meta: [],
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
|
rowIndex: 2,
|
||||||
|
dataFrame: logsDataFrame,
|
||||||
entry: 'third',
|
entry: 'third',
|
||||||
|
entryFieldIndex: 2,
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
labels: undefined,
|
labels: undefined,
|
||||||
logLevel: 'unknown',
|
logLevel: 'unknown',
|
||||||
@ -160,7 +164,10 @@ describe('ResultProcessor', () => {
|
|||||||
uniqueLabels: {},
|
uniqueLabels: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
rowIndex: 1,
|
||||||
|
dataFrame: logsDataFrame,
|
||||||
entry: 'second message',
|
entry: 'second message',
|
||||||
|
entryFieldIndex: 2,
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
labels: undefined,
|
labels: undefined,
|
||||||
logLevel: 'unknown',
|
logLevel: 'unknown',
|
||||||
@ -175,7 +182,10 @@ describe('ResultProcessor', () => {
|
|||||||
uniqueLabels: {},
|
uniqueLabels: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
rowIndex: 0,
|
||||||
|
dataFrame: logsDataFrame,
|
||||||
entry: 'this is a message',
|
entry: 'this is a message',
|
||||||
|
entryFieldIndex: 2,
|
||||||
hasAnsi: false,
|
hasAnsi: false,
|
||||||
labels: undefined,
|
labels: undefined,
|
||||||
logLevel: 'unknown',
|
logLevel: 'unknown',
|
||||||
|
61
public/app/features/panel/panellinks/linkSuppliers.test.ts
Normal file
61
public/app/features/panel/panellinks/linkSuppliers.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -1,6 +1,14 @@
|
|||||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||||
import { FieldDisplay } from '@grafana/data';
|
import {
|
||||||
import { LinkModelSupplier, getTimeField, Labels, ScopedVars, ScopedVar } from '@grafana/data';
|
FieldDisplay,
|
||||||
|
LinkModelSupplier,
|
||||||
|
getTimeField,
|
||||||
|
Labels,
|
||||||
|
ScopedVars,
|
||||||
|
ScopedVar,
|
||||||
|
Field,
|
||||||
|
LinkModel,
|
||||||
|
} from '@grafana/data';
|
||||||
import { getLinkSrv } from './link_srv';
|
import { getLinkSrv } from './link_srv';
|
||||||
|
|
||||||
interface SeriesVars {
|
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;
|
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 params: KeyValue = {};
|
||||||
const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
|
const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { mount } from 'enzyme';
|
|||||||
import { ConfigEditor } from './ConfigEditor';
|
import { ConfigEditor } from './ConfigEditor';
|
||||||
import { createDefaultConfigOptions } from '../mocks';
|
import { createDefaultConfigOptions } from '../mocks';
|
||||||
import { DataSourceHttpSettings } from '@grafana/ui';
|
import { DataSourceHttpSettings } from '@grafana/ui';
|
||||||
|
import { DerivedFields } from './DerivedFields';
|
||||||
|
|
||||||
describe('ConfigEditor', () => {
|
describe('ConfigEditor', () => {
|
||||||
it('should render without error', () => {
|
it('should render without error', () => {
|
||||||
@ -13,6 +14,7 @@ describe('ConfigEditor', () => {
|
|||||||
const wrapper = mount(<ConfigEditor onOptionsChange={() => {}} options={createDefaultConfigOptions()} />);
|
const wrapper = mount(<ConfigEditor onOptionsChange={() => {}} options={createDefaultConfigOptions()} />);
|
||||||
expect(wrapper.find(DataSourceHttpSettings).length).toBe(1);
|
expect(wrapper.find(DataSourceHttpSettings).length).toBe(1);
|
||||||
expect(wrapper.find({ label: 'Maximum lines' }).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', () => {
|
it('should pass correct data to onChange', () => {
|
@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data';
|
import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data';
|
||||||
import { FormField, DataSourceHttpSettings } from '@grafana/ui';
|
import { DataSourceHttpSettings } from '@grafana/ui';
|
||||||
import { LokiOptions } from '../types';
|
import { LokiOptions } from '../types';
|
||||||
|
import { MaxLinesField } from './MaxLinesField';
|
||||||
|
import { DerivedFields } from './DerivedFields';
|
||||||
|
|
||||||
export type Props = DataSourcePluginOptionsEditorProps<LokiOptions>;
|
export type Props = DataSourcePluginOptionsEditorProps<LokiOptions>;
|
||||||
|
|
||||||
@ -19,6 +21,7 @@ const makeJsonUpdater = <T extends any>(field: keyof LokiOptions) => (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setMaxLines = makeJsonUpdater('maxLines');
|
const setMaxLines = makeJsonUpdater('maxLines');
|
||||||
|
const setDerivedFields = makeJsonUpdater('derivedFields');
|
||||||
|
|
||||||
export const ConfigEditor = (props: Props) => {
|
export const ConfigEditor = (props: Props) => {
|
||||||
const { options, onOptionsChange } = props;
|
const { options, onOptionsChange } = props;
|
||||||
@ -42,39 +45,11 @@ export const ConfigEditor = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DerivedFields
|
||||||
|
value={options.jsonData.derivedFields}
|
||||||
|
onChange={value => onOptionsChange(setDerivedFields(options, value))}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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 (
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -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(<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();
|
||||||
|
});
|
||||||
|
});
|
@ -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<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>
|
||||||
|
);
|
||||||
|
};
|
@ -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(<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',
|
||||||
|
},
|
||||||
|
];
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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 (
|
||||||
|
<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.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,5 +1,5 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import { isEmpty, isString } from 'lodash';
|
import { isEmpty, isString, fromPairs } from 'lodash';
|
||||||
// Services & Utils
|
// Services & Utils
|
||||||
import {
|
import {
|
||||||
dateMath,
|
dateMath,
|
||||||
@ -9,6 +9,16 @@ import {
|
|||||||
AnnotationEvent,
|
AnnotationEvent,
|
||||||
DataFrameView,
|
DataFrameView,
|
||||||
LoadingState,
|
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,
|
PluginMeta,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
@ -17,11 +27,6 @@ import {
|
|||||||
DataQueryResponse,
|
DataQueryResponse,
|
||||||
AnnotationQueryRequest,
|
AnnotationQueryRequest,
|
||||||
} from '@grafana/data';
|
} 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 { LokiQuery, LokiOptions, LokiLogsStream, LokiResponse } from './types';
|
||||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
@ -154,6 +159,7 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
|
|||||||
data = data as LokiResponse;
|
data = data as LokiResponse;
|
||||||
for (const stream of data.streams || []) {
|
for (const stream of data.streams || []) {
|
||||||
const dataFrame = logStreamToDataFrame(stream);
|
const dataFrame = logStreamToDataFrame(stream);
|
||||||
|
this.enhanceDataFrame(dataFrame);
|
||||||
dataFrame.refId = target.refId;
|
dataFrame.refId = target.refId;
|
||||||
dataFrame.meta = {
|
dataFrame.meta = {
|
||||||
searchWords: getHighlighterExpressionsFromQuery(formatQuery(target.query, target.regexp)),
|
searchWords: getHighlighterExpressionsFromQuery(formatQuery(target.query, target.regexp)),
|
||||||
@ -405,6 +411,51 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
|
|||||||
|
|
||||||
return annotations;
|
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> {
|
function queryRequestFromAnnotationOptions(options: AnnotationQueryRequest<LokiQuery>): DataQueryRequest<LokiQuery> {
|
||||||
|
@ -5,7 +5,7 @@ import LokiCheatSheet from './components/LokiCheatSheet';
|
|||||||
import LokiQueryField from './components/LokiQueryField';
|
import LokiQueryField from './components/LokiQueryField';
|
||||||
import LokiQueryEditor from './components/LokiQueryEditor';
|
import LokiQueryEditor from './components/LokiQueryEditor';
|
||||||
import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl';
|
import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl';
|
||||||
import { ConfigEditor } from './components/ConfigEditor';
|
import { ConfigEditor } from './configuration/ConfigEditor';
|
||||||
|
|
||||||
export const plugin = new DataSourcePlugin(Datasource)
|
export const plugin = new DataSourcePlugin(Datasource)
|
||||||
.setQueryEditor(LokiQueryEditor)
|
.setQueryEditor(LokiQueryEditor)
|
||||||
|
@ -9,6 +9,7 @@ export interface LokiQuery extends DataQuery {
|
|||||||
|
|
||||||
export interface LokiOptions extends DataSourceJsonData {
|
export interface LokiOptions extends DataSourceJsonData {
|
||||||
maxLines?: string;
|
maxLines?: string;
|
||||||
|
derivedFields?: DerivedFieldConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LokiResponse {
|
export interface LokiResponse {
|
||||||
@ -34,3 +35,9 @@ export interface LokiExpression {
|
|||||||
regexp: string;
|
regexp: string;
|
||||||
query: string;
|
query: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DerivedFieldConfig = {
|
||||||
|
matcherRegex: string;
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user