Commit 5a3c1dc6 by Andrej Ocenas Committed by GitHub

Elastic: Add data links in datasource config (#20186)

parent 34299a1b
......@@ -202,6 +202,7 @@ export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number,
// Create metrics from logs
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs, timeZone);
} else {
// We got metrics in the dataFrame so process those
logsModel.series = getGraphSeriesModel(
metricSeries,
timeZone,
......
......@@ -4,6 +4,7 @@ import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { ElasticsearchOptions } from '../types';
import { defaultMaxConcurrentShardRequests, ElasticDetails } from './ElasticDetails';
import { LogsConfig } from './LogsConfig';
import { DataLinks } from './DataLinks';
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>;
export const ConfigEditor = (props: Props) => {
......@@ -46,6 +47,19 @@ export const ConfigEditor = (props: Props) => {
})
}
/>
<DataLinks
value={options.jsonData.dataLinks}
onChange={newValue => {
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
dataLinks: newValue,
},
});
}}
/>
</>
);
};
import React from 'react';
import { css } from 'emotion';
import { Button, FormField, VariableSuggestion, DataLinkInput, stylesFactory } from '@grafana/ui';
import { DataLinkConfig } from '../types';
const getStyles = stylesFactory(() => ({
firstRow: css`
display: flex;
`,
nameField: css`
flex: 2;
`,
regexField: css`
flex: 3;
`,
}));
type Props = {
value: DataLinkConfig;
onChange: (value: DataLinkConfig) => void;
onDelete: () => void;
suggestions: VariableSuggestion[];
className?: string;
};
export const DataLink = (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={6}
// A bit of a hack to prevent using default value for the width from FormField
inputWidth={null}
label="Field"
type="text"
value={value.field}
tooltip={'Can be exact field name or a regex pattern that will match on the field name.'}
onChange={handleChange('field')}
/>
<Button
variant={'inverse'}
title="Remove field"
icon={'fa fa-times'}
onClick={event => {
event.preventDefault();
onDelete();
}}
/>
</div>
<FormField
label="URL"
labelWidth={6}
inputEl={
<DataLinkInput
placeholder={'http://example.com/${__value.raw}'}
value={value.url || ''}
onChange={newValue =>
onChange({
...value,
url: newValue,
})
}
suggestions={suggestions}
/>
}
className={css`
width: 100%;
`}
/>
</div>
);
};
import React from 'react';
import { mount } from 'enzyme';
import { DataLinks } from './DataLinks';
import { Button } from '@grafana/ui';
import { DataLink } from './DataLink';
describe('DataLinks', () => {
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(<DataLinks onChange={() => {}} />);
expect(wrapper.find(Button).length).toBe(1);
expect(wrapper.find(Button).contains('Add')).toBeTruthy();
expect(wrapper.find(DataLink).length).toBe(0);
});
it('renders correctly when there are fields', () => {
const wrapper = mount(<DataLinks value={testValue} onChange={() => {}} />);
expect(wrapper.find(Button).filterWhere(button => button.contains('Add')).length).toBe(1);
expect(wrapper.find(DataLink).length).toBe(2);
});
it('adds new field', () => {
const onChangeMock = jest.fn();
const wrapper = mount(<DataLinks 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(<DataLinks value={testValue} onChange={onChangeMock} />);
const removeButton = wrapper
.find(DataLink)
.at(0)
.find(Button);
removeButton.simulate('click');
const newValue = onChangeMock.mock.calls[0][0];
expect(newValue.length).toBe(1);
expect(newValue[0]).toMatchObject({
field: 'regex2',
url: 'localhost2',
});
});
});
const testValue = [
{
field: 'regex1',
url: 'localhost1',
},
{
field: 'regex2',
url: 'localhost2',
},
];
import React from 'react';
import { css } from 'emotion';
import { Button, DataLinkBuiltInVars, stylesFactory, useTheme, VariableOrigin } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { DataLinkConfig } from '../types';
import { DataLink } from './DataLink';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
infoText: css`
padding-bottom: ${theme.spacing.md};
color: ${theme.colors.textWeak};
`,
dataLink: css`
margin-bottom: ${theme.spacing.sm};
`,
}));
type Props = {
value?: DataLinkConfig[];
onChange: (value: DataLinkConfig[]) => void;
};
export const DataLinks = (props: Props) => {
const { value, onChange } = props;
const theme = useTheme();
const styles = getStyles(theme);
return (
<>
<h3 className="page-heading">Data links</h3>
<div className={styles.infoText}>
Add links to existing fields. Links will be shown in log row details next to the field value.
</div>
<div className="gf-form-group">
{value &&
value.map((field, index) => {
return (
<DataLink
className={styles.dataLink}
key={index}
value={field}
onChange={newField => {
const newDataLinks = [...value];
newDataLinks.splice(index, 1, newField);
onChange(newDataLinks);
}}
onDelete={() => {
const newDataLinks = [...value];
newDataLinks.splice(index, 1);
onChange(newDataLinks);
}}
suggestions={[
{
value: DataLinkBuiltInVars.valueRaw,
label: 'Raw value',
documentation: 'Raw value of the field',
origin: VariableOrigin.Value,
},
]}
/>
);
})}
<div>
<Button
variant={'inverse'}
className={css`
margin-right: 10px;
`}
icon="fa fa-plus"
onClick={event => {
event.preventDefault();
const newDataLinks = [...(value || []), { field: '', url: '' }];
onChange(newDataLinks);
}}
>
Add
</Button>
</div>
</div>
</>
);
};
import angular from 'angular';
import { dateMath } from '@grafana/data';
import { dateMath, Field } from '@grafana/data';
import _ from 'lodash';
import { ElasticDatasource } from '../datasource';
import { ElasticDatasource } from './datasource';
import { toUtc, dateTime } from '@grafana/data';
import { BackendSrv } from 'app/core/services/backend_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { DataSourceInstanceSettings } from '@grafana/data';
import { ElasticsearchOptions } from '../types';
import { ElasticsearchOptions } from './types';
describe('ElasticDatasource', function(this: any) {
const backendSrv: any = {
......@@ -153,73 +153,23 @@ describe('ElasticDatasource', function(this: any) {
});
describe('When issuing logs query with interval pattern', () => {
let query, queryBuilderSpy: any;
beforeEach(async () => {
async function setupDataSource(jsonData?: Partial<ElasticsearchOptions>) {
createDatasource({
url: 'http://es.com',
database: 'mock-index',
jsonData: { interval: 'Daily', esVersion: 2, timeField: '@timestamp' } as ElasticsearchOptions,
jsonData: {
interval: 'Daily',
esVersion: 2,
timeField: '@timestamp',
...(jsonData || {}),
} as ElasticsearchOptions,
} as DataSourceInstanceSettings<ElasticsearchOptions>);
ctx.backendSrv.datasourceRequest = jest.fn(options => {
return Promise.resolve({
data: {
responses: [
{
aggregations: {
'2': {
buckets: [
{
doc_count: 10,
key: 1000,
},
{
doc_count: 15,
key: 2000,
},
],
},
},
hits: {
hits: [
{
'@timestamp': ['2019-06-24T09:51:19.765Z'],
_id: 'fdsfs',
_type: '_doc',
_index: 'mock-index',
_source: {
'@timestamp': '2019-06-24T09:51:19.765Z',
host: 'djisaodjsoad',
message: 'hello, i am a message',
},
fields: {
'@timestamp': ['2019-06-24T09:51:19.765Z'],
},
},
{
'@timestamp': ['2019-06-24T09:52:19.765Z'],
_id: 'kdospaidopa',
_type: '_doc',
_index: 'mock-index',
_source: {
'@timestamp': '2019-06-24T09:52:19.765Z',
host: 'dsalkdakdop',
message: 'hello, i am also message',
},
fields: {
'@timestamp': ['2019-06-24T09:52:19.765Z'],
},
},
],
},
},
],
},
});
return Promise.resolve(logsResponse);
});
query = {
const query = {
range: {
from: toUtc([2015, 4, 30, 10]),
to: toUtc([2019, 7, 1, 10]),
......@@ -238,13 +188,31 @@ describe('ElasticDatasource', function(this: any) {
],
};
queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery');
await ctx.ds.query(query);
});
const queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery');
const response = await ctx.ds.query(query);
return { queryBuilderSpy, response };
}
it('should call getLogsQuery()', () => {
it('should call getLogsQuery()', async () => {
const { queryBuilderSpy } = await setupDataSource();
expect(queryBuilderSpy).toHaveBeenCalled();
});
it('should enhance fields with links', async () => {
const { response } = await setupDataSource({
dataLinks: [
{
field: 'host',
url: 'http://localhost:3000/${__value.raw}',
},
],
});
// 1 for logs and 1 for counts.
expect(response.data.length).toBe(2);
const links = response.data[0].fields.find((field: Field) => field.name === 'host').config.links;
expect(links.length).toBe(1);
expect(links[0].url).toBe('http://localhost:3000/${__value.raw}');
});
});
describe('When issuing document query', () => {
......@@ -645,3 +613,58 @@ describe('ElasticDatasource', function(this: any) {
});
});
});
const logsResponse = {
data: {
responses: [
{
aggregations: {
'2': {
buckets: [
{
doc_count: 10,
key: 1000,
},
{
doc_count: 15,
key: 2000,
},
],
},
},
hits: {
hits: [
{
'@timestamp': ['2019-06-24T09:51:19.765Z'],
_id: 'fdsfs',
_type: '_doc',
_index: 'mock-index',
_source: {
'@timestamp': '2019-06-24T09:51:19.765Z',
host: 'djisaodjsoad',
message: 'hello, i am a message',
},
fields: {
'@timestamp': ['2019-06-24T09:51:19.765Z'],
},
},
{
'@timestamp': ['2019-06-24T09:52:19.765Z'],
_id: 'kdospaidopa',
_type: '_doc',
_index: 'mock-index',
_source: {
'@timestamp': '2019-06-24T09:52:19.765Z',
host: 'dsalkdakdop',
message: 'hello, i am also message',
},
fields: {
'@timestamp': ['2019-06-24T09:52:19.765Z'],
},
},
],
},
},
],
},
};
import angular from 'angular';
import _ from 'lodash';
import { DataSourceApi, DataSourceInstanceSettings, DataQueryRequest, DataQueryResponse } from '@grafana/data';
import {
DataSourceApi,
DataSourceInstanceSettings,
DataQueryRequest,
DataQueryResponse,
DataFrame,
} from '@grafana/data';
import { ElasticResponse } from './elastic_response';
import { IndexPattern } from './index_pattern';
import { ElasticQueryBuilder } from './query_builder';
......@@ -9,7 +15,7 @@ import * as queryDef from './query_def';
import { BackendSrv } from 'app/core/services/backend_srv';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { ElasticsearchOptions, ElasticsearchQuery } from './types';
import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery } from './types';
export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, ElasticsearchOptions> {
basicAuth: string;
......@@ -25,6 +31,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
indexPattern: IndexPattern;
logMessageField?: string;
logLevelField?: string;
dataLinks: DataLinkConfig[];
/** @ngInject */
constructor(
......@@ -52,6 +59,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
});
this.logMessageField = settingsData.logMessageField || '';
this.logLevelField = settingsData.logLevelField || '';
this.dataLinks = settingsData.dataLinks || [];
if (this.logMessageField === '') {
this.logMessageField = null;
......@@ -369,7 +377,11 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
return this.post(url, payload).then((res: any) => {
const er = new ElasticResponse(sentTargets, res);
if (sentTargets.some(target => target.isLogsQuery)) {
return er.getLogs(this.logMessageField, this.logLevelField);
const response = er.getLogs(this.logMessageField, this.logLevelField);
for (const dataFrame of response.data) {
this.enhanceDataFrame(dataFrame);
}
return response;
}
return er.getTimeSeries();
......@@ -547,6 +559,24 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
return false;
}
enhanceDataFrame(dataFrame: DataFrame) {
if (this.dataLinks.length) {
for (const field of dataFrame.fields) {
const dataLink = this.dataLinks.find(dataLink => field.name && field.name.match(dataLink.field));
if (dataLink) {
field.config = field.config || {};
field.config.links = [
...(field.config.links || []),
{
url: dataLink.url,
title: '',
},
];
}
}
}
}
private isPrimitive(obj: any) {
if (obj === null || obj === undefined) {
return true;
......
......@@ -8,6 +8,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
maxConcurrentShardRequests?: number;
logMessageField?: string;
logLevelField?: string;
dataLinks?: DataLinkConfig[];
}
export interface ElasticsearchAggregation {
......@@ -24,3 +25,8 @@ export interface ElasticsearchQuery extends DataQuery {
bucketAggs?: ElasticsearchAggregation[];
metrics?: ElasticsearchAggregation[];
}
export type DataLinkConfig = {
field: string;
url: string;
};
......@@ -25,9 +25,7 @@
"limit": 100,
"name": "Annotations & Alerts",
"showIn": 0,
"tags": [
"metrictank"
],
"tags": ["metrictank"],
"type": "tags"
}
]
......@@ -3258,11 +3256,11 @@
"target": "groupByNodes(perSecond(metrictank.stats.$environment.$instance.idx.*.ops.*.counter32), 'sum', 5, 7)",
"textEditor": false
},
{
"refId": "B",
"target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.add.values.rate32, 2, 3), 3, 4)",
"textEditor": false
},
{
"refId": "B",
"target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.add.values.rate32, 2, 3), 3, 4)",
"textEditor": false
},
{
"refId": "C",
"target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.query-insert.exec.values.rate32, 2, 3), 3, 4)",
......@@ -4783,30 +4781,9 @@
"enable": true,
"notice": false,
"now": true,
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"status": "Stable",
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
],
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"type": "timepicker"
},
"timezone": "utc",
......
......@@ -5,8 +5,8 @@
"category": "tsdb",
"includes": [
{ "type": "dashboard", "name": "Graphite Carbon Metrics", "path": "dashboards/carbon_metrics.json" },
{ "type": "dashboard", "name": "Metrictank (Graphite alternative)", "path": "dashboards/metrictank.json" }
{ "type": "dashboard", "name": "Graphite Carbon Metrics", "path": "dashboards/carbon_metrics.json" },
{ "type": "dashboard", "name": "Metrictank (Graphite alternative)", "path": "dashboards/metrictank.json" }
],
"metrics": true,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment