mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Annotations: add standard annotations support (and use it for flux queries) (#27375)
This commit is contained in:
parent
4707508f4b
commit
5d11d8faa3
87
packages/grafana-data/src/types/annotations.ts
Normal file
87
packages/grafana-data/src/types/annotations.ts
Normal file
@ -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<TQuery extends DataQuery = DataQuery> {
|
||||||
|
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<Record<keyof AnnotationEvent, AnnotationEventFieldMapping>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since Grafana 7.2
|
||||||
|
*
|
||||||
|
* This offers a generic approach to annotation processing
|
||||||
|
*/
|
||||||
|
export interface AnnotationSupport<TQuery extends DataQuery = DataQuery, TAnno = AnnotationQuery<TQuery>> {
|
||||||
|
/**
|
||||||
|
* 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<QueryEditorProps<any, TQuery>>;
|
||||||
|
}
|
@ -132,27 +132,6 @@ export enum NullValueMode {
|
|||||||
AsZero = 'null as zero',
|
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.
|
* Describes and API for exposing panel specific data configurations.
|
||||||
*/
|
*/
|
||||||
|
@ -3,7 +3,8 @@ import { ComponentType } from 'react';
|
|||||||
import { GrafanaPlugin, PluginMeta } from './plugin';
|
import { GrafanaPlugin, PluginMeta } from './plugin';
|
||||||
import { PanelData } from './panel';
|
import { PanelData } from './panel';
|
||||||
import { LogRowModel } from './logs';
|
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 { DataFrame, DataFrameDTO } from './dataFrame';
|
||||||
import { RawTimeRange, TimeRange } from './time';
|
import { RawTimeRange, TimeRange } from './time';
|
||||||
import { ScopedVars } from './ScopedVars';
|
import { ScopedVars } from './ScopedVars';
|
||||||
@ -155,8 +156,7 @@ export interface DataSourceConstructor<
|
|||||||
*/
|
*/
|
||||||
export abstract class DataSourceApi<
|
export abstract class DataSourceApi<
|
||||||
TQuery extends DataQuery = DataQuery,
|
TQuery extends DataQuery = DataQuery,
|
||||||
TOptions extends DataSourceJsonData = DataSourceJsonData,
|
TOptions extends DataSourceJsonData = DataSourceJsonData
|
||||||
TAnno = TQuery // defatult to direct query
|
|
||||||
> {
|
> {
|
||||||
/**
|
/**
|
||||||
* Set in constructor
|
* Set in constructor
|
||||||
@ -267,13 +267,23 @@ export abstract class DataSourceApi<
|
|||||||
|
|
||||||
showContextToggle?(row?: LogRowModel): boolean;
|
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<TAnno>): Promise<AnnotationEvent[]>;
|
|
||||||
|
|
||||||
interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[];
|
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<TQuery>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TQuery>): Promise<AnnotationEvent[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetadataInspectorProps<
|
export interface MetadataInspectorProps<
|
||||||
@ -473,12 +483,6 @@ export interface MetricFindValue {
|
|||||||
expandable?: boolean;
|
expandable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseAnnotationQuery {
|
|
||||||
datasource: string;
|
|
||||||
enable: boolean;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataSourceJsonData {
|
export interface DataSourceJsonData {
|
||||||
authType?: string;
|
authType?: string;
|
||||||
defaultRegion?: string;
|
defaultRegion?: string;
|
||||||
@ -547,20 +551,19 @@ export interface DataSourceSelectItem {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md
|
* Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md
|
||||||
|
*
|
||||||
|
* @deprecated -- use {@link AnnotationSupport}
|
||||||
*/
|
*/
|
||||||
export interface AnnotationQueryRequest<TAnno = {}> {
|
export interface AnnotationQueryRequest<MoreOptions = {}> {
|
||||||
range: TimeRange;
|
range: TimeRange;
|
||||||
rangeRaw: RawTimeRange;
|
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.
|
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
|
||||||
dashboard: any;
|
dashboard: any;
|
||||||
|
annotation: {
|
||||||
// The annotation query and common properties
|
datasource: string;
|
||||||
annotation: BaseAnnotationQuery & TAnno;
|
enable: boolean;
|
||||||
|
name: string;
|
||||||
|
} & MoreOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
|
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export * from './data';
|
export * from './data';
|
||||||
export * from './dataFrame';
|
export * from './dataFrame';
|
||||||
export * from './dataLink';
|
export * from './dataLink';
|
||||||
|
export * from './annotations';
|
||||||
export * from './logs';
|
export * from './logs';
|
||||||
export * from './navModel';
|
export * from './navModel';
|
||||||
export * from './select';
|
export * from './select';
|
||||||
|
@ -122,6 +122,8 @@ export class DataSourceWithBackend<
|
|||||||
/**
|
/**
|
||||||
* Override to skip executing a query
|
* Override to skip executing a query
|
||||||
*
|
*
|
||||||
|
* @returns false if the query should be skipped
|
||||||
|
*
|
||||||
* @virtual
|
* @virtual
|
||||||
*/
|
*/
|
||||||
filterQuery?(query: TQuery): boolean;
|
filterQuery?(query: TQuery): boolean;
|
||||||
|
@ -7,12 +7,30 @@ import coreModule from 'app/core/core_module';
|
|||||||
// Utils & Services
|
// Utils & Services
|
||||||
import { dedupAnnotations } from './events_processing';
|
import { dedupAnnotations } from './events_processing';
|
||||||
// Types
|
// Types
|
||||||
import { DashboardModel, PanelModel } from '../dashboard/state';
|
import { DashboardModel } from '../dashboard/state';
|
||||||
import { AnnotationEvent, AppEvents, DataSourceApi, PanelEvents, TimeRange, CoreApp } from '@grafana/data';
|
import {
|
||||||
|
AnnotationEvent,
|
||||||
|
AppEvents,
|
||||||
|
DataSourceApi,
|
||||||
|
PanelEvents,
|
||||||
|
rangeUtil,
|
||||||
|
DataQueryRequest,
|
||||||
|
CoreApp,
|
||||||
|
ScopedVars,
|
||||||
|
} from '@grafana/data';
|
||||||
import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
|
import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
import { getTimeSrv } from '../dashboard/services/TimeSrv';
|
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 {
|
export class AnnotationsSrv {
|
||||||
globalAnnotationsPromise: any;
|
globalAnnotationsPromise: any;
|
||||||
@ -32,7 +50,7 @@ export class AnnotationsSrv {
|
|||||||
this.datasourcePromises = null;
|
this.datasourcePromises = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAnnotations(options: { dashboard: DashboardModel; panel: PanelModel; range: TimeRange }) {
|
getAnnotations(options: AnnotationQueryOptions) {
|
||||||
return Promise.all([this.getGlobalAnnotations(options), this.getAlertStates(options)])
|
return Promise.all([this.getGlobalAnnotations(options), this.getAlertStates(options)])
|
||||||
.then(results => {
|
.then(results => {
|
||||||
// combine the annotations and flatten results
|
// combine the annotations and flatten results
|
||||||
@ -103,7 +121,7 @@ export class AnnotationsSrv {
|
|||||||
return this.alertStatesPromise;
|
return this.alertStatesPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
getGlobalAnnotations(options: { dashboard: DashboardModel; panel: PanelModel; range: TimeRange }) {
|
getGlobalAnnotations(options: AnnotationQueryOptions) {
|
||||||
const dashboard = options.dashboard;
|
const dashboard = options.dashboard;
|
||||||
|
|
||||||
if (this.globalAnnotationsPromise) {
|
if (this.globalAnnotationsPromise) {
|
||||||
@ -114,9 +132,6 @@ export class AnnotationsSrv {
|
|||||||
const promises = [];
|
const promises = [];
|
||||||
const dsPromises = [];
|
const dsPromises = [];
|
||||||
|
|
||||||
// No more points than pixels
|
|
||||||
const maxDataPoints = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
|
||||||
|
|
||||||
for (const annotation of dashboard.annotations.list) {
|
for (const annotation of dashboard.annotations.list) {
|
||||||
if (!annotation.enable) {
|
if (!annotation.enable) {
|
||||||
continue;
|
continue;
|
||||||
@ -130,21 +145,21 @@ export class AnnotationsSrv {
|
|||||||
promises.push(
|
promises.push(
|
||||||
datasourcePromise
|
datasourcePromise
|
||||||
.then((datasource: DataSourceApi) => {
|
.then((datasource: DataSourceApi) => {
|
||||||
if (!datasource.annotationQuery) {
|
// Use the legacy annotationQuery unless annotation support is explicitly defined
|
||||||
return [];
|
if (datasource.annotationQuery && !datasource.annotations) {
|
||||||
|
return datasource.annotationQuery({
|
||||||
|
range,
|
||||||
|
rangeRaw: range.raw,
|
||||||
|
annotation: annotation,
|
||||||
|
dashboard: dashboard,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// Note: future annotatoin lifecycle will use observables directly
|
||||||
// Add interval to annotation queries
|
return executeAnnotationQuery(options, datasource, annotation)
|
||||||
const interval = kbn.calculateInterval(range, maxDataPoints, datasource.interval);
|
.toPromise()
|
||||||
|
.then(res => {
|
||||||
return datasource.annotationQuery({
|
return res.events ?? [];
|
||||||
...interval,
|
});
|
||||||
app: CoreApp.Dashboard,
|
|
||||||
range,
|
|
||||||
rangeRaw: range.raw,
|
|
||||||
annotation: annotation,
|
|
||||||
dashboard: dashboard,
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.then(results => {
|
.then(results => {
|
||||||
// store response in annotation object if this is a snapshot call
|
// 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<AnnotationQueryResponse> {
|
||||||
|
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);
|
coreModule.service('annotationsSrv', AnnotationsSrv);
|
||||||
|
@ -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<SelectableValue<AnnotationEventFieldSource>> = [
|
||||||
|
// { 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<SelectableValue<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnnotationFieldMapper extends PureComponent<Props, State> {
|
||||||
|
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<AnnotationEventFieldSource>) => {
|
||||||
|
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<string>) => {
|
||||||
|
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 (
|
||||||
|
<tr key={row.key}>
|
||||||
|
<td>
|
||||||
|
{row.key}{' '}
|
||||||
|
{row.help && (
|
||||||
|
<Tooltip content={row.help}>
|
||||||
|
<Icon name="info-circle" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{/* <td>
|
||||||
|
<Select
|
||||||
|
value={valueOptions.find(v => v.value === mapping.source) || valueOptions[0]}
|
||||||
|
options={valueOptions}
|
||||||
|
onChange={(v: SelectableValue<AnnotationEventFieldSource>) => {
|
||||||
|
this.onFieldSourceChange(row.key, v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td> */}
|
||||||
|
<td>
|
||||||
|
<Select
|
||||||
|
value={currentValue}
|
||||||
|
options={picker}
|
||||||
|
placeholder={row.placeholder || row.key}
|
||||||
|
onChange={(v: SelectableValue<string>) => {
|
||||||
|
this.onFieldNameChange(row.key, v);
|
||||||
|
}}
|
||||||
|
noOptionsMessage="Unknown field names"
|
||||||
|
allowCustomValue={true}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{`${value}`}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const first = this.props.response?.events?.[0];
|
||||||
|
const mappings = this.props.mappings || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="filter-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Annotation</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>First Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{annotationEventNames.map(row => {
|
||||||
|
return this.renderRow(row, mappings[row.key] || {}, first);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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<DataQuery>;
|
||||||
|
change: (annotation: AnnotationQuery<DataQuery>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
running?: boolean;
|
||||||
|
response?: AnnotationQueryResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class StandardAnnotationQueryEditor extends PureComponent<Props, State> {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
rowStyle,
|
||||||
|
css`
|
||||||
|
margin: 4px 0px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{icon && (
|
||||||
|
<>
|
||||||
|
<Icon name={icon} />
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{running ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" size="xs" onClick={this.onRunQuery}>
|
||||||
|
TEST
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div>Annotations are not supported. This datasource needs to export a QueryEditor</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = annotation.target ?? { refId: 'Anno' };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<QueryEditor
|
||||||
|
key={datasource?.name}
|
||||||
|
query={query}
|
||||||
|
datasource={datasource}
|
||||||
|
onChange={this.onQueryChange}
|
||||||
|
onRunQuery={this.onRunQuery}
|
||||||
|
data={response?.panelData}
|
||||||
|
range={getTimeSrv().timeRange()}
|
||||||
|
/>
|
||||||
|
{this.renderStatus()}
|
||||||
|
|
||||||
|
<AnnotationFieldMapper response={response} mappings={annotation.mappings} change={this.onMappingChange} />
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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']);
|
||||||
|
},
|
||||||
|
]);
|
@ -7,10 +7,13 @@ import DatasourceSrv from '../plugins/datasource_srv';
|
|||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { AppEvents } from '@grafana/data';
|
import { AppEvents } from '@grafana/data';
|
||||||
|
|
||||||
|
// Registeres the angular directive
|
||||||
|
import './components/StandardAnnotationQueryEditor';
|
||||||
|
|
||||||
export class AnnotationsEditorCtrl {
|
export class AnnotationsEditorCtrl {
|
||||||
mode: any;
|
mode: any;
|
||||||
datasources: any;
|
datasources: any;
|
||||||
annotations: any;
|
annotations: any[];
|
||||||
currentAnnotation: any;
|
currentAnnotation: any;
|
||||||
currentDatasource: any;
|
currentDatasource: any;
|
||||||
currentIsNew: 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) {
|
edit(annotation: any) {
|
||||||
this.currentAnnotation = annotation;
|
this.currentAnnotation = annotation;
|
||||||
this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0;
|
this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0;
|
||||||
|
@ -117,7 +117,15 @@
|
|||||||
|
|
||||||
<h5 class="section-heading">Query</h5>
|
<h5 class="section-heading">Query</h5>
|
||||||
<rebuild-on-change property="ctrl.currentDatasource">
|
<rebuild-on-change property="ctrl.currentDatasource">
|
||||||
<plugin-component type="annotations-query-ctrl"> </plugin-component>
|
<!-- Legacy angular -->
|
||||||
|
<plugin-component ng-if="!ctrl.currentDatasource.annotations" type="annotations-query-ctrl"> </plugin-component>
|
||||||
|
|
||||||
|
<!-- React query editor -->
|
||||||
|
<standard-annotation-editor
|
||||||
|
ng-if="ctrl.currentDatasource.annotations"
|
||||||
|
annotation="ctrl.currentAnnotation"
|
||||||
|
datasource="ctrl.currentDatasource"
|
||||||
|
change="ctrl.onAnnotationChange" />
|
||||||
</rebuild-on-change>
|
</rebuild-on-change>
|
||||||
|
|
||||||
<div class="gf-form">
|
<div class="gf-form">
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
188
public/app/features/annotations/standardAnnotationSupport.ts
Normal file
188
public/app/features/annotations/standardAnnotationSupport.ts
Normal file
@ -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<Field> = {};
|
||||||
|
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;
|
||||||
|
}
|
25
public/app/features/annotations/types.ts
Normal file
25
public/app/features/annotations/types.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
import { InfluxQuery } from '../types';
|
import { InfluxQuery } from '../types';
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue, QueryEditorProps } from '@grafana/data';
|
||||||
import { cx, css } from 'emotion';
|
import { cx, css } from 'emotion';
|
||||||
import {
|
import {
|
||||||
InlineFormLabel,
|
InlineFormLabel,
|
||||||
@ -12,12 +12,10 @@ import {
|
|||||||
CodeEditorSuggestionItemKind,
|
CodeEditorSuggestionItemKind,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { getTemplateSrv } from '@grafana/runtime';
|
import { getTemplateSrv } from '@grafana/runtime';
|
||||||
|
import InfluxDatasource from '../datasource';
|
||||||
|
|
||||||
interface Props {
|
// @ts-ignore -- complicated since the datasource is not really reactified yet!
|
||||||
target: InfluxQuery;
|
type Props = QueryEditorProps<InfluxDatasource, InfluxQuery>;
|
||||||
change: (target: InfluxQuery) => void;
|
|
||||||
refresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const samples: Array<SelectableValue<string>> = [
|
const samples: Array<SelectableValue<string>> = [
|
||||||
{ label: 'Show buckets', description: 'List the avaliable buckets (table)', value: 'buckets()' },
|
{ label: 'Show buckets', description: 'List the avaliable buckets (table)', value: 'buckets()' },
|
||||||
@ -87,20 +85,19 @@ v1.tagValues(
|
|||||||
|
|
||||||
export class FluxQueryEditor extends PureComponent<Props> {
|
export class FluxQueryEditor extends PureComponent<Props> {
|
||||||
onFluxQueryChange = (query: string) => {
|
onFluxQueryChange = (query: string) => {
|
||||||
const { target, change } = this.props;
|
this.props.onChange({ ...this.props.query, query });
|
||||||
change({ ...target, query });
|
this.props.onRunQuery();
|
||||||
this.props.refresh();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onSampleChange = (val: SelectableValue<string>) => {
|
onSampleChange = (val: SelectableValue<string>) => {
|
||||||
this.props.change({
|
this.props.onChange({
|
||||||
...this.props.target,
|
...this.props.query,
|
||||||
query: val.value!,
|
query: val.value!,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Angular HACK: Since the target does not actually change!
|
// Angular HACK: Since the target does not actually change!
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
this.props.refresh();
|
this.props.onRunQuery();
|
||||||
};
|
};
|
||||||
|
|
||||||
getSuggestions = (): CodeEditorSuggestionItem[] => {
|
getSuggestions = (): CodeEditorSuggestionItem[] => {
|
||||||
@ -157,7 +154,7 @@ export class FluxQueryEditor extends PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { target } = this.props;
|
const { query } = this.props;
|
||||||
|
|
||||||
const helpTooltip = (
|
const helpTooltip = (
|
||||||
<div>
|
<div>
|
||||||
@ -171,7 +168,7 @@ export class FluxQueryEditor extends PureComponent<Props> {
|
|||||||
<CodeEditor
|
<CodeEditor
|
||||||
height={'200px'}
|
height={'200px'}
|
||||||
language="sql"
|
language="sql"
|
||||||
value={target.query || ''}
|
value={query.query || ''}
|
||||||
onBlur={this.onFluxQueryChange}
|
onBlur={this.onFluxQueryChange}
|
||||||
onSave={this.onFluxQueryChange}
|
onSave={this.onFluxQueryChange}
|
||||||
showMiniMap={false}
|
showMiniMap={false}
|
||||||
@ -211,6 +208,6 @@ export class FluxQueryEditor extends PureComponent<Props> {
|
|||||||
coreModule.directive('fluxQueryEditor', [
|
coreModule.directive('fluxQueryEditor', [
|
||||||
'reactDirective',
|
'reactDirective',
|
||||||
(reactDirective: any) => {
|
(reactDirective: any) => {
|
||||||
return reactDirective(FluxQueryEditor, ['target', 'change', 'refresh']);
|
return reactDirective(FluxQueryEditor, ['query', 'onChange', 'onRunQuery']);
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@ -19,12 +19,13 @@ export default class VariableQueryEditor extends PureComponent<Props> {
|
|||||||
if (datasource.isFlux) {
|
if (datasource.isFlux) {
|
||||||
return (
|
return (
|
||||||
<FluxQueryEditor
|
<FluxQueryEditor
|
||||||
target={{
|
datasource={datasource}
|
||||||
|
query={{
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
query,
|
query,
|
||||||
}}
|
}}
|
||||||
refresh={this.onRefresh}
|
onRunQuery={this.onRefresh}
|
||||||
change={v => onChange(v.query)}
|
onChange={v => onChange(v.query)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ import { InfluxQueryBuilder } from './query_builder';
|
|||||||
import { InfluxQuery, InfluxOptions, InfluxVersion } from './types';
|
import { InfluxQuery, InfluxOptions, InfluxVersion } from './types';
|
||||||
import { getBackendSrv, getTemplateSrv, DataSourceWithBackend, frameToMetricFindValue } from '@grafana/runtime';
|
import { getBackendSrv, getTemplateSrv, DataSourceWithBackend, frameToMetricFindValue } from '@grafana/runtime';
|
||||||
import { Observable, from } from 'rxjs';
|
import { Observable, from } from 'rxjs';
|
||||||
|
import { FluxQueryEditor } from './components/FluxQueryEditor';
|
||||||
|
|
||||||
export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, InfluxOptions> {
|
export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery, InfluxOptions> {
|
||||||
type: string;
|
type: string;
|
||||||
@ -55,6 +56,13 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery,
|
|||||||
this.httpMode = settingsData.httpMode || 'GET';
|
this.httpMode = settingsData.httpMode || 'GET';
|
||||||
this.responseParser = new ResponseParser();
|
this.responseParser = new ResponseParser();
|
||||||
this.isFlux = settingsData.version === InfluxVersion.Flux;
|
this.isFlux = settingsData.version === InfluxVersion.Flux;
|
||||||
|
|
||||||
|
if (this.isFlux) {
|
||||||
|
// When flux, use an annotation processor rather than the `annotationQuery` lifecycle
|
||||||
|
this.annotations = {
|
||||||
|
QueryEditor: FluxQueryEditor,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query(request: DataQueryRequest<InfluxQuery>): Observable<DataQueryResponse> {
|
query(request: DataQueryRequest<InfluxQuery>): Observable<DataQueryResponse> {
|
||||||
@ -73,6 +81,16 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery,
|
|||||||
return new InfluxQueryModel(query).render(false);
|
return new InfluxQueryModel(query).render(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns false if the query should be skipped
|
||||||
|
*/
|
||||||
|
filterQuery(query: InfluxQuery): boolean {
|
||||||
|
if (this.isFlux) {
|
||||||
|
return !!query.query;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only applied on flux queries
|
* Only applied on flux queries
|
||||||
*/
|
*/
|
||||||
@ -183,7 +201,7 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery,
|
|||||||
async annotationQuery(options: AnnotationQueryRequest<any>): Promise<AnnotationEvent[]> {
|
async annotationQuery(options: AnnotationQueryRequest<any>): Promise<AnnotationEvent[]> {
|
||||||
if (this.isFlux) {
|
if (this.isFlux) {
|
||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
message: 'Annotations are not yet supported with flux queries',
|
message: 'Flux requires the standard annotation query',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
<query-editor-row ng-if="ctrl.datasource.isFlux" query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
|
<query-editor-row ng-if="ctrl.datasource.isFlux" query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
|
||||||
<flux-query-editor
|
<flux-query-editor
|
||||||
target="ctrl.target"
|
query="ctrl.target"
|
||||||
change="ctrl.onChange"
|
onChange="ctrl.onChange"
|
||||||
refresh="ctrl.onRunQuery"
|
onRunQuery="ctrl.onRunQuery"
|
||||||
></flux-query-editor>
|
></flux-query-editor>
|
||||||
</query-editor-row>
|
</query-editor-row>
|
||||||
|
|
||||||
|
@ -539,9 +539,6 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest<LokiQuery> {
|
|||||||
raw: timeRange,
|
raw: timeRange,
|
||||||
},
|
},
|
||||||
rangeRaw: timeRange,
|
rangeRaw: timeRange,
|
||||||
app: 'test',
|
|
||||||
interval: '1m',
|
|
||||||
intervalMs: 6000,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user