Alerting: moving data source uid to query instead of model (#33416)

* initial commit.

* Some more improvements to the expression data source support.

* added tests to verify that time range picker and data source picker only is visible when callbacks is passed to row.

* fixing issue with filter in alerting list.

* minor refactoring.

* removed guarding code, should be fixed in  backend.

* cleaning the data query if we change to a different data source.
This commit is contained in:
Marcus Andersson 2021-04-29 15:10:14 +02:00 committed by GitHub
parent 28ec96788c
commit 9de2f1bb8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 424 additions and 224 deletions

View File

@ -1,4 +1,7 @@
export const Components = {
TimePicker: {
openButton: 'TimePicker Open Button',
},
DataSource: {
TestData: {
QueryTab: {

View File

@ -9,6 +9,7 @@ import { getFocusStyle } from '../Forms/commonStyles';
import { TimePickerButtonLabel } from './TimeRangePicker';
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
import { otherOptions, quickOptions } from './rangeOptions';
import { selectors } from '@grafana/e2e-selectors';
const isValidTimeRange = (range: any) => {
return dateMath.isValid(range.from) && dateMath.isValid(range.to);
@ -66,7 +67,12 @@ export const TimeRangeInput: FC<TimeRangeInputProps> = ({
return (
<div className={styles.container}>
<div tabIndex={0} className={styles.pickerInput} aria-label="TimePicker Open Button" onClick={onOpen}>
<div
tabIndex={0}
className={styles.pickerInput}
aria-label={selectors.components.TimePicker.openButton}
onClick={onOpen}
>
{isValidTimeRange(value) ? (
<TimePickerButtonLabel value={value as TimeRange} timeZone={timeZone} />
) : (

View File

@ -5,15 +5,11 @@ 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 { dataSource as expressionDatasource, 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 { GrafanaQuery } from 'app/types/unified-alerting-dto';
interface Props {
value?: GrafanaQuery[];
@ -53,27 +49,29 @@ export class AlertingQueryEditor extends PureComponent<Props, State> {
return;
}
const alertingQuery: GrafanaQueryModel = {
refId: '',
datasourceUid: defaultDataSource.uid,
datasource: defaultDataSource.name,
};
onChange(addQuery(value, alertingQuery));
onChange(
addQuery(value, {
datasourceUid: defaultDataSource.uid,
model: {
refId: '',
datasource: defaultDataSource.name,
},
})
);
};
onNewExpressionQuery = () => {
const { onChange, value = [] } = this.props;
const expressionQuery: GrafanaQueryModel = {
...expressionDatasource.newQuery({
type: ExpressionQueryType.classic,
conditions: [defaultCondition],
}),
datasourceUid: ExpressionDatasourceUID,
datasource: ExpressionDatasourceID,
};
onChange(addQuery(value, expressionQuery));
onChange(
addQuery(value, {
datasourceUid: ExpressionDatasourceUID,
model: expressionDatasource.newQuery({
type: ExpressionQueryType.classic,
conditions: [defaultCondition],
}),
})
);
};
renderAddQueryRow(styles: ReturnType<typeof getStyles>) {
@ -123,14 +121,18 @@ export class AlertingQueryEditor extends PureComponent<Props, State> {
}
}
const addQuery = (queries: GrafanaQuery[], model: GrafanaQueryModel): GrafanaQuery[] => {
const addQuery = (
queries: GrafanaQuery[],
queryToAdd: Pick<GrafanaQuery, 'model' | 'datasourceUid'>
): GrafanaQuery[] => {
const refId = getNextRefIdChar(queries);
const query: GrafanaQuery = {
...queryToAdd,
refId,
queryType: '',
model: {
...model,
...queryToAdd.model,
hide: false,
refId: refId,
},

View File

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { DataQuery, DataSourceApi, DataSourceInstanceSettings, rangeUtil, PanelData, TimeRange } from '@grafana/data';
import { DataQuery, 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';
@ -18,18 +18,12 @@ interface Props {
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 });
this.state = { dataPerQuery: {} };
}
onRemoveQuery = (query: DataQuery) => {
@ -40,10 +34,42 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
if (itemIndex === index) {
return { ...item, relativeTimeRange: rangeUtil.timeRangeToRelative(timeRange) };
if (itemIndex !== index) {
return item;
}
return item;
return {
...item,
relativeTimeRange: rangeUtil.timeRangeToRelative(timeRange),
};
})
);
}
onChangeDataSource(settings: DataSourceInstanceSettings, index: number) {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
if (itemIndex !== index) {
return item;
}
const previous = getDataSourceSrv().getInstanceSettings(item.datasourceUid);
if (previous?.type === settings.uid) {
return {
...item,
datasourceUid: settings.uid,
};
}
const { refId, hide } = item.model;
return {
...item,
datasourceUid: settings.uid,
model: { refId, hide },
};
})
);
}
@ -52,10 +78,17 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
if (itemIndex === index) {
return { ...item, model: { ...item.model, ...query, datasource: query.datasource! } };
if (itemIndex !== index) {
return item;
}
return item;
return {
...item,
model: {
...item.model,
...query,
datasource: query.datasource!,
},
};
})
);
}
@ -79,14 +112,8 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
onQueriesChange(update);
};
getDataSourceSettings = (query: DataQuery): DataSourceInstanceSettings | undefined => {
const { defaultDataSource } = this.state;
if (isExpressionQuery(query)) {
return getDataSourceSrv().getInstanceSettings(defaultDataSource.name);
}
return getDataSourceSrv().getInstanceSettings(query.datasource);
getDataSourceSettings = (query: GrafanaQuery): DataSourceInstanceSettings | undefined => {
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
};
render() {
@ -108,7 +135,12 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
return (
<QueryEditorRow
dsSettings={{ ...dsSettings, meta: { ...dsSettings.meta, mixed: true } }}
dataSource={dsSettings}
onChangeDataSource={
!isExpressionQuery(query.model)
? (settings) => this.onChangeDataSource(settings, index)
: undefined
}
id={query.refId}
index={index}
key={query.refId}

View File

@ -3,14 +3,15 @@ import React, { FC, useMemo } from 'react';
import { useStyles } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { isAlertingRule } from '../../utils/rules';
import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
import { Annotation } from '../Annotation';
import { AlertLabels } from '../AlertLabels';
import { AlertInstancesTable } from './AlertInstancesTable';
import { DetailsField } from '../DetailsField';
import { RuleQuery } from './RuleQuery';
import { getDataSourceSrv } from '@grafana/runtime';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
interface Props {
rule: CombinedRule;
@ -27,17 +28,23 @@ export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => {
const dataSources: Array<{ name: string; icon?: string }> = useMemo(() => {
if (isCloudRulesSource(rulesSource)) {
return [{ name: rulesSource.name, icon: rulesSource.meta.info.logos.small }];
} else if (rule.queries) {
return rule.queries
.map(({ datasource }) => {
const ds = getDataSourceSrv().getInstanceSettings(datasource);
if (ds) {
return { name: ds.name, icon: ds.meta.info.logos.small };
}
return { name: datasource };
})
.filter(({ name }) => name !== '__expr__');
}
if (isGrafanaRulerRule(rule.rulerRule)) {
const { data } = rule.rulerRule.grafana_alert;
return data.reduce((dataSources, query) => {
const ds = getDatasourceSrv().getInstanceSettings(query.datasourceUid);
if (!ds || ds.uid === ExpressionDatasourceUID) {
return dataSources;
}
dataSources.push({ name: ds.name, icon: ds.meta.info.logos.small });
return dataSources;
}, [] as Array<{ name: string; icon?: string }>);
}
return [];
}, [rule, rulesSource]);

View File

@ -120,7 +120,6 @@ function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, gr
return {
name: rule.name,
query: rule.query,
queries: rule.query && isGrafanaRulesSource(namespace.rulesSource) ? JSON.parse(rule.query) : undefined,
labels: rule.labels || {},
annotations: isAlertingRule(rule) ? rule.annotations || {} : {},
promRule: rule,
@ -156,7 +155,6 @@ function rulerRuleToCombinedRule(
}
: {
name: rule.grafana_alert.title,
queries: (rule.grafana_alert.data ?? []).map((d) => d.model),
query: '',
labels: rule.labels || {},
annotations: rule.annotations || {},

View File

@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleFilterState } from 'app/types/unified-alerting';
import { isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
import { isAlertingRule } from '../utils/rules';
import { isCloudRulesSource } from '../utils/datasource';
import { isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
import { getFiltersFromUrlParams } from '../utils/misc';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { getDataSourceSrv } from '@grafana/runtime';
export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
const [queryParams] = useQueryParams();
@ -45,11 +47,7 @@ const reduceNamespaces = (filters: RuleFilterState) => {
const reduceGroups = (filters: RuleFilterState) => {
return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => {
const rules = group.rules.filter((rule) => {
if (
filters.dataSource &&
isGrafanaRulesSource(rule.namespace.rulesSource) &&
!rule.queries?.find(({ datasource }) => datasource === filters.dataSource)
) {
if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) {
return false;
}
// Query strings can match alert name, label keys, and label values
@ -84,3 +82,17 @@ const reduceGroups = (filters: RuleFilterState) => {
return groupAcc;
};
};
const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: RuleFilterState): boolean => {
if (!filter.dataSource) {
return true;
}
return !!rulerRule.grafana_alert.data.find((query) => {
if (!query.datasourceUid) {
return false;
}
const ds = getDataSourceSrv().getInstanceSettings(query.datasourceUid);
return ds?.name === filter.dataSource;
});
};

View File

@ -6,9 +6,8 @@ export const SAMPLE_QUERIES = [
from: 30,
to: 0,
},
datasourceUid: '000000004',
model: {
datasource: 'gdev-testdata',
datasourceUid: '000000004',
intervalMs: 1000,
maxDataPoints: 100,
pulseWave: {
@ -30,6 +29,7 @@ export const SAMPLE_QUERIES = [
from: 0,
to: 0,
},
datasourceUid: '-100',
model: {
conditions: [
{
@ -48,8 +48,6 @@ export const SAMPLE_QUERIES = [
},
},
],
datasource: '__expr__',
datasourceUid: '-100',
intervalMs: 1000,
maxDataPoints: 100,
refId: 'B',

View File

@ -86,7 +86,6 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
...defaultFormValues,
name: ga.title,
type: RuleFormType.threshold,
dataSourceName: ga.data[0]?.model.datasource,
evaluateFor: rule.for,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
noDataState: ga.no_data_state,

View File

@ -37,8 +37,8 @@ export function isRecordingRulerRule(rule: RulerRuleDTO): rule is RulerRecording
return 'record' in rule;
}
export function isGrafanaRulerRule(rule: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
return 'grafana_alert' in rule;
export function isGrafanaRulerRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
return typeof rule === 'object' && 'grafana_alert' in rule;
}
export function alertInstanceKey(alert: Alert): string {

View File

@ -1,4 +1,4 @@
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
import { DataSourceInstanceSettings, DataSourcePluginMeta, PluginType } from '@grafana/data';
import { ExpressionQuery, ExpressionQueryType } from './types';
import { ExpressionQueryEditor } from './ExpressionQueryEditor';
import { DataSourceWithBackend } from '@grafana/runtime';
@ -29,11 +29,37 @@ export class ExpressionDatasourceApi extends DataSourceWithBackend<ExpressionQue
export const ExpressionDatasourceID = '__expr__';
export const ExpressionDatasourceUID = '-100';
export const expressionDatasource = new ExpressionDatasourceApi({
export const instanceSettings: DataSourceInstanceSettings = {
id: -100,
uid: ExpressionDatasourceUID,
name: ExpressionDatasourceID,
} as DataSourceInstanceSettings);
expressionDatasource.meta = {
type: 'grafana-expression',
meta: {
baseUrl: '',
module: '',
type: PluginType.datasource,
name: ExpressionDatasourceID,
id: ExpressionDatasourceID,
info: {
author: {
name: 'Grafana Labs',
},
logos: {
small: 'public/img/icn-datasource.svg',
large: 'public/img/icn-datasource.svg',
},
description: 'Adds expression support to Grafana',
screenshots: [],
links: [],
updated: '',
version: '',
},
},
jsonData: {},
};
export const dataSource = new ExpressionDatasourceApi(instanceSettings);
dataSource.meta = {
id: ExpressionDatasourceID,
info: {
logos: {
@ -42,6 +68,6 @@ expressionDatasource.meta = {
},
},
} as DataSourcePluginMeta;
expressionDatasource.components = {
dataSource.components = {
QueryEditor: ExpressionQueryEditor,
};

View File

@ -13,7 +13,12 @@ import { AppEvents, DataSourceApi, DataSourceInstanceSettings, DataSourceSelectI
import { auto } from 'angular';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
// Pretend Datasource
import { expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import {
dataSource as expressionDatasource,
ExpressionDatasourceID,
ExpressionDatasourceUID,
instanceSettings as expressionInstanceSettings,
} from 'app/features/expressions/ExpressionDatasource';
import { DataSourceVariableModel } from '../variables/types';
import { cloneDeep } from 'lodash';
@ -52,6 +57,10 @@ export class DatasourceSrv implements DataSourceService {
return this.settingsMapByName[this.defaultName];
}
if (nameOrUid === ExpressionDatasourceID || nameOrUid === ExpressionDatasourceUID) {
return expressionInstanceSettings;
}
// Complex logic to support template variable data source names
// For this we just pick the current or first data source in the variable
if (nameOrUid[0] === '$') {
@ -112,7 +121,7 @@ export class DatasourceSrv implements DataSourceService {
async loadDatasource(name: string): Promise<DataSourceApi<any, any>> {
// Expression Datasource (not a real datasource)
if (name === expressionDatasource.name) {
if (name === ExpressionDatasourceID || name === ExpressionDatasourceUID) {
this.datasources[name] = expressionDatasource as any;
return Promise.resolve(expressionDatasource);
}

View File

@ -19,7 +19,7 @@ import {
TimeRange,
toLegacyResponseData,
} from '@grafana/data';
import { QueryEditorRowTitle } from './QueryEditorRowTitle';
import { QueryEditorRowHeader } from './QueryEditorRowHeader';
import {
QueryOperationRow,
QueryOperationRowRenderProps,
@ -33,10 +33,11 @@ interface Props {
data: PanelData;
query: DataQuery;
queries: DataQuery[];
dsSettings: DataSourceInstanceSettings;
id: string;
index: number;
timeRange?: TimeRange;
dataSource: DataSourceInstanceSettings;
onChangeDataSource?: (dsSettings: DataSourceInstanceSettings) => void;
onChangeTimeRange?: (timeRange: TimeRange) => void;
onAddQuery: (query: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
@ -111,7 +112,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
}
getQueryDataSourceIdentifier(): string | null | undefined {
const { query, dsSettings } = this.props;
const { query, dataSource: dsSettings } = this.props;
return query.datasource ?? dsSettings.name;
}
@ -302,20 +303,18 @@ export class QueryEditorRow extends PureComponent<Props, State> {
);
};
renderTitle = (props: QueryOperationRowRenderProps) => {
const { query, dsSettings, onChange, queries, onChangeTimeRange, timeRange } = this.props;
const { datasource } = this.state;
const isDisabled = query.hide;
renderHeader = (props: QueryOperationRowRenderProps) => {
const { query, dataSource, onChangeDataSource, onChange, queries, onChangeTimeRange, timeRange } = this.props;
return (
<QueryEditorRowTitle
<QueryEditorRowHeader
query={query}
queries={queries}
onTimeRangeChange={onChangeTimeRange}
onChangeTimeRange={onChangeTimeRange}
timeRange={timeRange}
inMixedMode={dsSettings.meta.mixed}
dataSourceName={datasource!.name}
disabled={isDisabled}
onChangeDataSource={onChangeDataSource}
dataSource={dataSource}
disabled={query.hide}
onClick={(e) => this.onToggleEditMode(e, props)}
onChange={onChange}
collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
@ -346,7 +345,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
id={id}
draggable={true}
index={index}
headerElement={this.renderTitle}
headerElement={this.renderHeader}
actions={this.renderActions}
onOpen={this.onOpen}
>

View File

@ -0,0 +1,124 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Props, QueryEditorRowHeader } from './QueryEditorRowHeader';
import { DataSourceInstanceSettings, dateTime } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
return {
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn(),
getList: jest.fn().mockReturnValue([]),
}),
};
});
describe('QueryEditorRowHeader', () => {
it('Can edit title', () => {
const scenario = renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: 'new name' } });
fireEvent.blur(input);
expect((scenario.props.onChange as any).mock.calls[0][0].refId).toBe('new name');
});
it('Show error when other query with same name exists', async () => {
renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: 'B' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('Query name already exists');
});
it('Show error when empty name is specified', async () => {
renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: '' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('An empty query name is not allowed');
});
it('should show data source picker when callback is passed', async () => {
renderScenario({ onChangeDataSource: () => {} });
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).not.toBeNull();
});
it('should not show data source picker when no callback is passed', async () => {
renderScenario({ onChangeDataSource: undefined });
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
});
it('should show time range picker when callback and value is passed', async () => {
renderScenario({
onChangeTimeRange: () => {},
timeRange: {
from: dateTime(),
to: dateTime(),
raw: { from: 'now', to: 'now' },
},
});
expect(screen.queryByLabelText(selectors.components.TimePicker.openButton)).not.toBeNull();
});
it('should not show time range picker when no value is passed', async () => {
renderScenario({
onChangeTimeRange: () => {},
timeRange: undefined,
});
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
});
it('should not show time range picker when no callback is passed', async () => {
renderScenario({
onChangeTimeRange: undefined,
timeRange: {
from: dateTime(),
to: dateTime(),
raw: { from: 'now', to: 'now' },
},
});
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
});
});
function renderScenario(overrides: Partial<Props>) {
const props: Props = {
query: {
refId: 'A',
},
queries: [
{
refId: 'A',
},
{
refId: 'B',
},
],
dataSource: {} as DataSourceInstanceSettings,
disabled: false,
onChange: jest.fn(),
onClick: jest.fn(),
collapsedText: '',
};
Object.assign(props, overrides);
return {
props,
renderResult: render(<QueryEditorRowHeader {...props} />),
};
}

View File

@ -2,37 +2,27 @@ import React, { useState } from 'react';
import { css, cx } from '@emotion/css';
import { DataQuery, DataSourceInstanceSettings, GrafanaTheme, TimeRange } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Icon, Input, stylesFactory, useTheme, FieldValidationMessage, TimeRangeInput } from '@grafana/ui';
import { Icon, Input, FieldValidationMessage, TimeRangeInput, useStyles } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { ExpressionDatasourceID } from '../../expressions/ExpressionDatasource';
import { ExpressionDatasourceUID } from '../../expressions/ExpressionDatasource';
export interface Props {
query: DataQuery;
queries: DataQuery[];
dataSourceName: string;
inMixedMode?: boolean;
disabled?: boolean;
timeRange?: TimeRange;
onTimeRangeChange?: (timeRange: TimeRange) => void;
dataSource: DataSourceInstanceSettings;
onChangeDataSource?: (settings: DataSourceInstanceSettings) => void;
onChangeTimeRange?: (timeRange: TimeRange) => void;
onChange: (query: DataQuery) => void;
onClick: (e: React.MouseEvent) => void;
collapsedText: string | null;
}
export const QueryEditorRowTitle: React.FC<Props> = ({
dataSourceName,
inMixedMode,
disabled,
query,
queries,
onClick,
onChange,
onTimeRangeChange,
timeRange,
collapsedText,
}) => {
const theme = useTheme();
const styles = getQueryEditorRowTitleStyles(theme);
export const QueryEditorRowHeader: React.FC<Props> = (props) => {
const { dataSource, onChangeDataSource, disabled, query, queries, onClick, onChange, collapsedText } = props;
const styles = useStyles(getStyles);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [validationError, setValidationError] = useState<string | null>(null);
@ -91,10 +81,6 @@ export const QueryEditorRowTitle: React.FC<Props> = ({
event.target.select();
};
const onDataSourceChange = (dataSource: DataSourceInstanceSettings) => {
onChange({ ...query, datasource: dataSource.name });
};
return (
<div className={styles.wrapper}>
{!isEditing && (
@ -126,17 +112,8 @@ export const QueryEditorRowTitle: React.FC<Props> = ({
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
{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>}
<PickerRenderer {...props} />
{dataSource && !onChangeDataSource && <em className={styles.contextInfo}> ({dataSource.name})</em>}
{disabled && <em className={styles.contextInfo}> Disabled</em>}
{collapsedText && (
@ -148,7 +125,27 @@ export const QueryEditorRowTitle: React.FC<Props> = ({
);
};
const getQueryEditorRowTitleStyles = stylesFactory((theme: GrafanaTheme) => {
const PickerRenderer: React.FC<Props> = (props) => {
const { onChangeTimeRange, timeRange, onChangeDataSource, dataSource } = props;
const styles = useStyles(getStyles);
if (!onChangeTimeRange && !onChangeDataSource) {
return null;
}
if (dataSource.uid === ExpressionDatasourceUID) {
return null;
}
return (
<div className={styles.pickerWrapper}>
{onChangeDataSource && <DataSourcePicker current={dataSource.name} onChange={onChangeDataSource} />}
{onChangeTimeRange && timeRange && <TimeRangeInput onChange={onChangeTimeRange} value={timeRange} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
display: flex;
@ -225,5 +222,9 @@ const getQueryEditorRowTitleStyles = stylesFactory((theme: GrafanaTheme) => {
color: ${theme.colors.textWeak};
padding-left: 10px;
`,
pickerWrapper: css`
display: flex;
margin-left: 8px;
`,
};
});
};

View File

@ -1,66 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Props, QueryEditorRowTitle } from './QueryEditorRowTitle';
function renderScenario(overrides: Partial<Props>) {
const props: Props = {
query: {
refId: 'A',
},
queries: [
{
refId: 'A',
},
{
refId: 'B',
},
],
dataSourceName: 'hello',
disabled: false,
onChange: jest.fn(),
onClick: jest.fn(),
collapsedText: '',
};
Object.assign(props, overrides);
return {
props,
renderResult: render(<QueryEditorRowTitle {...props} />),
};
}
describe('QueryEditorRowTitle', () => {
it('Can edit title', () => {
const scenario = renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: 'new name' } });
fireEvent.blur(input);
expect((scenario.props.onChange as any).mock.calls[0][0].refId).toBe('new name');
});
it('Show error when other query with same name exists', async () => {
renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: 'B' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('Query name already exists');
});
it('Show error when empty name is specified', async () => {
renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: '' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('An empty query name is not allowed');
});
});

View File

@ -5,6 +5,7 @@ import React, { PureComponent } from 'react';
import { DataQuery, DataSourceInstanceSettings, PanelData } from '@grafana/data';
import { QueryEditorRow } from './QueryEditorRow';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { getDataSourceSrv } from '@grafana/runtime';
interface Props {
// The query configuration
@ -39,6 +40,35 @@ export class QueryEditorRows extends PureComponent<Props> {
);
}
onDataSourceChange(dataSource: DataSourceInstanceSettings, index: number) {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
if (itemIndex !== index) {
return item;
}
if (item.datasource) {
const previous = getDataSourceSrv().getInstanceSettings(item.datasource);
if (previous?.type === dataSource.type) {
return {
...item,
datasource: dataSource.name,
};
}
}
return {
refId: item.refId,
hide: item.hide,
datasource: dataSource.name,
};
})
);
}
onDragEnd = (result: DropResult) => {
const { queries, onQueriesChange } = this.props;
@ -67,21 +97,29 @@ export class QueryEditorRows extends PureComponent<Props> {
{(provided) => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
{queries.map((query, index) => (
<QueryEditorRow
dsSettings={dsSettings}
id={query.refId}
index={index}
key={query.refId}
data={data}
query={query}
onChange={(query) => this.onChangeQuery(query, index)}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.props.onAddQuery}
onRunQuery={this.props.onRunQueries}
queries={queries}
/>
))}
{queries.map((query, index) => {
const dataSourceSettings = getDataSourceSettings(query, dsSettings);
const onChangeDataSourceSettings = dsSettings.meta.mixed
? (settings: DataSourceInstanceSettings) => this.onDataSourceChange(settings, index)
: undefined;
return (
<QueryEditorRow
id={query.refId}
index={index}
key={query.refId}
data={data}
query={query}
dataSource={dataSourceSettings}
onChangeDataSource={onChangeDataSourceSettings}
onChange={(query) => this.onChangeQuery(query, index)}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.props.onAddQuery}
onRunQuery={this.props.onRunQueries}
queries={queries}
/>
);
})}
{provided.placeholder}
</div>
);
@ -91,3 +129,14 @@ export class QueryEditorRows extends PureComponent<Props> {
);
}
}
const getDataSourceSettings = (
query: DataQuery,
groupSettings: DataSourceInstanceSettings
): DataSourceInstanceSettings => {
if (!query.datasource) {
return groupSettings;
}
const querySettings = getDataSourceSrv().getInstanceSettings(query.datasource);
return querySettings || groupSettings;
};

View File

@ -19,7 +19,10 @@ import {
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { addQuery } from 'app/core/utils/query';
import { Unsubscribable } from 'rxjs';
import { expressionDatasource, ExpressionDatasourceID } from 'app/features/expressions/ExpressionDatasource';
import {
dataSource as expressionDatasource,
ExpressionDatasourceID,
} from 'app/features/expressions/ExpressionDatasource';
import { selectors } from '@grafana/e2e-selectors';
import { PanelQueryRunner } from '../state/PanelQueryRunner';
import { QueryGroupOptionsEditor } from './QueryGroupOptions';

View File

@ -22,7 +22,11 @@ import {
} from '@grafana/data';
import { toDataQueryError } from '@grafana/runtime';
import { emitDataRequestEvent } from './queryAnalytics';
import { expressionDatasource, ExpressionDatasourceID } from 'app/features/expressions/ExpressionDatasource';
import {
dataSource as expressionDatasource,
ExpressionDatasourceID,
ExpressionDatasourceUID,
} from 'app/features/expressions/ExpressionDatasource';
import { ExpressionQuery } from 'app/features/expressions/types';
type MapOfResponsePackets = { [str: string]: DataQueryResponse };
@ -175,7 +179,7 @@ export function callQueryMethod(
) {
// If any query has an expression, use the expression endpoint
for (const target of request.targets) {
if (target.datasource === ExpressionDatasourceID) {
if (target.datasource === ExpressionDatasourceID || target.datasource === ExpressionDatasourceUID) {
return expressionDatasource.query(request as DataQueryRequest<ExpressionQuery>);
}
}

View File

@ -92,16 +92,12 @@ export enum GrafanaAlertState {
KeepLastState = 'KeepLastState',
OK = 'OK',
}
export interface GrafanaQueryModel extends DataQuery {
datasource: string;
datasourceUid: string;
}
export interface GrafanaQuery {
refId: string;
queryType: string;
relativeTimeRange: RelativeTimeRange;
model: GrafanaQueryModel;
datasourceUid: string;
model: DataQuery;
}
export interface PostableGrafanaRuleDefinition {

View File

@ -8,7 +8,6 @@ import {
Labels,
Annotations,
RulerRuleGroupDTO,
GrafanaQueryModel,
} from './unified-alerting-dto';
export type Alert = {
@ -82,7 +81,6 @@ export interface CombinedRule {
rulerRule?: RulerRuleDTO;
group: CombinedRuleGroup;
namespace: CombinedRuleNamespace;
queries?: GrafanaQueryModel[];
}
export interface CombinedRuleGroup {