Alerting: adding query editor when creating threshold rule. (#33123)

* fix viz

* add datasource picker on query rows in mixed mode

* add timerange, handle add/remove queryrunners

* multiqueryrunner test

* trying things out.

* adding another test to verify running a induvidual query runner will update multirunner.

* cleaned up tests a bit.

* draft version working ok.

* fixing so we base the refId from request targets.

* reenable adding expression

* layout fixes for alerting page

* some cleanup

* cleaning up code that we won't use

* changed so we don't display the time range if params not passed.

* remove unused things in querygroup

* changed button to type button.

* removed timerange from dataQuery and removed multiquery runner.

* minor refactoring.

* renamed callback function to make it more clear what it does.

* renamed droppable area.

* changed so we only display the query editor when selecting threshold.

* removed the refresh picker.

* revert

* wip

* extending with data query.

* timerange fixes

* it is now possible to add grafana queries.

* removed unused type.

* removed expect import.

* added docs.

* moved range converting methods to rangeUtil.

* clean up some typings, remove file

* making sure we don't blow up on component being unmounted.

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Peter Holmberg
2021-04-21 13:57:17 +02:00
committed by GitHub
parent a151dfaa04
commit 569fb3f112
27 changed files with 512 additions and 315 deletions

View File

@@ -1,4 +1,4 @@
import { rangeUtil } from './index';
import { dateTime, rangeUtil } from './index';
describe('Range Utils', () => {
describe('relative time', () => {
@@ -40,4 +40,22 @@ describe('Range Utils', () => {
expect(() => rangeUtil.describeInterval('xyz')).toThrow();
});
});
describe('relativeToTimeRange', () => {
it('should convert seconds to timeRange', () => {
const relativeTimeRange = { from: 600, to: 300 };
const timeRange = rangeUtil.relativeToTimeRange(relativeTimeRange, dateTime('2021-04-20T15:55:00Z'));
expect(timeRange.from.valueOf()).toEqual(dateTime('2021-04-20T15:45:00Z').valueOf());
expect(timeRange.to.valueOf()).toEqual(dateTime('2021-04-20T15:50:00Z').valueOf());
});
it('should convert from now', () => {
const relativeTimeRange = { from: 600, to: 0 };
const timeRange = rangeUtil.relativeToTimeRange(relativeTimeRange, dateTime('2021-04-20T15:55:00Z'));
expect(timeRange.from.valueOf()).toEqual(dateTime('2021-04-20T15:45:00Z').valueOf());
expect(timeRange.to.valueOf()).toEqual(dateTime('2021-04-20T15:55:00Z').valueOf());
});
});
});

View File

@@ -1,9 +1,9 @@
import { each, groupBy, has } from 'lodash';
import { RawTimeRange, TimeRange, TimeZone, IntervalValues } from '../types/time';
import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange } from '../types/time';
import * as dateMath from './datemath';
import { isDateTime, DateTime } from './moment_wrapper';
import { isDateTime, DateTime, dateTime } from './moment_wrapper';
import { timeZoneAbbrevation, dateTimeFormat, dateTimeFormatTimeAgo } from './formatter';
import { dateTimeParse } from './parser';
@@ -432,3 +432,36 @@ export function roundInterval(interval: number) {
return 31536000000; // 1y
}
}
/**
* Converts a TimeRange to a RelativeTimeRange that can be used in
* e.g. alerting queries/rules.
*
* @internal
*/
export function timeRangeToRelative(timeRange: TimeRange): RelativeTimeRange {
const now = dateTime().unix();
const from = (now - timeRange.from.unix()) / 1000;
const to = (now - timeRange.to.unix()) / 1000;
return {
from,
to,
};
}
/**
* Converts a RelativeTimeRange to a TimeRange
*
* @internal
*/
export function relativeToTimeRange(relativeTimeRange: RelativeTimeRange, now: DateTime = dateTime()): TimeRange {
const from = dateTime(now).subtract(relativeTimeRange.from, 's');
const to = relativeTimeRange.to === 0 ? dateTime(now) : dateTime(now).subtract(relativeTimeRange.to, 's');
return {
from,
to,
raw: { from, to },
};
}

View File

@@ -11,6 +11,15 @@ export interface TimeRange {
raw: RawTimeRange;
}
/**
* Type to describe relative time to now in seconds.
* @internal
*/
export interface RelativeTimeRange {
from: number;
to: number;
}
export interface AbsoluteTimeRange {
from: number;
to: number;

View File

@@ -43,7 +43,7 @@ export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
};
return (
<>
<div className={styles.wrapper}>
<ToolbarButton
className={className}
isOpen={isOpen}
@@ -71,7 +71,7 @@ export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
</ClickOutsideWrapper>
</div>
)}
</>
</div>
);
});
@@ -79,6 +79,10 @@ ButtonSelect.displayName = 'ButtonSelect';
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
position: relative;
display: inline-flex;
`,
menuWrapper: css`
position: absolute;
z-index: ${theme.zIndex.dropdown};

View File

