Commit 5d11d8fa by Ryan McKinley Committed by GitHub

Annotations: add standard annotations support (and use it for flux queries) (#27375)

parent 4707508f
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',
}
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.
*/
......
......@@ -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;
interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[];
/**
* 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.
* 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
*/
annotationQuery?(options: AnnotationQueryRequest<TAnno>): Promise<AnnotationEvent[]>;
annotations?: AnnotationSupport<TQuery>;
interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): 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<
......@@ -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<TAnno = {}> {
export interface AnnotationQueryRequest<MoreOptions = {}> {
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<TQuery extends DataQuery = DataQuery> {
......
export * from './data';
export * from './dataFrame';
export * from './dataLink';
export * from './annotations';
export * from './logs';
export * from './navModel';
export * from './select';
......
......@@ -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;
......
......@@ -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 [];
}
// Add interval to annotation queries
const interval = kbn.calculateInterval(range, maxDataPoints, datasource.interval);
// Use the legacy annotationQuery unless annotation support is explicitly defined
if (datasource.annotationQuery && !datasource.annotations) {
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<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);
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>
);
}
}
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} />
&nbsp;
</>
)}
{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 '@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;
......
......@@ -117,7 +117,15 @@
<h5 class="section-heading">Query</h5>
<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>
<div class="gf-form">
......
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",
},
]
`);
});
});
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;
}
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;
}
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<InfluxDatasource, InfluxQuery>;
const samples: Array<SelectableValue<string>> = [
{ label: 'Show buckets', description: 'List the avaliable buckets (table)', value: 'buckets()' },
......@@ -87,20 +85,19 @@ v1.tagValues(
export class FluxQueryEditor extends PureComponent<Props> {
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<string>) => {
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<Props> {
};
render() {
const { target } = this.props;
const { query } = this.props;
const helpTooltip = (
<div>
......@@ -171,7 +168,7 @@ export class FluxQueryEditor extends PureComponent<Props> {
<CodeEditor
height={'200px'}
language="sql"
value={target.query || ''}
value={query.query || ''}
onBlur={this.onFluxQueryChange}
onSave={this.onFluxQueryChange}
showMiniMap={false}
......@@ -211,6 +208,6 @@ export class FluxQueryEditor extends PureComponent<Props> {
coreModule.directive('fluxQueryEditor', [
'reactDirective',
(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) {
return (
<FluxQueryEditor
target={{
datasource={datasource}
query={{
refId: 'A',
query,
}}
refresh={this.onRefresh}
change={v => onChange(v.query)}
onRunQuery={this.onRefresh}
onChange={v => onChange(v.query)}
/>
);
}
......
......@@ -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<InfluxQuery, InfluxOptions> {
type: string;
......@@ -55,6 +56,13 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery,
this.httpMode = settingsData.httpMode || 'GET';
this.responseParser = new ResponseParser();
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> {
......@@ -74,6 +82,16 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery,
}
/**
* 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
*/
applyTemplateVariables(query: InfluxQuery, scopedVars: ScopedVars): Record<string, any> {
......@@ -183,7 +201,7 @@ export default class InfluxDatasource extends DataSourceWithBackend<InfluxQuery,
async annotationQuery(options: AnnotationQueryRequest<any>): Promise<AnnotationEvent[]> {
if (this.isFlux) {
return Promise.reject({
message: 'Annotations are not yet supported with flux queries',
message: 'Flux requires the standard annotation query',
});
}
......
<query-editor-row ng-if="ctrl.datasource.isFlux" query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
<flux-query-editor
target="ctrl.target"
change="ctrl.onChange"
refresh="ctrl.onRunQuery"
query="ctrl.target"
onChange="ctrl.onChange"
onRunQuery="ctrl.onRunQuery"
></flux-query-editor>
</query-editor-row>
......
......@@ -539,9 +539,6 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest<LokiQuery> {
raw: timeRange,
},
rangeRaw: timeRange,
app: 'test',
interval: '1m',
intervalMs: 6000,
};
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment