mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 </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};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
143
public/app/features/alerting/components/AlertingQueryRows.tsx
Normal file
143
public/app/features/alerting/components/AlertingQueryRows.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || {},
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SAMPLE_QUERIES = [
|
||||
type: 'and',
|
||||
},
|
||||
query: {
|
||||
Params: ['A'],
|
||||
params: ['A'],
|
||||
},
|
||||
reducer: {
|
||||
type: 'last',
|
||||
|
||||
@@ -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,
|
||||
|
||||
7
public/app/features/expressions/guards.ts
Normal file
7
public/app/features/expressions/guards.ts
Normal 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;
|
||||
};
|
||||
@@ -49,7 +49,6 @@ export interface ExpressionQuery extends DataQuery {
|
||||
upsampler?: string;
|
||||
conditions?: ClassicCondition[];
|
||||
}
|
||||
|
||||
export interface ClassicCondition {
|
||||
evaluator: {
|
||||
params: number[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user