@@ -16,19 +16,17 @@ import {
evaluateAlertDefinition,
evaluateNotSavedAlertDefinition,
getAlertDefinition,
onRunQueries,
updateAlertDefinition,
updateAlertDefinitionOption,
updateAlertDefinitionUiState,
} from './state/actions';
import { StoreState } from 'app/types';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { GrafanaQuery } from '../../types/unified-alerting-dto';
function mapStateToProps(state: StoreState, props: RouteProps) {
return {
uiState: state.alertDefinition.uiState,
getQueryOptions: state.alertDefinition.getQueryOptions,
queryRunner: state.alertDefinition.queryRunner,
getInstances: state.alertDefinition.getInstances,
alertDefinition: state.alertDefinition.alertDefinition,
pageId: props.match.params.id as string,
@@ -43,7 +41,6 @@ const mapDispatchToProps = {
createAlertDefinition,
getAlertDefinition,
evaluateNotSavedAlertDefinition,
onRunQueries,
cleanUpDefinitionState,
};
@@ -125,18 +122,9 @@ class NextGenAlertingPageUnconnected extends PureComponent<Props> {
}
render() {
const {
alertDefinition,
uiState,
updateAlertDefinitionUiState,
getQueryOptions,
getInstances,
onRunQueries,
queryRunner,
} = this.props;
const { alertDefinition, uiState, updateAlertDefinitionUiState, getInstances } = this.props;
const styles = getStyles(config.theme);
const queryOptions = getQueryOptions();
return (
<div className={styles.wrapper}>
@@ -146,15 +134,8 @@ class NextGenAlertingPageUnconnected extends PureComponent<Props> {
<div className={styles.splitPanesWrapper}>
<SplitPaneWrapper
leftPaneComponents={[
<AlertingQueryPreview
key="queryPreview"
onTest={this.onTest}
queries={queryOptions.queries}
getInstances={getInstances}
queryRunner={queryRunner!}
onRunQueries={onRunQueries}
/>,
<AlertingQueryEditor key="queryEditor" />,
<AlertingQueryPreview key="queryPreview" getInstances={getInstances} queries={[]} onTest={this.onTest} />,
<AlertingQueryEditor key="queryEditor" value={[] as GrafanaQuery[]} onChange={() => {}} />,
]}
uiState={uiState}
updateUiState={updateAlertDefinitionUiState}
@@ -164,7 +145,6 @@ class NextGenAlertingPageUnconnected extends PureComponent<Props> {
onChange={this.onChangeAlertOption}
onIntervalChange={this.onChangeInterval}
onConditionChange={this.onConditionChange}
queryOptions={queryOptions}
/>
}
/>

View File

@@ -1,8 +1,8 @@
import React, { FC, FormEvent, useMemo } from 'react';
import React, { FC, FormEvent } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { Field, Input, Select, Tab, TabContent, TabsBar, TextArea, useStyles } from '@grafana/ui';
import { AlertDefinition, QueryGroupOptions } from 'app/types';
import { AlertDefinition } from 'app/types';
const intervalOptions: Array<SelectableValue<number>> = [
{ value: 60, label: '1m' },
@@ -15,20 +15,10 @@ interface Props {
onChange: (event: FormEvent<HTMLElement>) => void;
onIntervalChange: (interval: SelectableValue<number>) => void;
onConditionChange: (refId: SelectableValue<string>) => void;
queryOptions: QueryGroupOptions;
}
export const AlertDefinitionOptions: FC<Props> = ({
alertDefinition,
onChange,
onIntervalChange,
onConditionChange,
queryOptions,
}) => {
export const AlertDefinitionOptions: FC<Props> = ({ alertDefinition, onChange, onIntervalChange }) => {
const styles = useStyles(getStyles);
const refIds = useMemo(() => queryOptions.queries.map((q) => ({ value: q.refId, label: q.refId })), [
queryOptions.queries,
]);
return (
<div className={styles.wrapper}>
@@ -61,14 +51,7 @@ export const AlertDefinitionOptions: FC<Props> = ({
</div>
</Field>
<Field label="Conditions">
<div className={styles.optionRow}>
<Select
onChange={onConditionChange}
value={refIds.find((r) => r.value === alertDefinition.condition)}
options={refIds}
noOptionsMessage="No queries added"
/>
</div>
<div />
</Field>
</TabContent>
</div>

View File

@@ -1,82 +1,151 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { RefreshPicker, stylesFactory } from '@grafana/ui';
import { DataSourceApi, GrafanaTheme } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Button, HorizontalGroup, Icon, stylesFactory, Tooltip } from '@grafana/ui';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { AlertingQueryRows } from './AlertingQueryRows';
import {
expressionDatasource,
ExpressionDatasourceID,
ExpressionDatasourceUID,
} from '../../expressions/ExpressionDatasource';
import { getNextRefIdChar } from 'app/core/utils/query';
import { defaultCondition } from '../../expressions/utils/expressionTypes';
import { ExpressionQueryType } from '../../expressions/types';
import { GrafanaQuery, GrafanaQueryModel } from 'app/types/unified-alerting-dto';
import { config } from 'app/core/config';
import { QueryGroup } from '../../query/components/QueryGroup';
import { onRunQueries, queryOptionsChange } from '../state/actions';
import { QueryGroupOptions, StoreState } from 'app/types';
function mapStateToProps(state: StoreState) {
return {
queryOptions: state.alertDefinition.getQueryOptions(),
queryRunner: state.alertDefinition.queryRunner,
};
interface Props {
value?: GrafanaQuery[];
onChange: (queries: GrafanaQuery[]) => void;
}
const mapDispatchToProps = {
queryOptionsChange,
onRunQueries,
};
interface State {
defaultDataSource?: DataSourceApi;
}
export class AlertingQueryEditor extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
const connector = connect(mapStateToProps, mapDispatchToProps);
async componentDidMount() {
try {
const defaultDataSource = await getDataSourceSrv().get();
this.setState({ defaultDataSource });
} catch (error) {
console.error(error);
}
}
interface OwnProps {}
onRunQueries = () => {};
type Props = OwnProps & ConnectedProps<typeof connector>;
class AlertingQueryEditorUnconnected extends PureComponent<Props> {
onQueryOptionsChange = (queryOptions: QueryGroupOptions) => {
this.props.queryOptionsChange(queryOptions);
onDuplicateQuery = (query: GrafanaQuery) => {
const { onChange, value = [] } = this.props;
onChange([...value, query]);
};
onRunQueries = () => {
this.props.onRunQueries();
onNewAlertingQuery = () => {
const { onChange, value = [] } = this.props;
const { defaultDataSource } = this.state;
if (!defaultDataSource) {
return;
}
const alertingQuery: GrafanaQueryModel = {
refId: '',
datasourceUid: defaultDataSource.uid,
datasource: defaultDataSource.name,
};
onChange(addQuery(value, alertingQuery));
};
onIntervalChanged = (interval: string) => {
this.props.queryOptionsChange({ ...this.props.queryOptions, minInterval: interval });
onNewExpressionQuery = () => {
const { onChange, value = [] } = this.props;
const expressionQuery: GrafanaQueryModel = {
...expressionDatasource.newQuery({
type: ExpressionQueryType.classic,
conditions: [defaultCondition],
}),
datasourceUid: ExpressionDatasourceUID,
datasource: ExpressionDatasourceID,
};
onChange(addQuery(value, expressionQuery));
};
renderAddQueryRow(styles: ReturnType<typeof getStyles>) {
return (
<HorizontalGroup spacing="md" align="flex-start">
<Button
type="button"
icon="plus"
onClick={this.onNewAlertingQuery}
variant="secondary"
aria-label={selectors.components.QueryTab.addQuery}
>
Query
</Button>
{config.expressionsEnabled && (
<Tooltip content="Experimental feature: queries could stop working in next version" placement="right">
<Button
type="button"
icon="plus"
onClick={this.onNewExpressionQuery}
variant="secondary"
className={styles.expressionButton}
>
<span>Expression&nbsp;</span>
<Icon name="exclamation-triangle" className="muted" size="sm" />
</Button>
</Tooltip>
)}
</HorizontalGroup>
);
}
render() {
const { queryOptions, queryRunner } = this.props;
const { value = [] } = this.props;
const styles = getStyles(config.theme);
return (
<div className={styles.wrapper}>
<div className={styles.container}>
<h4>Queries</h4>
<div className={styles.refreshWrapper}>
<RefreshPicker
onIntervalChanged={this.onIntervalChanged}
onRefresh={this.onRunQueries}
intervals={['15s', '30s']}
/>
</div>
<QueryGroup
queryRunner={queryRunner!} // if the queryRunner is undefined here, somethings very wrong so it's ok to throw an unhandled error
options={queryOptions}
onRunQueries={this.onRunQueries}
onOptionsChange={this.onQueryOptionsChange}
/>
</div>
<div className={styles.container}>
<AlertingQueryRows
queries={value}
onQueriesChange={this.props.onChange}
onDuplicateQuery={this.onDuplicateQuery}
onRunQueries={this.onRunQueries}
/>
{this.renderAddQueryRow(styles)}
</div>
);
}
}
export const AlertingQueryEditor = connector(AlertingQueryEditorUnconnected);
const addQuery = (queries: GrafanaQuery[], model: GrafanaQueryModel): GrafanaQuery[] => {
const refId = getNextRefIdChar(queries);
const query: GrafanaQuery = {
refId,
queryType: '',
model: {
...model,
hide: false,
refId: refId,
},
relativeTimeRange: {
from: 21600,
to: 0,
},
};
return [...queries, query];
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
wrapper: css`
padding-left: ${theme.spacing.md};
height: 100%;
`,
container: css`
padding: ${theme.spacing.md};
background-color: ${theme.colors.panelBg};
height: 100%;
`,
@@ -88,5 +157,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
border: 1px solid ${theme.colors.panelBorder};
border-radius: ${theme.border.radius.md};
`,
expressionButton: css`
margin-right: ${theme.spacing.sm};
`,
};
});

View File

@@ -1,14 +1,11 @@
import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
import React, { FC, useState } from 'react';
import { css } from '@emotion/css';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DataFrame, DataQuery, GrafanaTheme, PanelData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Icon, Tab, TabContent, TabsBar } from '@grafana/ui';
import { Icon, Tab, TabContent, TabsBar, useStyles } from '@grafana/ui';
import { PreviewQueryTab } from './PreviewQueryTab';
import { PreviewInstancesTab } from './PreviewInstancesTab';
import { EmptyState } from './EmptyState';
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
enum Tabs {
Query = 'query',
@@ -21,18 +18,17 @@ const tabs = [
];
interface Props {
queryRunner: PanelQueryRunner;
getInstances: () => DataFrame[];
queries: DataQuery[];
onTest: () => void;
onRunQueries: () => void;
}
export const AlertingQueryPreview: FC<Props> = ({ getInstances, onRunQueries, onTest, queries, queryRunner }) => {
export const AlertingQueryPreview: FC<Props> = ({ getInstances, onTest, queries }) => {
const [activeTab, setActiveTab] = useState<string>(Tabs.Query);
const styles = getStyles(config.theme);
const observable = useMemo(() => queryRunner.getData({ withFieldConfig: true, withTransforms: true }), [queryRunner]);
const data = useObservable<PanelData>(observable);
const styles = useStyles(getStyles);
let data = {} as PanelData;
const instances = getInstances();
return (
@@ -61,7 +57,6 @@ export const AlertingQueryPreview: FC<Props> = ({ getInstances, onRunQueries, on
onTest={onTest}
data={data}
activeTab={activeTab}
onRunQueries={onRunQueries}
queries={queries}
/>
))}
@@ -76,10 +71,9 @@ interface PreviewProps {
onTest: () => void;
data: PanelData;
activeTab: string;
onRunQueries: () => void;
}
const QueriesAndInstances: FC<PreviewProps> = ({ queries, instances, onTest, data, activeTab, onRunQueries }) => {
const QueriesAndInstances: FC<PreviewProps> = ({ queries, instances, onTest, data, activeTab }) => {
if (queries.length === 0) {
return (
<EmptyState title="No queries added.">
@@ -100,7 +94,7 @@ const QueriesAndInstances: FC<PreviewProps> = ({ queries, instances, onTest, dat
case Tabs.Query:
default:
return <PreviewQueryTab data={data} width={width} height={height} onRunQueries={onRunQueries} />;
return <PreviewQueryTab data={data} width={width} height={height} />;
}
}}
</AutoSizer>

View File

@@ -0,0 +1,143 @@
import React, { PureComponent } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { DataQuery, DataSourceApi, DataSourceInstanceSettings, rangeUtil, PanelData, TimeRange } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
interface Props {
// The query configuration
queries: GrafanaQuery[];
// Query editing
onQueriesChange: (queries: GrafanaQuery[]) => void;
onDuplicateQuery: (query: GrafanaQuery) => void;
onRunQueries: () => void;
}
interface State {
dataPerQuery: Record<string, PanelData>;
defaultDataSource: DataSourceApi;
}
export class AlertingQueryRows extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { dataPerQuery: {}, defaultDataSource: {} as DataSourceApi };
}
async componentDidMount() {
const defaultDataSource = await getDataSourceSrv().get();
this.setState({ defaultDataSource });
}
onRemoveQuery = (query: DataQuery) => {
this.props.onQueriesChange(this.props.queries.filter((item) => item.model !== query));
};
onChangeTimeRange(timeRange: TimeRange, index: number) {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
if (itemIndex === index) {
return { ...item, relativeTimeRange: rangeUtil.timeRangeToRelative(timeRange) };
}
return item;
})
);
}
onChangeQuery(query: DataQuery, index: number) {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
if (itemIndex === index) {
return { ...item, model: { ...item.model, ...query, datasource: query.datasource! } };
}
return item;
})
);
}
onDragEnd = (result: DropResult) => {
const { queries, onQueriesChange } = this.props;
if (!result || !result.destination) {
return;
}
const startIndex = result.source.index;
const endIndex = result.destination.index;
if (startIndex === endIndex) {
return;
}
const update = Array.from(queries);
const [removed] = update.splice(startIndex, 1);
update.splice(endIndex, 0, removed);
onQueriesChange(update);
};
getDataSourceSettings = (query: DataQuery): DataSourceInstanceSettings | undefined => {
const { defaultDataSource } = this.state;
if (isExpressionQuery(query)) {
return getDataSourceSrv().getInstanceSettings(defaultDataSource.name);
}
return getDataSourceSrv().getInstanceSettings(query.datasource);
};
render() {
const { queries } = this.props;
return (
<DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="alerting-queries" direction="vertical">
{(provided) => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
{queries.map((query: GrafanaQuery, index) => {
const data = this.state.dataPerQuery[query.refId];
const dsSettings = this.getDataSourceSettings(query);
if (!dsSettings) {
return null;
}
return (
<QueryEditorRow
dsSettings={{ ...dsSettings, meta: { ...dsSettings.meta, mixed: true } }}
id={query.refId}
index={index}
key={query.refId}
data={data}
query={query.model}
onChange={(query) => this.onChangeQuery(query, index)}
timeRange={
!isExpressionQuery(query.model)
? rangeUtil.relativeToTimeRange(query.relativeTimeRange)
: undefined
}
onChangeTimeRange={
!isExpressionQuery(query.model)
? (timeRange) => this.onChangeTimeRange(timeRange, index)
: undefined
}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.props.onDuplicateQuery}
onRunQuery={this.props.onRunQueries}
queries={queries}
/>
);
})}
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
);
}
}

View File

@@ -8,10 +8,9 @@ interface Props {
data: PanelData;
width: number;
height: number;
onRunQueries: () => void;
}
export const PreviewQueryTab: FC<Props> = ({ data, height, onRunQueries, width }) => {
export const PreviewQueryTab: FC<Props> = ({ data, height, width }) => {
const [currentSeries, setSeries] = useState<number>(0);
const theme = useTheme();
const styles = getStyles(theme, height);
@@ -29,7 +28,7 @@ export const PreviewQueryTab: FC<Props> = ({ data, height, onRunQueries, width }
if (!data) {
return (
<EmptyState title="Run queries to view data.">
<Button onClick={onRunQueries}>Run queries</Button>
<Button>Run queries</Button>
</EmptyState>
);
}

View File

@@ -3,8 +3,8 @@ import {
applyFieldOverrides,
dataFrameFromJSON,
DataFrameJSON,
DataQuery,
DataSourceApi,
dateMath,
} from '@grafana/data';
import { config, getBackendSrv, getDataSourceSrv, locationService } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
@@ -19,7 +19,6 @@ import {
setAlertDefinitions,
setInstanceData,
setNotificationChannels,
setQueryOptions,
setUiState,
updateAlertDefinitionOptions,
} from './reducers';
@@ -34,7 +33,7 @@ import {
ThunkResult,
} from 'app/types';
import { ExpressionDatasourceID } from '../../expressions/ExpressionDatasource';
import { ExpressionQuery } from '../../expressions/types';
import { isExpressionQuery } from 'app/features/expressions/guards';
export function getAlertRulesAsync(options: { state: string }): ThunkResult<void> {
return async (dispatch) => {
@@ -159,30 +158,6 @@ export function updateAlertDefinitionOption(alertDefinition: Partial<AlertDefini
};
}
export function queryOptionsChange(queryOptions: QueryGroupOptions): ThunkResult<void> {
return (dispatch) => {
dispatch(setQueryOptions(queryOptions));
};
}
export function onRunQueries(): ThunkResult<void> {
return (dispatch, getStore) => {
const { queryRunner, getQueryOptions } = getStore().alertDefinition;
const timeRange = { from: 'now-1h', to: 'now' };
const queryOptions = getQueryOptions();
queryRunner!.run({
// if the queryRunner is undefined here somethings very wrong so it's ok to throw an unhandled error
timezone: 'browser',
timeRange: { from: dateMath.parse(timeRange.from)!, to: dateMath.parse(timeRange.to)!, raw: timeRange },
maxDataPoints: queryOptions.maxDataPoints ?? 100,
minInterval: queryOptions.minInterval,
queries: queryOptions.queries,
datasource: queryOptions.dataSource.name!,
});
};
}
export function evaluateAlertDefinition(): ThunkResult<void> {
return async (dispatch, getStore) => {
const { alertDefinition } = getStore().alertDefinition;
@@ -200,12 +175,12 @@ export function evaluateAlertDefinition(): ThunkResult<void> {
export function evaluateNotSavedAlertDefinition(): ThunkResult<void> {
return async (dispatch, getStore) => {
const { alertDefinition, getQueryOptions } = getStore().alertDefinition;
const { alertDefinition } = getStore().alertDefinition;
const defaultDataSource = await getDataSourceSrv().get(null);
const response: { instances: DataFrameJSON[] } = await getBackendSrv().post('/api/alert-definitions/eval', {
condition: alertDefinition.condition,
data: buildDataQueryModel(getQueryOptions(), defaultDataSource),
data: buildDataQueryModel({} as QueryGroupOptions, defaultDataSource),
});
const handledResponse = handleJSONResponse(response.instances);
@@ -221,7 +196,7 @@ export function cleanUpDefinitionState(): ThunkResult<void> {
}
async function buildAlertDefinition(state: AlertDefinitionState) {
const queryOptions = state.getQueryOptions();
const queryOptions = {} as QueryGroupOptions;
const currentAlertDefinition = state.alertDefinition;
const defaultDataSource = await getDataSourceSrv().get(null);
@@ -248,32 +223,41 @@ function handleJSONResponse(frames: DataFrameJSON[]) {
}
function buildDataQueryModel(queryOptions: QueryGroupOptions, defaultDataSource: DataSourceApi) {
return queryOptions.queries.map((query) => {
let dataSource: QueryGroupDataSource;
const isExpression = query.datasource === ExpressionDatasourceID;
return queryOptions.queries.map((query: DataQuery) => {
if (isExpressionQuery(query)) {
const dataSource: QueryGroupDataSource = {
name: ExpressionDatasourceID,
uid: ExpressionDatasourceID,
};
if (isExpression) {
dataSource = { name: ExpressionDatasourceID, uid: ExpressionDatasourceID };
} else {
const dataSourceSetting = getDataSourceSrv().getInstanceSettings(query.datasource);
dataSource = {
name: dataSourceSetting?.name ?? defaultDataSource.name,
uid: dataSourceSetting?.uid ?? defaultDataSource.uid,
return {
model: {
...query,
type: query.type,
datasource: dataSource.name,
datasourceUid: dataSource.uid,
},
refId: query.refId,
};
}
const dataSourceSetting = getDataSourceSrv().getInstanceSettings(query.datasource);
const dataSource: QueryGroupDataSource = {
name: dataSourceSetting?.name ?? defaultDataSource.name,
uid: dataSourceSetting?.uid ?? defaultDataSource.uid,
};
return {
model: {
...query,
type: isExpression ? (query as ExpressionQuery).type : query.queryType,
type: query.queryType,
datasource: dataSource.name,
datasourceUid: dataSource.uid,
},
refId: query.refId,
relativeTimeRange: {
From: 500,
To: 0,
from: 600,
to: 0,
},
};
});

View File

@@ -1,10 +1,9 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ApplyFieldOverrideOptions, DataFrame, DataTransformerConfig, dateTime, FieldColorModeId } from '@grafana/data';
import { DataFrame, dateTime } from '@grafana/data';
import alertDef from './alertDef';
import {
AlertDefinition,
AlertDefinitionDTO,
AlertDefinitionQueryModel,
AlertDefinitionState,
AlertDefinitionUiState,
AlertRule,
@@ -13,11 +12,8 @@ import {
NotificationChannelOption,
NotificationChannelState,
NotifierDTO,
QueryGroupOptions,
} from 'app/types';
import store from 'app/core/store';
import { config } from '@grafana/runtime';
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
import unifiedAlertingReducer from '../unified/state/reducers';
export const ALERT_DEFINITION_UI_STATE_STORAGE_KEY = 'grafana.alerting.alertDefinition.ui';
@@ -35,24 +31,6 @@ export const initialChannelState: NotificationChannelState = {
notifiers: [],
};
const options: ApplyFieldOverrideOptions = {
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.PaletteClassic,
},
},
overrides: [],
},
replaceVariables: (v: string) => v,
theme: config.theme,
};
const dataConfig = {
getTransformations: () => [] as DataTransformerConfig[],
getFieldOverrideOptions: () => options,
};
export const initialAlertDefinitionState: AlertDefinitionState = {
alertDefinition: {
id: 0,
@@ -63,14 +41,12 @@ export const initialAlertDefinitionState: AlertDefinitionState = {
data: [],
intervalSeconds: 60,
},
queryRunner: new PanelQueryRunner(dataConfig),
uiState: { ...store.getObject(ALERT_DEFINITION_UI_STATE_STORAGE_KEY, DEFAULT_ALERT_DEFINITION_UI_STATE) },
data: [],
alertDefinitions: [] as AlertDefinition[],
/* These are functions as they are mutated later on and redux toolkit will Object.freeze state so
* we need to store these using functions instead */
getInstances: () => [] as DataFrame[],
getQueryOptions: () => ({ maxDataPoints: 100, dataSource: { name: '-- Mixed --' }, queries: [] }),
};
function convertToAlertRule(dto: AlertRuleDTO, state: string): AlertRule {
@@ -164,8 +140,6 @@ const alertDefinitionSlice = createSlice({
initialState: initialAlertDefinitionState,
reducers: {
setAlertDefinition: (state: AlertDefinitionState, action: PayloadAction<AlertDefinitionDTO>) => {
const currentOptions = state.getQueryOptions();
state.alertDefinition.title = action.payload.title;
state.alertDefinition.id = action.payload.id;
state.alertDefinition.uid = action.payload.uid;
@@ -173,10 +147,6 @@ const alertDefinitionSlice = createSlice({
state.alertDefinition.intervalSeconds = action.payload.intervalSeconds;
state.alertDefinition.data = action.payload.data;
state.alertDefinition.description = action.payload.description;
state.getQueryOptions = () => ({
...currentOptions,
queries: action.payload.data.map((q: AlertDefinitionQueryModel) => ({ ...q.model })),
});
},
updateAlertDefinitionOptions: (state: AlertDefinitionState, action: PayloadAction<Partial<AlertDefinition>>) => {
state.alertDefinition = { ...state.alertDefinition, ...action.payload };
@@ -184,9 +154,6 @@ const alertDefinitionSlice = createSlice({
setUiState: (state: AlertDefinitionState, action: PayloadAction<AlertDefinitionUiState>) => {
state.uiState = { ...state.uiState, ...action.payload };
},
setQueryOptions: (state: AlertDefinitionState, action: PayloadAction<QueryGroupOptions>) => {
state.getQueryOptions = () => action.payload;
},
setAlertDefinitions: (state: AlertDefinitionState, action: PayloadAction<AlertDefinition[]>) => {
state.alertDefinitions = action.payload;
},
@@ -194,18 +161,10 @@ const alertDefinitionSlice = createSlice({
state.getInstances = () => action.payload;
},
cleanUpState: (state: AlertDefinitionState, action: PayloadAction<undefined>) => {
if (state.queryRunner) {
state.queryRunner.destroy();
state.queryRunner = undefined;
delete state.queryRunner;
state.queryRunner = new PanelQueryRunner(dataConfig);
}
state.alertDefinitions = initialAlertDefinitionState.alertDefinitions;
state.alertDefinition = initialAlertDefinitionState.alertDefinition;
state.data = initialAlertDefinitionState.data;
state.getInstances = initialAlertDefinitionState.getInstances;
state.getQueryOptions = initialAlertDefinitionState.getQueryOptions;
state.uiState = initialAlertDefinitionState.uiState;
},
},
@@ -222,7 +181,6 @@ export const {
export const {
setUiState,
updateAlertDefinitionOptions,
setQueryOptions,
setAlertDefinitions,
setAlertDefinition,
setInstanceData,

View File

@@ -83,7 +83,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
onClick={handleSubmit((values) => submit(values, false))}
disabled={submitState.loading}
>
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
{submitState.loading && <Spinner className={styles.buttonSpinner} inline={true} />}
Save
</ToolbarButton>
<ToolbarButton
@@ -92,11 +92,11 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
onClick={handleSubmit((values) => submit(values, true))}
disabled={submitState.loading}
>
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
{submitState.loading && <Spinner className={styles.buttonSpinner} inline={true} />}
Save and exit
</ToolbarButton>
</PageToolbar>
<div className={styles.contentOutter}>
<div className={styles.contentOuter}>
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<div className={styles.contentInner}>
{hasErrors && (
@@ -127,7 +127,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
const getStyles = (theme: GrafanaTheme) => {
return {
buttonSpiner: css`
buttonSpinner: css`
margin-right: ${theme.spacing.sm};
`,
toolbar: css`
@@ -145,7 +145,7 @@ const getStyles = (theme: GrafanaTheme) => {
flex: 1;
padding: ${theme.spacing.md};
`,
contentOutter: css`
contentOuter: css`
background: ${theme.colors.panelBg};
overflow: hidden;
flex: 1;

View File

@@ -1,27 +0,0 @@
import { TextArea } from '@grafana/ui';
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
import React, { FC, useState } from 'react';
interface Props {
value?: GrafanaQuery[];
onChange: (value: GrafanaQuery[]) => void;
}
// @TODO replace with actual query editor once it's done
export const GrafanaQueryEditor: FC<Props> = ({ value, onChange }) => {
const [content, setContent] = useState(JSON.stringify(value || [], null, 2));
const onChangeHandler = (e: React.FormEvent<HTMLTextAreaElement>) => {
const val = (e.target as HTMLTextAreaElement).value;
setContent(val);
try {
const parsed = JSON.parse(val);
if (parsed && Array.isArray(parsed)) {
console.log('queries changed');
onChange(parsed);
}
} catch (e) {
console.log('invalid json');
}
};
return <TextArea rows={20} value={content} onChange={onChangeHandler} />;
};

View File

@@ -1,13 +1,11 @@
import React, { FC } from 'react';
import { Field, InputControl } from '@grafana/ui';
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { Field, InputControl } from '@grafana/ui';
import { ExpressionEditor } from './ExpressionEditor';
import { GrafanaQueryEditor } from './GrafanaQueryEditor';
import { isArray } from 'lodash';
import { RuleEditorSection } from './RuleEditorSection';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { AlertingQueryEditor } from '../../../components/AlertingQueryEditor';
// @TODO get proper query editors in
export const QueryStep: FC = () => {
const { control, watch, errors } = useFormContext<RuleFormValues>();
const type = watch('type');
@@ -34,10 +32,10 @@ export const QueryStep: FC = () => {
>
<InputControl
name="queries"
as={GrafanaQueryEditor}
as={AlertingQueryEditor}
control={control}
rules={{
validate: (queries) => isArray(queries) && !!queries.length,
validate: (queries) => Array.isArray(queries) && !!queries.length,
}}
/>
</Field>

View File

@@ -156,7 +156,7 @@ function rulerRuleToCombinedRule(
}
: {
name: rule.grafana_alert.title,
queries: rule.grafana_alert.data.map((d) => d.model),
queries: (rule.grafana_alert.data ?? []).map((d) => d.model),
query: '',
labels: rule.labels || {},
annotations: rule.annotations || {},

View File

@@ -41,7 +41,7 @@ export const SAMPLE_QUERIES = [
type: 'and',
},
query: {
Params: ['A'],
params: ['A'],
},
reducer: {
type: 'last',

View File

@@ -15,17 +15,19 @@ export class ExpressionDatasourceApi extends DataSourceWithBackend<ExpressionQue
return `Expression: ${query.type}`;
}
newQuery(): ExpressionQuery {
newQuery(query?: Partial<ExpressionQuery>): ExpressionQuery {
return {
refId: '--', // Replaced with query
type: ExpressionQueryType.math,
type: query?.type ?? ExpressionQueryType.math,
datasource: ExpressionDatasourceID,
conditions: query?.conditions ?? undefined,
};
}
}
// MATCHES the constant in DataSourceWithBackend
export const ExpressionDatasourceID = '__expr__';
export const ExpressionDatasourceUID = '-100';
export const expressionDatasource = new ExpressionDatasourceApi({
id: -100,

View File

@@ -0,0 +1,7 @@
import { DataQuery } from '@grafana/data';
import { ExpressionDatasourceID } from './ExpressionDatasource';
import { ExpressionQuery } from './types';
export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is ExpressionQuery => {
return dataQuery?.datasource === ExpressionDatasourceID;
};

View File

@@ -49,7 +49,6 @@ export interface ExpressionQuery extends DataQuery {
upsampler?: string;
conditions?: ClassicCondition[];
}
export interface ClassicCondition {
evaluator: {
params: number[];

View File

@@ -4,7 +4,7 @@ import classNames from 'classnames';
import { has, cloneDeep } from 'lodash';
// Utils & Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AngularComponent, getAngularLoader, getTemplateSrv } from '@grafana/runtime';
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { ErrorBoundaryAlert, HorizontalGroup, InfoBox } from '@grafana/ui';
import {
@@ -36,6 +36,8 @@ interface Props {
dsSettings: DataSourceInstanceSettings;
id: string;
index: number;
timeRange?: TimeRange;
onChangeTimeRange?: (timeRange: TimeRange) => void;
onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
onChange: (query: DataQuery) => void;
@@ -301,17 +303,18 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
renderTitle = (props: QueryOperationRowRenderProps) => {
const { query, dsSettings, onChange, queries } = this.props;
const dataSourceName = dsSettings.meta.mixed
? getTemplateSrv().replace(this.getQueryDataSourceIdentifier() ?? '')
: undefined;
const { query, dsSettings, onChange, queries, onChangeTimeRange, timeRange } = this.props;
const { datasource } = this.state;
const isDisabled = query.hide;
return (
<QueryEditorRowTitle
query={query}
queries={queries}
dataSourceName={dataSourceName}
onTimeRangeChange={onChangeTimeRange}
timeRange={timeRange}
inMixedMode={dsSettings.meta.mixed}
dataSourceName={datasource!.name}
disabled={isDisabled}
onClick={(e) => this.onToggleEditMode(e, props)}
onChange={onChange}

View File

@@ -1,14 +1,19 @@
import React, { useState } from 'react';
import { css, cx } from '@emotion/css';
import { DataQuery, GrafanaTheme } from '@grafana/data';
import { FieldValidationMessage, Icon, Input, stylesFactory, useTheme } from '@grafana/ui';
import { DataQuery, DataSourceInstanceSettings, GrafanaTheme, TimeRange } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Icon, Input, stylesFactory, useTheme, FieldValidationMessage, TimeRangeInput } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { ExpressionDatasourceID } from '../../expressions/ExpressionDatasource';
export interface Props {
query: DataQuery;
queries: DataQuery[];
dataSourceName?: string;
dataSourceName: string;
inMixedMode?: boolean;
disabled?: boolean;
timeRange?: TimeRange;
onTimeRangeChange?: (timeRange: TimeRange) => void;
onChange: (query: DataQuery) => void;
onClick: (e: React.MouseEvent) => void;
collapsedText: string | null;
@@ -16,11 +21,14 @@ export interface Props {
export const QueryEditorRowTitle: React.FC<Props> = ({
dataSourceName,
inMixedMode,
disabled,
query,
queries,
onClick,
onChange,
onTimeRangeChange,
timeRange,
collapsedText,
}) => {
const theme = useTheme();
@@ -83,6 +91,10 @@ export const QueryEditorRowTitle: React.FC<Props> = ({
event.target.select();
};
const onDataSourceChange = (dataSource: DataSourceInstanceSettings) => {
onChange({ ...query, datasource: dataSource.name });
};
return (
<div className={styles.wrapper}>
{!isEditing && (
@@ -114,7 +126,17 @@ export const QueryEditorRowTitle: React.FC<Props> = ({
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
{dataSourceName && <em className={styles.contextInfo}> ({dataSourceName})</em>}
{inMixedMode && (
<div style={{ display: 'flex', marginLeft: '8px' }}>
{query.datasource !== ExpressionDatasourceID && (
<>
<DataSourcePicker current={dataSourceName} onChange={onDataSourceChange} />
{onTimeRangeChange && timeRange && <TimeRangeInput onChange={onTimeRangeChange} value={timeRange} />}
</>
)}
</div>
)}
{dataSourceName && !inMixedMode && <em className={styles.contextInfo}> ({dataSourceName})</em>}
{disabled && <em className={styles.contextInfo}> Disabled</em>}
{collapsedText && (

View File

@@ -28,12 +28,6 @@ export class QueryEditorRows extends PureComponent<Props> {
onChangeQuery(query: DataQuery, index: number) {
const { queries, onQueriesChange } = this.props;
const old = queries[index];
if (old.datasource) {
query.datasource = old.datasource;
}
// update query in array
onQueriesChange(
queries.map((item, itemIndex) => {

View File

@@ -45,6 +45,7 @@ interface State {
scrollTop: number;
data: PanelData;
isHelpOpen: boolean;
defaultDataSource?: DataSourceApi;
}
export class QueryGroup extends PureComponent<Props, State> {
@@ -76,7 +77,8 @@ export class QueryGroup extends PureComponent<Props, State> {
try {
const ds = await this.dataSourceSrv.get(options.dataSource.name);
const dsSettings = this.dataSourceSrv.getInstanceSettings(options.dataSource.name);
this.setState({ dataSource: ds, dsSettings });
const defaultDataSource = await this.dataSourceSrv.get();
this.setState({ dataSource: ds, dsSettings, defaultDataSource });
} catch (error) {
console.log('failed to load data source', error);
}
@@ -140,15 +142,23 @@ export class QueryGroup extends PureComponent<Props, State> {
};
onAddQueryClick = () => {
if (this.state.dsSettings?.meta.mixed) {
this.setState({ isAddingMixed: true });
return;
}
this.onChange({ queries: addQuery(this.props.options.queries) });
const { options } = this.props;
this.onChange({ queries: addQuery(options.queries, this.newQuery()) });
this.onScrollBottom();
};
newQuery(): Partial<DataQuery> {
const { dsSettings, defaultDataSource } = this.state;
if (!dsSettings?.meta.mixed) {
return {};
}
return {
datasource: defaultDataSource?.name,
};
}
onChange(changedProps: Partial<QueryGroupOptions>) {
this.props.onOptionsChange({
...this.props.options,
@@ -320,7 +330,6 @@ export class QueryGroup extends PureComponent<Props, State> {
Query
</Button>
)}
{isAddingMixed && this.renderMixedPicker()}
{config.expressionsEnabled && this.isExpressionsSupported(dsSettings) && (
<Tooltip content="Experimental feature: queries could stop working in next version" placement="right">
<Button

View File

@@ -1,4 +1,11 @@
import { ApplyFieldOverrideOptions, DataTransformerConfig, dateMath, FieldColorModeId, PanelData } from '@grafana/data';
import {
ApplyFieldOverrideOptions,
DataTransformerConfig,
dateMath,
FieldColorModeId,
NavModelItem,
PanelData,
} from '@grafana/data';
import { GraphNG, LegendDisplayMode, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react';
@@ -6,6 +13,8 @@ import { useObservable } from 'react-use';
import { QueryGroup } from '../query/components/QueryGroup';
import { PanelQueryRunner } from '../query/state/PanelQueryRunner';
import { QueryGroupOptions } from 'app/types';
import Page from '../../core/components/Page/Page';
import AutoSizer from 'react-virtualized-auto-sizer';
interface State {
queryRunner: PanelQueryRunner;
@@ -40,33 +49,46 @@ export const TestStuffPage: FC = () => {
const observable = useMemo(() => queryRunner.getData({ withFieldConfig: true, withTransforms: true }), [queryRunner]);
const data = useObservable(observable);
return (
<div style={{ padding: '30px 50px' }} className="page-scrollbar-wrapper">
<h3>New page</h3>
<div>
<QueryGroup
options={queryOptions}
queryRunner={queryRunner}
onRunQueries={onRunQueries}
onOptionsChange={onOptionsChange}
/>
</div>
const node: NavModelItem = {
id: 'test-page',
text: 'Test page',
icon: 'dashboard',
subTitle: 'FOR TESTING!',
url: 'sandbox/test',
};
{data && (
<div style={{ padding: '16px' }}>
<GraphNG
width={1200}
height={300}
data={data.series}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeRange={data.timeRange}
timeZone="browser"
return (
<Page navModel={{ node: node, main: node }}>
<Page.Contents>
{data && (
<AutoSizer style={{ width: '100%', height: '600px' }}>
{({ width }) => {
return (
<div>
<GraphNG
width={width}
height={300}
data={data.series}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeRange={data.timeRange}
timeZone="browser"
/>
<Table data={data.series[0]} width={width} height={300} />
</div>
);
}}
</AutoSizer>
)}
<div style={{ marginTop: '16px', height: '45%' }}>
<QueryGroup
options={queryOptions}
queryRunner={queryRunner}
onRunQueries={onRunQueries}
onOptionsChange={onOptionsChange}
/>
<hr />
<Table data={data.series[0]} width={1200} height={300} />
</div>
)}
</div>
</Page.Contents>
</Page>
);
};

View File

@@ -1,6 +1,4 @@
import { DataFrame, DataQuery, PanelData, SelectableValue, TimeRange } from '@grafana/data';
import { PanelQueryRunner } from '../features/query/state/PanelQueryRunner';
import { QueryGroupOptions } from './query';
import { ExpressionQuery } from '../features/expressions/types';
export interface AlertRuleDTO {
@@ -140,11 +138,9 @@ export interface AlertNotification {
export interface AlertDefinitionState {
uiState: AlertDefinitionUiState;
alertDefinition: AlertDefinition;
queryRunner?: PanelQueryRunner;
data: PanelData[];
alertDefinitions: AlertDefinition[];
getInstances: () => DataFrame[];
getQueryOptions: () => QueryGroupOptions;
}
export interface AlertDefinition {

View File

@@ -1,5 +1,7 @@
// Prometheus API DTOs, possibly to be autogenerated from openapi spec in the near future
import { DataQuery, RelativeTimeRange } from '@grafana/data';
export type Labels = Record<string, string>;
export type Annotations = Record<string, string>;
@@ -91,21 +93,14 @@ export enum GrafanaAlertState {
OK = 'OK',
}
export interface GrafanaQueryModel {
export interface GrafanaQueryModel extends DataQuery {
datasource: string;
datasourceUid: string;
refId: string;
[key: string]: any;
}
export interface GrafanaQuery {
refId: string;
queryType: string;
relativeTimeRange: {
from: number;
to: number;
};
relativeTimeRange: RelativeTimeRange;
model: GrafanaQueryModel;
}