diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index 7be1b754865..05d291f6d54 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -146,7 +146,8 @@ Request object passed to datasource.annotationQuery function: "datasource": "generic datasource", "enable": true, "name": "annotation name" - } + }, + "dashboard": DashboardModel } ``` diff --git a/packages/grafana-data/src/utils/dataFrameView.ts b/packages/grafana-data/src/utils/dataFrameView.ts index 1838779478c..1cf833e9ded 100644 --- a/packages/grafana-data/src/utils/dataFrameView.ts +++ b/packages/grafana-data/src/utils/dataFrameView.ts @@ -68,4 +68,10 @@ export class DataFrameView implements Vector { toJSON(): T[] { return this.toArray(); } + + forEachRow(iterator: (row: T) => void) { + for (let i = 0; i < this.data.length; i++) { + iterator(this.get(i)); + } + } } diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index debfa57de2c..a5941326512 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -8,6 +8,7 @@ import { LogRowModel, LoadingState, DataFrameDTO, + AnnotationEvent, } from '@grafana/data'; import { PluginMeta, GrafanaPlugin } from './plugin'; import { PanelData } from './panel'; @@ -276,6 +277,12 @@ export abstract class DataSourceApi< * Used in explore */ languageProvider?: any; + + /** + * Can be optionally implemented to allow datasource to be a source of annotations for dashboard. To be visible + * in the annotation editor `annotations` capability also needs to be enabled in plugin.json. + */ + annotationQuery?(options: AnnotationQueryRequest): Promise; } export interface QueryEditorProps< @@ -542,3 +549,18 @@ export interface DataSourceSelectItem { meta: DataSourcePluginMeta; sort: string; } + +/** + * Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md + */ +export interface AnnotationQueryRequest { + range: TimeRange; + rangeRaw: RawTimeRange; + // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. + dashboard: any; + annotation: { + datasource: string; + enable: boolean; + name: string; + } & MoreOptions; +} diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index ed03aa9e65c..42bd5a92739 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -12,6 +12,7 @@ import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; import { SearchField } from './components/search/SearchField'; import { GraphContextMenu } from 'app/plugins/panel/graph/GraphContextMenu'; import ReactProfileWrapper from 'app/features/profile/ReactProfileWrapper'; +import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor'; export function registerAngularDirectives() { react2AngularDirective('sidemenu', SideMenu, []); @@ -102,4 +103,10 @@ export function registerAngularDirectives() { ]); react2AngularDirective('reactProfileWrapper', ReactProfileWrapper, []); + + react2AngularDirective('lokiAnnotationsQueryEditor', LokiAnnotationsQueryEditor, [ + 'expr', + 'onChange', + ['datasource', { watchDepth: 'reference' }], + ]); } diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index bd8ae8aa16e..5df84b4626f 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -15,6 +15,7 @@ import { AnnotationEvent } from '@grafana/data'; import DatasourceSrv from '../plugins/datasource_srv'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TimeSrv } from '../dashboard/services/TimeSrv'; +import { DataSourceApi } from '@grafana/ui'; export class AnnotationsSrv { globalAnnotationsPromise: any; @@ -126,7 +127,7 @@ export class AnnotationsSrv { dsPromises.push(datasourcePromise); promises.push( datasourcePromise - .then((datasource: any) => { + .then((datasource: DataSourceApi) => { // issue query against data source return datasource.annotationQuery({ range: range, diff --git a/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx b/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx new file mode 100644 index 00000000000..9797bc846b6 --- /dev/null +++ b/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx @@ -0,0 +1,17 @@ +/** + * Just a simple wrapper for a react component that is actually implementing the query editor. + */ +export class LokiAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + annotation: any; + + /** @ngInject */ + constructor() { + this.annotation.target = this.annotation.target || {}; + this.onQueryChange = this.onQueryChange.bind(this); + } + + onQueryChange(expr: string) { + this.annotation.expr = expr; + } +} diff --git a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx new file mode 100644 index 00000000000..d714844d1a8 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx @@ -0,0 +1,54 @@ +// Libraries +import React, { memo } from 'react'; + +// Types +import { DataSourceApi, DataSourceJsonData, DataSourceStatus } from '@grafana/ui'; +import { LokiQuery } from '../types'; +import { useLokiSyntax } from './useLokiSyntax'; +import { LokiQueryFieldForm } from './LokiQueryFieldForm'; + +interface Props { + expr: string; + datasource: DataSourceApi; + onChange: (expr: string) => void; +} + +export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEditor(props: Props) { + const { expr, datasource, onChange } = props; + + // Timerange to get existing labels from. Hard coding like this seems to be good enough right now. + const absolute = { + from: Date.now() - 10000, + to: Date.now(), + }; + + const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax( + datasource.languageProvider, + DataSourceStatus.Connected, + absolute + ); + + const query: LokiQuery = { + refId: '', + expr, + }; + + return ( +
+ onChange(query.expr)} + onRunQuery={() => {}} + history={[]} + panelData={null} + onLoadOptions={setActiveOption} + onLabelsRefresh={refreshLabels} + syntaxLoaded={isSyntaxReady} + absoluteRange={absolute} + {...syntaxProps} + /> +
+ ); +}); diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 7c6e7ecbfba..c4239134382 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -1,8 +1,8 @@ import LokiDatasource from './datasource'; import { LokiQuery } from './types'; import { getQueryOptions } from 'test/helpers/getQueryOptions'; -import { DataSourceApi } from '@grafana/ui'; -import { DataFrame } from '@grafana/data'; +import { AnnotationQueryRequest, DataSourceApi } from '@grafana/ui'; +import { DataFrame, dateTime } from '@grafana/data'; import { BackendSrv } from 'app/core/services/backend_srv'; import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -22,15 +22,15 @@ describe('LokiDatasource', () => { }, }; + const backendSrvMock = { datasourceRequest: jest.fn() }; + const backendSrv = (backendSrvMock as unknown) as BackendSrv; + + const templateSrvMock = ({ + getAdhocFilters: (): any[] => [], + replace: (a: string) => a, + } as unknown) as TemplateSrv; + describe('when querying', () => { - const backendSrvMock = { datasourceRequest: jest.fn() }; - const backendSrv = (backendSrvMock as unknown) as BackendSrv; - - const templateSrvMock = ({ - getAdhocFilters: (): any[] => [], - replace: (a: string) => a, - } as unknown) as TemplateSrv; - const testLimit = makeLimitTest(instanceSettings, backendSrvMock, backendSrv, templateSrvMock, testResp); test('should use default max lines when no limit given', () => { @@ -171,6 +171,37 @@ describe('LokiDatasource', () => { }); }); }); + + describe('annotationQuery', () => { + it('should transform the loki data to annototion response', async () => { + const ds = new LokiDatasource(instanceSettings, backendSrv, templateSrvMock); + backendSrvMock.datasourceRequest = jest.fn(() => + Promise.resolve({ + data: { + streams: [ + { + entries: [{ ts: '2019-02-01T10:27:37.498180581Z', line: 'hello' }], + labels: '{label="value"}', + }, + { + entries: [{ ts: '2019-02-01T12:27:37.498180581Z', line: 'hello 2' }], + labels: '{label2="value2"}', + }, + ], + }, + }) + ); + const query = makeAnnotationQueryRequest(); + + const res = await ds.annotationQuery(query); + expect(res.length).toBe(2); + expect(res[0].text).toBe('hello'); + expect(res[0].tags).toEqual(['value']); + + expect(res[1].text).toBe('hello 2'); + expect(res[1].tags).toEqual(['value2']); + }); + }); }); type LimitTestArgs = { @@ -208,3 +239,27 @@ function makeLimitTest( expect(backendSrvMock.datasourceRequest.mock.calls[0][0].url).toContain(`limit=${expectedLimit}`); }; } + +function makeAnnotationQueryRequest(): AnnotationQueryRequest { + const timeRange = { + from: dateTime(), + to: dateTime(), + }; + return { + annotation: { + expr: '{test=test}', + refId: '', + datasource: 'loki', + enable: true, + name: 'test-annotation', + }, + dashboard: { + id: 1, + } as any, + range: { + ...timeRange, + raw: timeRange, + }, + rangeRaw: timeRange, + }; +} diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 84422e94454..e0cc078c429 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -1,7 +1,15 @@ // Libraries import _ from 'lodash'; // Services & Utils -import { dateMath, DataFrame, LogRowModel, LoadingState, DateTime } from '@grafana/data'; +import { + dateMath, + DataFrame, + LogRowModel, + LoadingState, + DateTime, + AnnotationEvent, + DataFrameView, +} from '@grafana/data'; import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query'; import LanguageProvider from './language_provider'; import { logStreamToDataFrame } from './result_transformer'; @@ -15,6 +23,7 @@ import { DataQueryRequest, DataStreamObserver, DataQueryResponse, + AnnotationQueryRequest, } from '@grafana/ui'; import { LokiQuery, LokiOptions, LokiLogsStream, LokiResponse } from './types'; @@ -193,7 +202,7 @@ export class LokiDatasource extends DataSourceApi { } }; - runQueries = async (options: DataQueryRequest) => { + runQueries = async (options: DataQueryRequest): Promise<{ data: DataFrame[] }> => { const queryTargets = options.targets .filter(target => target.expr && !target.hide && !target.live) .map(target => this.prepareQueryTarget(target, options)); @@ -368,6 +377,52 @@ export class LokiDatasource extends DataSourceApi { return { status: 'error', message: message }; }); } + + async annotationQuery(options: AnnotationQueryRequest): Promise { + if (!options.annotation.expr) { + return []; + } + + const query = queryRequestFromAnnotationOptions(options); + const { data } = await this.runQueries(query); + const annotations: AnnotationEvent[] = []; + for (const frame of data) { + const tags = Object.values(frame.labels); + const view = new DataFrameView<{ ts: string; line: string }>(frame); + view.forEachRow(row => { + annotations.push({ + time: new Date(row.ts).valueOf(), + text: row.line, + tags, + }); + }); + } + + return annotations; + } +} + +function queryRequestFromAnnotationOptions(options: AnnotationQueryRequest): DataQueryRequest { + const refId = `annotation-${options.annotation.name}`; + const target: LokiQuery = { refId, expr: options.annotation.expr }; + + return { + requestId: refId, + range: options.range, + targets: [target], + dashboardId: options.dashboard.id, + scopedVars: null, + startTime: Date.now(), + + // This should mean the default defined on datasource is used. + maxDataPoints: 0, + + // Dummy values, are required in type but not used here. + timezone: 'utc', + panelId: 0, + interval: '', + intervalMs: 0, + }; } export default LokiDatasource; diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts index 571fa944fe5..b1b5bb46370 100644 --- a/public/app/plugins/datasource/loki/module.ts +++ b/public/app/plugins/datasource/loki/module.ts @@ -3,6 +3,7 @@ import Datasource from './datasource'; import LokiStartPage from './components/LokiStartPage'; import LokiQueryField from './components/LokiQueryField'; import LokiQueryEditor from './components/LokiQueryEditor'; +import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl'; export class LokiConfigCtrl { static templateUrl = 'partials/config.html'; @@ -14,4 +15,5 @@ export { LokiConfigCtrl as ConfigCtrl, LokiQueryField as ExploreQueryField, LokiStartPage as ExploreStartPage, + LokiAnnotationsQueryCtrl as AnnotationsQueryCtrl, }; diff --git a/public/app/plugins/datasource/loki/partials/annotations.editor.html b/public/app/plugins/datasource/loki/partials/annotations.editor.html new file mode 100644 index 00000000000..e0d75b578bb --- /dev/null +++ b/public/app/plugins/datasource/loki/partials/annotations.editor.html @@ -0,0 +1,5 @@ + diff --git a/public/app/plugins/datasource/loki/plugin.json b/public/app/plugins/datasource/loki/plugin.json index 58a6b594265..fefeb67f96b 100644 --- a/public/app/plugins/datasource/loki/plugin.json +++ b/public/app/plugins/datasource/loki/plugin.json @@ -6,7 +6,7 @@ "metrics": true, "alerting": false, - "annotations": false, + "annotations": true, "logs": true, "streaming": true,