diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 839bd39598c..87e1ff0d081 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -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, diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx index 1d7a22da40c..9d6e87e8f72 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -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; export const ConfigEditor = (props: Props) => { @@ -46,6 +47,19 @@ export const ConfigEditor = (props: Props) => { }) } /> + + { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + dataLinks: newValue, + }, + }); + }} + /> ); }; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx new file mode 100644 index 00000000000..37adb02470b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx @@ -0,0 +1,83 @@ +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) => { + onChange({ + ...value, + [field]: event.currentTarget.value, + }); + }; + + return ( +
+
+ +
+ + + onChange({ + ...value, + url: newValue, + }) + } + suggestions={suggestions} + /> + } + className={css` + width: 100%; + `} + /> +
+ ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx new file mode 100644 index 00000000000..3e8f6299cf6 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx @@ -0,0 +1,66 @@ +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( {}} />); + 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( {}} />); + + 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(); + const addButton = wrapper.find(Button).filterWhere(button => button.contains('Add')); + addButton.simulate('click'); + expect(onChangeMock.mock.calls[0][0].length).toBe(1); + }); + + it('removes field', () => { + const onChangeMock = jest.fn(); + const wrapper = mount(); + const removeButton = wrapper + .find(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', + }, +]; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx new file mode 100644 index 00000000000..7800955209c --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx @@ -0,0 +1,83 @@ +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 ( + <> +

Data links

+ +
+ Add links to existing fields. Links will be shown in log row details next to the field value. +
+ +
+ {value && + value.map((field, index) => { + return ( + { + 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, + }, + ]} + /> + ); + })} +
+ +
+
+ + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/specs/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts similarity index 87% rename from public/app/plugins/datasource/elasticsearch/specs/datasource.test.ts rename to public/app/plugins/datasource/elasticsearch/datasource.test.ts index edbabe4340d..3a8f6151d51 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -1,13 +1,13 @@ 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) { 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); 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,12 +188,30 @@ 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()', async () => { + const { queryBuilderSpy } = await setupDataSource(); + expect(queryBuilderSpy).toHaveBeenCalled(); }); - it('should call getLogsQuery()', () => { - 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}'); }); }); @@ -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'], + }, + }, + ], + }, + }, + ], + }, +}; diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 54c235084ab..f89a61b3546 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -1,6 +1,12 @@ 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 { basicAuth: string; @@ -25,6 +31,7 @@ export class ElasticDatasource extends DataSourceApi { 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 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; diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index 7e3ea49b4cc..0a752fbed53 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -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; +}; diff --git a/public/app/plugins/datasource/graphite/dashboards/metrictank.json b/public/app/plugins/datasource/graphite/dashboards/metrictank.json index c48fa1c4f38..a40e64d0645 100644 --- a/public/app/plugins/datasource/graphite/dashboards/metrictank.json +++ b/public/app/plugins/datasource/graphite/dashboards/metrictank.json @@ -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", diff --git a/public/app/plugins/datasource/graphite/plugin.json b/public/app/plugins/datasource/graphite/plugin.json index bb02db66008..5671bcd1939 100644 --- a/public/app/plugins/datasource/graphite/plugin.json +++ b/public/app/plugins/datasource/graphite/plugin.json @@ -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,