From 4d9f2980984bd62738ced9fc2055efae7d2b9b0d Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 11 Sep 2020 08:09:44 -0700 Subject: [PATCH] Annotations: add standard annotations support (and use it for flux queries) (#27375) (cherry picked from commit 5d11d8faa3b59a918d0c8d3036b775d7ce79a597) --- .../grafana-data/src/types/annotations.ts | 87 ++++++++ packages/grafana-data/src/types/data.ts | 21 -- packages/grafana-data/src/types/datasource.ts | 51 ++--- packages/grafana-data/src/types/index.ts | 1 + .../src/utils/DataSourceWithBackend.ts | 2 + .../features/annotations/annotations_srv.ts | 119 +++++++++-- .../components/AnnotationResultMapper.tsx | 195 ++++++++++++++++++ .../StandardAnnotationQueryEditor.tsx | 186 +++++++++++++++++ .../app/features/annotations/editor_ctrl.ts | 18 +- .../features/annotations/partials/editor.html | 10 +- .../standardAnnotationSupport.test.ts | 83 ++++++++ .../annotations/standardAnnotationSupport.ts | 188 +++++++++++++++++ public/app/features/annotations/types.ts | 25 +++ .../influxdb/components/FluxQueryEditor.tsx | 27 ++- .../components/VariableQueryEditor.tsx | 7 +- .../plugins/datasource/influxdb/datasource.ts | 20 +- .../influxdb/partials/query.editor.html | 6 +- .../datasource/loki/datasource.test.ts | 3 - 18 files changed, 955 insertions(+), 94 deletions(-) create mode 100644 packages/grafana-data/src/types/annotations.ts create mode 100644 public/app/features/annotations/components/AnnotationResultMapper.tsx create mode 100644 public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx create mode 100644 public/app/features/annotations/standardAnnotationSupport.test.ts create mode 100644 public/app/features/annotations/standardAnnotationSupport.ts create mode 100644 public/app/features/annotations/types.ts diff --git a/packages/grafana-data/src/types/annotations.ts b/packages/grafana-data/src/types/annotations.ts new file mode 100644 index 00000000000..c8b8f8e9302 --- /dev/null +++ b/packages/grafana-data/src/types/annotations.ts @@ -0,0 +1,87 @@ +import { DataQuery, QueryEditorProps } from './datasource'; +import { DataFrame } from './dataFrame'; +import { ComponentType } from 'react'; + +/** + * This JSON object is stored in the dashboard json model. + */ +export interface AnnotationQuery { + datasource: string; + enable: boolean; + name: string; + iconColor: string; + + // Standard datasource query + target?: TQuery; + + // Convert a dataframe to an AnnotationEvent + mappings?: AnnotationEventMappings; +} + +export interface AnnotationEvent { + id?: string; + annotation?: any; + dashboardId?: number; + panelId?: number; + userId?: number; + login?: string; + email?: string; + avatarUrl?: string; + time?: number; + timeEnd?: number; + isRegion?: boolean; + title?: string; + text?: string; + type?: string; + tags?: string[]; + + // Currently used to merge annotations from alerts and dashboard + source?: any; // source.type === 'dashboard' +} + +/** + * @alpha -- any value other than `field` is experimental + */ +export enum AnnotationEventFieldSource { + Field = 'field', // Default -- find the value with a matching key + Text = 'text', // Write a constant string into the value + Skip = 'skip', // Do not include the field +} + +export interface AnnotationEventFieldMapping { + source?: AnnotationEventFieldSource; // defautls to 'field' + value?: string; + regex?: string; +} + +export type AnnotationEventMappings = Partial>; + +/** + * Since Grafana 7.2 + * + * This offers a generic approach to annotation processing + */ +export interface AnnotationSupport> { + /** + * This hook lets you manipulate any existing stored values before running them though the processor. + * This is particularly helpful when dealing with migrating old formats. ie query as a string vs object + */ + prepareAnnotation?(json: any): TAnno; + + /** + * Convert the stored JSON model to a standard datasource query object. + * This query will be executed in the datasource and the results converted into events. + * Returning an undefined result will quietly skip query execution + */ + prepareQuery?(anno: TAnno): TQuery | undefined; + + /** + * When the standard frame > event processing is insufficient, this allows explicit control of the mappings + */ + processEvents?(anno: TAnno, data: DataFrame): AnnotationEvent[] | undefined; + + /** + * Specify a custom QueryEditor for the annotation page. If not specified, the standard one will be used + */ + QueryEditor?: ComponentType>; +} diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index 77f7c47d47b..95390403d9f 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -132,27 +132,6 @@ export enum NullValueMode { AsZero = 'null as zero', } -export interface AnnotationEvent { - id?: string; - annotation?: any; - dashboardId?: number; - panelId?: number; - userId?: number; - login?: string; - email?: string; - avatarUrl?: string; - time?: number; - timeEnd?: number; - isRegion?: boolean; - title?: string; - text?: string; - type?: string; - tags?: string[]; - - // Currently used to merge annotations from alerts and dashboard - source?: any; // source.type === 'dashboard' -} - /** * Describes and API for exposing panel specific data configurations. */ diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index a59dc8cc376..3c74f05a814 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -3,7 +3,8 @@ import { ComponentType } from 'react'; import { GrafanaPlugin, PluginMeta } from './plugin'; import { PanelData } from './panel'; import { LogRowModel } from './logs'; -import { AnnotationEvent, KeyValue, LoadingState, TableData, TimeSeries } from './data'; +import { AnnotationEvent, AnnotationSupport } from './annotations'; +import { KeyValue, LoadingState, TableData, TimeSeries } from './data'; import { DataFrame, DataFrameDTO } from './dataFrame'; import { RawTimeRange, TimeRange } from './time'; import { ScopedVars } from './ScopedVars'; @@ -155,8 +156,7 @@ export interface DataSourceConstructor< */ export abstract class DataSourceApi< TQuery extends DataQuery = DataQuery, - TOptions extends DataSourceJsonData = DataSourceJsonData, - TAnno = TQuery // defatult to direct query + TOptions extends DataSourceJsonData = DataSourceJsonData > { /** * Set in constructor @@ -267,13 +267,23 @@ export abstract class DataSourceApi< showContextToggle?(row?: LogRowModel): boolean; - /** - * 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; - interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[]; + + /** + * An annotation processor allows explict control for how annotations are managed. + * + * It is only necessary to configure an annotation processor if the default behavior is not desirable + */ + annotations?: AnnotationSupport; + + /** + * Can be optionally implemented to allow datasource to be a source of annotations for dashboard. + * This function will only be called if an angular {@link AnnotationsQueryCtrl} is configured and + * the {@link annotations} is undefined + * + * @deprecated -- prefer using {@link AnnotationSupport} + */ + annotationQuery?(options: AnnotationQueryRequest): Promise; } export interface MetadataInspectorProps< @@ -473,12 +483,6 @@ export interface MetricFindValue { expandable?: boolean; } -export interface BaseAnnotationQuery { - datasource: string; - enable: boolean; - name: string; -} - export interface DataSourceJsonData { authType?: string; defaultRegion?: string; @@ -547,20 +551,19 @@ export interface DataSourceSelectItem { /** * Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md + * + * @deprecated -- use {@link AnnotationSupport} */ -export interface AnnotationQueryRequest { +export interface AnnotationQueryRequest { range: TimeRange; rangeRaw: RawTimeRange; - interval: string; - intervalMs: number; - maxDataPoints?: number; - app: CoreApp | string; - // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. dashboard: any; - - // The annotation query and common properties - annotation: BaseAnnotationQuery & TAnno; + annotation: { + datasource: string; + enable: boolean; + name: string; + } & MoreOptions; } export interface HistoryItem { diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 35d02d1f91e..37bf4af4109 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -1,6 +1,7 @@ export * from './data'; export * from './dataFrame'; export * from './dataLink'; +export * from './annotations'; export * from './logs'; export * from './navModel'; export * from './select'; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index aeaf063b9cb..4c8e719a8f7 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -122,6 +122,8 @@ export class DataSourceWithBackend< /** * Override to skip executing a query * + * @returns false if the query should be skipped + * * @virtual */ filterQuery?(query: TQuery): boolean; diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index 2bb7d56cb7f..920ca1c9c90 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -7,12 +7,30 @@ import coreModule from 'app/core/core_module'; // Utils & Services import { dedupAnnotations } from './events_processing'; // Types -import { DashboardModel, PanelModel } from '../dashboard/state'; -import { AnnotationEvent, AppEvents, DataSourceApi, PanelEvents, TimeRange, CoreApp } from '@grafana/data'; +import { DashboardModel } from '../dashboard/state'; +import { + AnnotationEvent, + AppEvents, + DataSourceApi, + PanelEvents, + rangeUtil, + DataQueryRequest, + CoreApp, + ScopedVars, +} from '@grafana/data'; import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; import { appEvents } from 'app/core/core'; import { getTimeSrv } from '../dashboard/services/TimeSrv'; -import kbn from 'app/core/utils/kbn'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AnnotationQueryResponse, AnnotationQueryOptions } from './types'; +import { standardAnnotationSupport, singleFrameFromPanelData } from './standardAnnotationSupport'; +import { runRequest } from '../dashboard/state/runRequest'; + +let counter = 100; +function getNextRequestId() { + return 'AQ' + counter++; +} export class AnnotationsSrv { globalAnnotationsPromise: any; @@ -32,7 +50,7 @@ export class AnnotationsSrv { this.datasourcePromises = null; } - getAnnotations(options: { dashboard: DashboardModel; panel: PanelModel; range: TimeRange }) { + getAnnotations(options: AnnotationQueryOptions) { return Promise.all([this.getGlobalAnnotations(options), this.getAlertStates(options)]) .then(results => { // combine the annotations and flatten results @@ -103,7 +121,7 @@ export class AnnotationsSrv { return this.alertStatesPromise; } - getGlobalAnnotations(options: { dashboard: DashboardModel; panel: PanelModel; range: TimeRange }) { + getGlobalAnnotations(options: AnnotationQueryOptions) { const dashboard = options.dashboard; if (this.globalAnnotationsPromise) { @@ -114,9 +132,6 @@ export class AnnotationsSrv { const promises = []; const dsPromises = []; - // No more points than pixels - const maxDataPoints = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; - for (const annotation of dashboard.annotations.list) { if (!annotation.enable) { continue; @@ -130,21 +145,21 @@ export class AnnotationsSrv { promises.push( datasourcePromise .then((datasource: DataSourceApi) => { - if (!datasource.annotationQuery) { - return []; + // Use the legacy annotationQuery unless annotation support is explicitly defined + if (datasource.annotationQuery && !datasource.annotations) { + return datasource.annotationQuery({ + range, + rangeRaw: range.raw, + annotation: annotation, + dashboard: dashboard, + }); } - - // Add interval to annotation queries - const interval = kbn.calculateInterval(range, maxDataPoints, datasource.interval); - - return datasource.annotationQuery({ - ...interval, - app: CoreApp.Dashboard, - range, - rangeRaw: range.raw, - annotation: annotation, - dashboard: dashboard, - }); + // Note: future annotatoin lifecycle will use observables directly + return executeAnnotationQuery(options, datasource, annotation) + .toPromise() + .then(res => { + return res.events ?? []; + }); }) .then(results => { // store response in annotation object if this is a snapshot call @@ -195,4 +210,64 @@ export class AnnotationsSrv { } } +export function executeAnnotationQuery( + options: AnnotationQueryOptions, + datasource: DataSourceApi, + savedJsonAnno: any +): Observable { + const processor = { + ...standardAnnotationSupport, + ...datasource.annotations, + }; + + const annotation = processor.prepareAnnotation!(savedJsonAnno); + if (!annotation) { + return of({}); + } + + const query = processor.prepareQuery!(annotation); + if (!query) { + return of({}); + } + + // No more points than pixels + const maxDataPoints = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + + // Add interval to annotation queries + const interval = rangeUtil.calculateInterval(options.range, maxDataPoints, datasource.interval); + + const scopedVars: ScopedVars = { + __interval: { text: interval.interval, value: interval.interval }, + __interval_ms: { text: interval.intervalMs.toString(), value: interval.intervalMs }, + __annotation: { text: annotation.name, value: annotation }, + }; + + const queryRequest: DataQueryRequest = { + startTime: Date.now(), + requestId: getNextRequestId(), + range: options.range, + maxDataPoints, + scopedVars, + ...interval, + app: CoreApp.Dashboard, + + timezone: options.dashboard.timezone, + + targets: [ + { + ...query, + refId: 'Anno', + }, + ], + }; + + return runRequest(datasource, queryRequest).pipe( + map(panelData => { + const frame = singleFrameFromPanelData(panelData); + const events = frame ? processor.processEvents!(annotation, frame) : []; + return { panelData, frame, events }; + }) + ); +} + coreModule.service('annotationsSrv', AnnotationsSrv); diff --git a/public/app/features/annotations/components/AnnotationResultMapper.tsx b/public/app/features/annotations/components/AnnotationResultMapper.tsx new file mode 100644 index 00000000000..61022f9cfef --- /dev/null +++ b/public/app/features/annotations/components/AnnotationResultMapper.tsx @@ -0,0 +1,195 @@ +import React, { PureComponent } from 'react'; + +import { + SelectableValue, + getFieldDisplayName, + AnnotationEvent, + AnnotationEventMappings, + AnnotationEventFieldMapping, + formattedValueToString, + AnnotationEventFieldSource, + getValueFormat, +} from '@grafana/data'; + +import { annotationEventNames, AnnotationFieldInfo } from '../standardAnnotationSupport'; +import { Select, Tooltip, Icon } from '@grafana/ui'; +import { AnnotationQueryResponse } from '../types'; + +// const valueOptions: Array> = [ +// { value: AnnotationEventFieldSource.Field, label: 'Field', description: 'Set the field value from a response field' }, +// { value: AnnotationEventFieldSource.Text, label: 'Text', description: 'Enter direct text for the value' }, +// { value: AnnotationEventFieldSource.Skip, label: 'Skip', description: 'Hide this field' }, +// ]; + +interface Props { + response?: AnnotationQueryResponse; + + mappings?: AnnotationEventMappings; + + change: (mappings?: AnnotationEventMappings) => void; +} + +interface State { + fieldNames: Array>; +} + +export class AnnotationFieldMapper extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + fieldNames: [], + }; + } + + updateFields = () => { + const frame = this.props.response?.frame; + if (frame && frame.fields) { + const fieldNames = frame.fields.map(f => { + const name = getFieldDisplayName(f, frame); + + let description = ''; + for (let i = 0; i < frame.length; i++) { + if (i > 0) { + description += ', '; + } + if (i > 2) { + description += '...'; + break; + } + description += f.values.get(i); + } + + if (description.length > 50) { + description = description.substring(0, 50) + '...'; + } + + return { + label: `${name} (${f.type})`, + value: name, + description, + }; + }); + this.setState({ fieldNames }); + } + }; + + componentDidMount() { + this.updateFields(); + } + + componentDidUpdate(oldProps: Props) { + if (oldProps.response !== this.props.response) { + this.updateFields(); + } + } + + onFieldSourceChange = (k: keyof AnnotationEvent, v: SelectableValue) => { + const mappings = this.props.mappings || {}; + const mapping = mappings[k] || {}; + + this.props.change({ + ...mappings, + [k]: { + ...mapping, + source: v.value || AnnotationEventFieldSource.Field, + }, + }); + }; + + onFieldNameChange = (k: keyof AnnotationEvent, v: SelectableValue) => { + const mappings = this.props.mappings || {}; + const mapping = mappings[k] || {}; + + this.props.change({ + ...mappings, + [k]: { + ...mapping, + value: v.value, + source: AnnotationEventFieldSource.Field, + }, + }); + }; + + renderRow(row: AnnotationFieldInfo, mapping: AnnotationEventFieldMapping, first?: AnnotationEvent) { + const { fieldNames } = this.state; + + let picker = fieldNames; + const current = mapping.value; + let currentValue = fieldNames.find(f => current === f.value); + if (current) { + picker = [...fieldNames]; + if (!currentValue) { + picker.push({ + label: current, + value: current, + }); + } + } + + let value = first ? first[row.key] : ''; + if (value && row.key.startsWith('time')) { + const fmt = getValueFormat('dateTimeAsIso'); + value = formattedValueToString(fmt(value as number)); + } + if (value === null || value === undefined) { + value = ''; // empty string + } + + return ( + + + {row.key}{' '} + {row.help && ( + + + + )} + + {/* + ) => { + this.onFieldNameChange(row.key, v); + }} + noOptionsMessage="Unknown field names" + allowCustomValue={true} + /> + + {`${value}`} + + ); + } + + render() { + const first = this.props.response?.events?.[0]; + const mappings = this.props.mappings || {}; + + return ( + + + + + + + + + + {annotationEventNames.map(row => { + return this.renderRow(row, mappings[row.key] || {}, first); + })} + +
AnnotationFromFirst Value
+ ); + } +} diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx new file mode 100644 index 00000000000..30b4da8a320 --- /dev/null +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -0,0 +1,186 @@ +import React, { PureComponent } from 'react'; + +import { AnnotationEventMappings, DataQuery, LoadingState, DataSourceApi, AnnotationQuery } from '@grafana/data'; +import { Spinner, Icon, IconName, Button } from '@grafana/ui'; + +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { cx, css } from 'emotion'; +import { standardAnnotationSupport } from '../standardAnnotationSupport'; +import { executeAnnotationQuery } from '../annotations_srv'; +import { PanelModel } from 'app/features/dashboard/state'; +import { AnnotationQueryResponse } from '../types'; +import { AnnotationFieldMapper } from './AnnotationResultMapper'; +import coreModule from 'app/core/core_module'; + +interface Props { + datasource: DataSourceApi; + annotation: AnnotationQuery; + change: (annotation: AnnotationQuery) => void; +} + +interface State { + running?: boolean; + response?: AnnotationQueryResponse; +} + +export default class StandardAnnotationQueryEditor extends PureComponent { + state = {} as State; + + componentDidMount() { + this.verifyDataSource(); + } + + componentDidUpdate(oldProps: Props) { + if (this.props.annotation !== oldProps.annotation) { + this.verifyDataSource(); + } + } + + verifyDataSource() { + const { datasource, annotation } = this.props; + + // Handle any migration issues + const processor = { + ...standardAnnotationSupport, + ...datasource.annotations, + }; + + const fixed = processor.prepareAnnotation!(annotation); + if (fixed !== annotation) { + this.props.change(fixed); + } else { + this.onRunQuery(); + } + } + + onRunQuery = async () => { + const { datasource, annotation } = this.props; + this.setState({ + running: true, + }); + const response = await executeAnnotationQuery( + { + range: getTimeSrv().timeRange(), + panel: {} as PanelModel, + dashboard: getDashboardSrv().getCurrent(), + }, + datasource, + annotation + ).toPromise(); + this.setState({ + running: false, + response, + }); + }; + + onQueryChange = (target: DataQuery) => { + this.props.change({ + ...this.props.annotation, + target, + }); + }; + + onMappingChange = (mappings: AnnotationEventMappings) => { + this.props.change({ + ...this.props.annotation, + mappings, + }); + }; + + renderStatus() { + const { response, running } = this.state; + let rowStyle = 'alert-info'; + let text = '...'; + let icon: IconName | undefined = undefined; + + if (running || response?.panelData?.state === LoadingState.Loading || !response) { + text = 'loading...'; + } else { + const { events, panelData, frame } = response; + + if (panelData?.error) { + rowStyle = 'alert-error'; + icon = 'exclamation-triangle'; + text = panelData.error.message ?? 'error'; + } else if (!events?.length) { + rowStyle = 'alert-warning'; + icon = 'exclamation-triangle'; + text = 'No events found'; + } else { + text = `${events.length} events (from ${frame?.fields.length} fields)`; + } + } + return ( +
+
+ {icon && ( + <> + +   + + )} + {text} +
+
+ {running ? ( + + ) : ( + + )} +
+
+ ); + } + + render() { + const { datasource, annotation } = this.props; + const { response } = this.state; + + // Find the annotaiton runner + let QueryEditor = datasource.annotations?.QueryEditor || datasource.components?.QueryEditor; + if (!QueryEditor) { + return
Annotations are not supported. This datasource needs to export a QueryEditor
; + } + + const query = annotation.target ?? { refId: 'Anno' }; + return ( + <> + + {this.renderStatus()} + + +
+ + ); + } +} + +// Careful to use a unique directive name! many plugins already use "annotationEditor" and have conflicts +coreModule.directive('standardAnnotationEditor', [ + 'reactDirective', + (reactDirective: any) => { + return reactDirective(StandardAnnotationQueryEditor, ['annotation', 'datasource', 'change']); + }, +]); diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts index e4c09c830e7..75d59652361 100644 --- a/public/app/features/annotations/editor_ctrl.ts +++ b/public/app/features/annotations/editor_ctrl.ts @@ -7,10 +7,13 @@ import DatasourceSrv from '../plugins/datasource_srv'; import appEvents from 'app/core/app_events'; import { AppEvents } from '@grafana/data'; +// Registeres the angular directive +import './components/StandardAnnotationQueryEditor'; + export class AnnotationsEditorCtrl { mode: any; datasources: any; - annotations: any; + annotations: any[]; currentAnnotation: any; currentDatasource: any; currentIsNew: any; @@ -69,6 +72,19 @@ export class AnnotationsEditorCtrl { }); } + /** + * Called from the react editor + */ + onAnnotationChange = (annotation: any) => { + const currentIndex = this.dashboard.annotations.list.indexOf(this.currentAnnotation); + if (currentIndex >= 0) { + this.dashboard.annotations.list[currentIndex] = annotation; + } else { + console.warn('updating annotatoin, but not in the dashboard', annotation); + } + this.currentAnnotation = annotation; + }; + edit(annotation: any) { this.currentAnnotation = annotation; this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0; diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html index 443e73b9920..ff989dd4ec2 100644 --- a/public/app/features/annotations/partials/editor.html +++ b/public/app/features/annotations/partials/editor.html @@ -117,7 +117,15 @@
Query
- + + + + +
diff --git a/public/app/features/annotations/standardAnnotationSupport.test.ts b/public/app/features/annotations/standardAnnotationSupport.test.ts new file mode 100644 index 00000000000..73ee1560a07 --- /dev/null +++ b/public/app/features/annotations/standardAnnotationSupport.test.ts @@ -0,0 +1,83 @@ +import { toDataFrame, FieldType } from '@grafana/data'; +import { getAnnotationsFromFrame } from './standardAnnotationSupport'; + +describe('DataFrame to annotations', () => { + test('simple conversion', () => { + const frame = toDataFrame({ + fields: [ + { type: FieldType.time, values: [1, 2, 3] }, + { name: 'first string field', values: ['t1', 't2', 't3'] }, + { name: 'tags', values: ['aaa,bbb', 'bbb,ccc', 'zyz'] }, + ], + }); + + const events = getAnnotationsFromFrame(frame); + expect(events).toMatchInlineSnapshot(` + Array [ + Object { + "tags": Array [ + "aaa", + "bbb", + ], + "text": "t1", + "time": 1, + }, + Object { + "tags": Array [ + "bbb", + "ccc", + ], + "text": "t2", + "time": 2, + }, + Object { + "tags": Array [ + "zyz", + ], + "text": "t3", + "time": 3, + }, + ] + `); + }); + + test('explicit mappins', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time1', values: [111, 222, 333] }, + { name: 'time2', values: [100, 200, 300] }, + { name: 'aaaaa', values: ['a1', 'a2', 'a3'] }, + { name: 'bbbbb', values: ['b1', 'b2', 'b3'] }, + ], + }); + + const events = getAnnotationsFromFrame(frame, { + text: { value: 'bbbbb' }, + time: { value: 'time2' }, + timeEnd: { value: 'time1' }, + title: { value: 'aaaaa' }, + }); + expect(events).toMatchInlineSnapshot(` + Array [ + Object { + "text": "b1", + "time": 100, + "timeEnd": 111, + "title": "a1", + }, + Object { + "text": "b2", + "time": 200, + "timeEnd": 222, + "title": "a2", + }, + Object { + "text": "b3", + "time": 300, + "timeEnd": 333, + "title": "a3", + }, + ] + `); + }); +}); diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts new file mode 100644 index 00000000000..e1ff17db698 --- /dev/null +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -0,0 +1,188 @@ +import { + DataFrame, + AnnotationQuery, + AnnotationSupport, + PanelData, + transformDataFrame, + FieldType, + Field, + KeyValue, + AnnotationEvent, + AnnotationEventMappings, + getFieldDisplayName, + AnnotationEventFieldSource, +} from '@grafana/data'; + +import isString from 'lodash/isString'; + +export const standardAnnotationSupport: AnnotationSupport = { + /** + * Assume the stored value is standard model. + */ + prepareAnnotation: (json: any) => { + if (isString(json?.query)) { + const { query, ...rest } = json; + return { + ...rest, + target: { + query, + }, + mappings: {}, + }; + } + return json as AnnotationQuery; + }, + + /** + * Convert the stored JSON model and environment to a standard datasource query object. + * This query will be executed in the datasource and the results converted into events. + * Returning an undefined result will quietly skip query execution + */ + prepareQuery: (anno: AnnotationQuery) => anno.target, + + /** + * When the standard frame > event processing is insufficient, this allows explicit control of the mappings + */ + processEvents: (anno: AnnotationQuery, data: DataFrame) => { + return getAnnotationsFromFrame(data, anno.mappings); + }, +}; + +/** + * Flatten all panel data into a single frame + */ +export function singleFrameFromPanelData(rsp: PanelData): DataFrame | undefined { + if (!rsp?.series?.length) { + return undefined; + } + if (rsp.series.length === 1) { + return rsp.series[0]; + } + + return transformDataFrame( + [ + { + id: 'seriesToColumns', + options: { byField: 'Time' }, + }, + ], + rsp.series + )[0]; +} + +interface AnnotationEventFieldSetter { + key: keyof AnnotationEvent; + field?: Field; + text?: string; + regex?: RegExp; + split?: string; // for tags +} + +export interface AnnotationFieldInfo { + key: keyof AnnotationEvent; + + split?: string; + field?: (frame: DataFrame) => Field | undefined; + placeholder?: string; + help?: string; +} + +export const annotationEventNames: AnnotationFieldInfo[] = [ + { + key: 'time', + field: (frame: DataFrame) => frame.fields.find(f => f.type === FieldType.time), + placeholder: 'time, or the first time field', + }, + { key: 'timeEnd', help: 'When this field is defined, the annotation will be treated as a range' }, + { + key: 'title', + }, + { + key: 'text', + field: (frame: DataFrame) => frame.fields.find(f => f.type === FieldType.string), + placeholder: 'text, or the first text field', + }, + { key: 'tags', split: ',', help: 'The results will be split on comma (,)' }, + // { key: 'userId' }, + // { key: 'login' }, + // { key: 'email' }, +]; + +export function getAnnotationsFromFrame(frame: DataFrame, options?: AnnotationEventMappings): AnnotationEvent[] { + if (!frame?.length) { + return []; + } + + let hasTime = false; + let hasText = false; + const byName: KeyValue = {}; + for (const f of frame.fields) { + const name = getFieldDisplayName(f, frame); + byName[name.toLowerCase()] = f; + } + + if (!options) { + options = {}; + } + + const fields: AnnotationEventFieldSetter[] = []; + for (const evts of annotationEventNames) { + const opt = options[evts.key] || {}; //AnnotationEventFieldMapping + if (opt.source === AnnotationEventFieldSource.Skip) { + continue; + } + const setter: AnnotationEventFieldSetter = { key: evts.key, split: evts.split }; + + if (opt.source === AnnotationEventFieldSource.Text) { + setter.text = opt.value; + } else { + const lower = (opt.value || evts.key).toLowerCase(); + setter.field = byName[lower]; + if (!setter.field && evts.field) { + setter.field = evts.field(frame); + } + } + + if (setter.field || setter.text) { + fields.push(setter); + if (setter.key === 'time') { + hasTime = true; + } else if (setter.key === 'text') { + hasText = true; + } + } + } + + if (!hasTime || !hasText) { + return []; // throw an error? + } + + // Add each value to the string + const events: AnnotationEvent[] = []; + for (let i = 0; i < frame.length; i++) { + const anno: AnnotationEvent = {}; + for (const f of fields) { + let v: any = undefined; + if (f.text) { + v = f.text; // TODO support templates! + } else if (f.field) { + v = f.field.values.get(i); + if (v !== undefined && f.regex) { + const match = f.regex.exec(v); + if (match) { + v = match[1] ? match[1] : match[0]; + } + } + } + + if (v !== undefined) { + if (f.split) { + v = (v as string).split(','); + } + (anno as any)[f.key] = v; + } + } + events.push(anno); + } + return events; +} diff --git a/public/app/features/annotations/types.ts b/public/app/features/annotations/types.ts new file mode 100644 index 00000000000..d5c1329409a --- /dev/null +++ b/public/app/features/annotations/types.ts @@ -0,0 +1,25 @@ +import { PanelData, DataFrame, AnnotationEvent, TimeRange } from '@grafana/data'; +import { DashboardModel, PanelModel } from '../dashboard/state'; + +export interface AnnotationQueryOptions { + dashboard: DashboardModel; + panel: PanelModel; + range: TimeRange; +} + +export interface AnnotationQueryResponse { + /** + * All the data flattened to a single frame + */ + frame?: DataFrame; + + /** + * The processed annotation events + */ + events?: AnnotationEvent[]; + + /** + * The original panel response + */ + panelData?: PanelData; +} diff --git a/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx b/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx index e3e6486aeff..a7a22e231cb 100644 --- a/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import coreModule from 'app/core/core_module'; import { InfluxQuery } from '../types'; -import { SelectableValue } from '@grafana/data'; +import { SelectableValue, QueryEditorProps } from '@grafana/data'; import { cx, css } from 'emotion'; import { InlineFormLabel, @@ -12,12 +12,10 @@ import { CodeEditorSuggestionItemKind, } from '@grafana/ui'; import { getTemplateSrv } from '@grafana/runtime'; +import InfluxDatasource from '../datasource'; -interface Props { - target: InfluxQuery; - change: (target: InfluxQuery) => void; - refresh: () => void; -} +// @ts-ignore -- complicated since the datasource is not really reactified yet! +type Props = QueryEditorProps; const samples: Array> = [ { label: 'Show buckets', description: 'List the avaliable buckets (table)', value: 'buckets()' }, @@ -87,20 +85,19 @@ v1.tagValues( export class FluxQueryEditor extends PureComponent { onFluxQueryChange = (query: string) => { - const { target, change } = this.props; - change({ ...target, query }); - this.props.refresh(); + this.props.onChange({ ...this.props.query, query }); + this.props.onRunQuery(); }; onSampleChange = (val: SelectableValue) => { - this.props.change({ - ...this.props.target, + this.props.onChange({ + ...this.props.query, query: val.value!, }); // Angular HACK: Since the target does not actually change! this.forceUpdate(); - this.props.refresh(); + this.props.onRunQuery(); }; getSuggestions = (): CodeEditorSuggestionItem[] => { @@ -157,7 +154,7 @@ export class FluxQueryEditor extends PureComponent { }; render() { - const { target } = this.props; + const { query } = this.props; const helpTooltip = (
@@ -171,7 +168,7 @@ export class FluxQueryEditor extends PureComponent { { coreModule.directive('fluxQueryEditor', [ 'reactDirective', (reactDirective: any) => { - return reactDirective(FluxQueryEditor, ['target', 'change', 'refresh']); + return reactDirective(FluxQueryEditor, ['query', 'onChange', 'onRunQuery']); }, ]); diff --git a/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx index 6d4ec551531..f63015dc2ba 100644 --- a/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx @@ -19,12 +19,13 @@ export default class VariableQueryEditor extends PureComponent { if (datasource.isFlux) { return ( onChange(v.query)} + onRunQuery={this.onRefresh} + onChange={v => onChange(v.query)} /> ); } diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 8452d0ff276..d5db6fb11a7 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -21,6 +21,7 @@ import { InfluxQueryBuilder } from './query_builder'; import { InfluxQuery, InfluxOptions, InfluxVersion } from './types'; import { getBackendSrv, getTemplateSrv, DataSourceWithBackend, frameToMetricFindValue } from '@grafana/runtime'; import { Observable, from } from 'rxjs'; +import { FluxQueryEditor } from './components/FluxQueryEditor'; export default class InfluxDatasource extends DataSourceWithBackend { type: string; @@ -55,6 +56,13 @@ export default class InfluxDatasource extends DataSourceWithBackend): Observable { @@ -73,6 +81,16 @@ export default class InfluxDatasource extends DataSourceWithBackend): Promise { if (this.isFlux) { return Promise.reject({ - message: 'Annotations are not yet supported with flux queries', + message: 'Flux requires the standard annotation query', }); } diff --git a/public/app/plugins/datasource/influxdb/partials/query.editor.html b/public/app/plugins/datasource/influxdb/partials/query.editor.html index caec0a3119d..e3bb0b68805 100644 --- a/public/app/plugins/datasource/influxdb/partials/query.editor.html +++ b/public/app/plugins/datasource/influxdb/partials/query.editor.html @@ -1,9 +1,9 @@ diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index a8e677ce878..6a5194b77e5 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -539,9 +539,6 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest { raw: timeRange, }, rangeRaw: timeRange, - app: 'test', - interval: '1m', - intervalMs: 6000, }; }