Alerting: Expressions pipeline redesign (#54601)

This commit is contained in:
Gilles De Mey 2022-10-05 14:35:15 +02:00 committed by GitHub
parent 222c33c307
commit 87cba8836f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2126 additions and 816 deletions

View File

@ -4,6 +4,7 @@ const ds1 = {
type: 'prometheus',
name: 'gdev-prometheus',
meta: {
alerting: true,
info: {
logos: {
small: 'http://example.com/logo.png',

View File

@ -6,7 +6,6 @@ import { byTestId } from 'testing-library-selector';
import { DataSourceApi } from '@grafana/data';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { toggleOption } from 'app/features/variables/pickers/OptionsPicker/reducer';
@ -302,58 +301,7 @@ describe('PanelAlertTabContent', () => {
expect(match).toHaveLength(2);
const defaults = JSON.parse(decodeURIComponent(match![1]));
expect(defaults).toEqual({
type: 'grafana',
folder: { id: 1, title: 'super folder' },
queries: [
{
refId: 'A',
queryType: '',
relativeTimeRange: { from: 21600, to: 0 },
datasourceUid: 'mock-ds-2',
model: {
expr: 'sum(some_metric [15s])) by (app)',
refId: 'A',
datasource: {
type: 'prometheus',
uid: 'mock-ds-2',
},
interval: '',
intervalMs: 15000,
},
},
{
refId: 'B',
datasourceUid: '-100',
queryType: '',
model: {
refId: 'B',
hide: false,
expression: 'A',
type: 'classic_conditions',
datasource: {
type: ExpressionDatasourceRef.type,
uid: '-100',
},
conditions: [
{
type: 'query',
evaluator: { params: [3], type: 'gt' },
operator: { type: 'and' },
query: { params: ['A'] },
reducer: { params: [], type: 'last' },
},
],
},
},
],
name: 'mypanel',
condition: 'B',
annotations: [
{ key: '__dashboardUid__', value: '12' },
{ key: '__panelId__', value: '34' },
],
});
expect(defaults).toMatchSnapshot();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },

View File

@ -296,7 +296,7 @@ describe('RuleEditor', () => {
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
grafana_alert: {
condition: 'B',
condition: 'C',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
no_data_state: 'NoData',

View File

@ -0,0 +1,119 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PanelAlertTabContent Will render alerts belonging to panel and a button to create alert from panel queries 1`] = `
Object {
"annotations": Array [
Object {
"key": "__dashboardUid__",
"value": "12",
},
Object {
"key": "__panelId__",
"value": "34",
},
],
"condition": "C",
"folder": Object {
"id": 1,
"title": "super folder",
},
"name": "mypanel",
"queries": Array [
Object {
"datasourceUid": "mock-ds-2",
"model": Object {
"datasource": Object {
"type": "prometheus",
"uid": "mock-ds-2",
},
"expr": "sum(some_metric [15s])) by (app)",
"interval": "",
"intervalMs": 15000,
"refId": "A",
},
"queryType": "",
"refId": "A",
"relativeTimeRange": Object {
"from": 21600,
"to": 0,
},
},
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [
"B",
],
},
"reducer": Object {
"params": Array [],
"type": "last",
},
"type": "query",
},
],
"datasource": Object {
"type": "__expr__",
"uid": "-100",
},
"expression": "A",
"hide": false,
"reducer": "last",
"refId": "B",
"type": "reduce",
},
"queryType": "",
"refId": "B",
},
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [
"C",
],
},
"reducer": Object {
"params": Array [],
"type": "last",
},
"type": "query",
},
],
"datasource": Object {
"type": "__expr__",
"uid": "-100",
},
"expression": "B",
"hide": false,
"refId": "C",
"type": "threshold",
},
"queryType": "",
"refId": "C",
},
],
"type": "grafana",
}
`;

View File

@ -0,0 +1,20 @@
import { css } from '@emotion/css';
import React from 'react';
/**
* A simple "flex: 1;" component you can use in combination with the Stack component(s), like so
*
* <Stack direction="row">
* <span>hello</span>
* <Spacer />
* <span>world</span>
* </Stack>
*/
export const Spacer = () => (
<span
className={css`
flex: 1;
`}
/>
);

View File

@ -8,12 +8,13 @@ export type State = 'good' | 'bad' | 'warning' | 'neutral' | 'info';
type Props = {
state: State;
size?: 'md' | 'sm';
};
export const StateTag: FC<Props> = ({ children, state }) => {
export const StateTag: FC<Props> = ({ children, state, size = 'md' }) => {
const styles = useStyles2(getStyles);
return <span className={cx(styles.common, styles[state])}>{children || state}</span>;
return <span className={cx(styles.common, styles[state], styles[size])}>{children || state}</span>;
};
const getStyles = (theme: GrafanaTheme2) => ({
@ -22,10 +23,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: white;
border-radius: ${theme.shape.borderRadius()};
font-size: ${theme.typography.size.sm};
padding: ${theme.spacing(0.5, 1)};
text-transform: capitalize;
line-height: 1.2;
min-width: ${theme.spacing(8)};
text-align: center;
font-weight: ${theme.typography.fontWeightBold};
`,
@ -54,4 +53,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
border: solid 1px ${theme.colors.primary.main};
color: ${theme.colors.primary.contrastText};
`,
md: css`
padding: ${theme.spacing(0.5, 1)};
min-width: ${theme.spacing(8)};
`,
sm: css`
padding: ${theme.spacing(0.3, 0.5)};
min-width: 52px;
`,
});

View File

@ -0,0 +1,54 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, useStyles2 } from '@grafana/ui';
interface AlertConditionProps {
enabled?: boolean;
error?: Error;
warning?: Error;
onSetCondition: () => void;
}
export const AlertConditionIndicator: FC<AlertConditionProps> = ({
enabled = false,
error,
warning,
onSetCondition,
}) => {
const styles = useStyles2(getStyles);
if (enabled && error) {
return <Badge color="red" icon="exclamation-circle" text="Alert condition" tooltip={error.message} />;
}
if (enabled && warning) {
return <Badge color="orange" icon="exclamation-triangle" text="Alert condition" tooltip={warning.message} />;
}
if (enabled && !error && !warning) {
return <Badge color="green" icon="check" text="Alert condition" />;
}
if (!enabled) {
return (
<div className={styles.actionLink} onClick={() => onSetCondition()}>
Make this the alert condition
</div>
);
}
return null;
};
const getStyles = (theme: GrafanaTheme2) => ({
actionLink: css`
color: ${theme.colors.text.link};
cursor: pointer;
&:hover {
text-decoration: underline;
}
`,
});

View File

@ -0,0 +1,449 @@
import { css, cx } from '@emotion/css';
import { capitalize, uniqueId } from 'lodash';
import React, { FC, useCallback, useState } from 'react';
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { AutoSizeInput, Icon, IconButton, Select, Stack, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
import { Math } from 'app/features/expressions/components/Math';
import { Reduce } from 'app/features/expressions/components/Reduce';
import { Resample } from 'app/features/expressions/components/Resample';
import { Threshold } from 'app/features/expressions/components/Threshold';
import { ExpressionQuery, ExpressionQueryType, gelTypes } from 'app/features/expressions/types';
import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { HoverCard } from '../HoverCard';
import { Spacer } from '../Spacer';
import { AlertStateTag } from '../rules/AlertStateTag';
import { AlertConditionIndicator } from './AlertConditionIndicator';
import { formatLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util';
interface ExpressionProps {
isAlertCondition?: boolean;
data?: PanelData;
error?: Error;
warning?: Error;
queries: AlertQuery[];
query: ExpressionQuery;
onSetCondition: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onRemoveExpression: (refId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onChangeQuery: (query: ExpressionQuery) => void;
}
export const Expression: FC<ExpressionProps> = ({
queries = [],
query,
data,
error,
warning,
isAlertCondition,
onSetCondition,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType,
onChangeQuery,
}) => {
const styles = useStyles2(getStyles);
const queryType = query?.type;
const isLoading = data && Object.values(data).some((d) => Boolean(d) && d.state === LoadingState.Loading);
const hasResults = Array.isArray(data?.series) && !isLoading;
const series = data?.series ?? [];
// sometime we receive results where every value is just "null" when noData occurs
const emptyResults = hasResults && isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeries(series);
const alertCondition = isAlertCondition ?? false;
const showSummary = isAlertCondition && hasResults;
const groupedByState = {
[PromAlertingRuleState.Firing]: series.filter((serie) => getSeriesValue(serie) >= 1),
[PromAlertingRuleState.Inactive]: series.filter((serie) => getSeriesValue(serie) < 1),
};
const renderExpressionType = useCallback(
(query: ExpressionQuery) => {
// these are the refs we can choose from that don't include the current one
const availableRefIds = queries
.filter((q) => query.refId !== q.refId)
.map((q) => ({ value: q.refId, label: q.refId }));
switch (query.type) {
case ExpressionQueryType.math:
return <Math onChange={onChangeQuery} query={query} labelWidth={'auto'} onRunQuery={() => {}} />;
case ExpressionQueryType.reduce:
return <Reduce onChange={onChangeQuery} refIds={availableRefIds} labelWidth={'auto'} query={query} />;
case ExpressionQueryType.resample:
return <Resample onChange={onChangeQuery} query={query} labelWidth={'auto'} refIds={availableRefIds} />;
case ExpressionQueryType.classic:
return <ClassicConditions onChange={onChangeQuery} query={query} refIds={availableRefIds} />;
case ExpressionQueryType.threshold:
return <Threshold onChange={onChangeQuery} query={query} labelWidth={'auto'} refIds={availableRefIds} />;
default:
return <>Expression not supported: {query.type}</>;
}
},
[onChangeQuery, queries]
);
return (
<div className={cx(styles.expression.wrapper, alertCondition && styles.expression.alertCondition)}>
<div className={styles.expression.stack}>
<Header
refId={query.refId}
queryType={queryType}
onRemoveExpression={() => onRemoveExpression(query.refId)}
onUpdateRefId={(newRefId) => onUpdateRefId(query.refId, newRefId)}
onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)}
/>
<div className={styles.expression.body}>{renderExpressionType(query)}</div>
{hasResults && (
<div className={styles.expression.results}>
{!emptyResults && isTimeSeriesResults && (
<div>
{series.map((frame, index) => (
<TimeseriesRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
))}
</div>
)}
{!emptyResults &&
!isTimeSeriesResults &&
series.map((frame, index) => (
// There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles)
<FrameRow key={uniqueId()} frame={frame} index={index} isAlertCondition={alertCondition} />
))}
{emptyResults && <div className={cx(styles.expression.noData, styles.mutedText)}>No data</div>}
</div>
)}
<div className={styles.footer}>
<Stack direction="row" alignItems="center">
<AlertConditionIndicator
onSetCondition={() => onSetCondition(query.refId)}
enabled={alertCondition}
error={error}
warning={warning}
/>
<Spacer />
{showSummary && (
<PreviewSummary
firing={groupedByState[PromAlertingRuleState.Firing].length}
normal={groupedByState[PromAlertingRuleState.Inactive].length}
/>
)}
</Stack>
</div>
</div>
</div>
);
};
const PreviewSummary: FC<{ firing: number; normal: number }> = ({ firing, normal }) => {
const { mutedText } = useStyles2(getStyles);
return <span className={mutedText}>{`${firing} firing, ${normal} normal`}</span>;
};
interface HeaderProps {
refId: string;
queryType: ExpressionQueryType;
onUpdateRefId: (refId: string) => void;
onRemoveExpression: () => void;
onUpdateExpressionType: (type: ExpressionQueryType) => void;
}
const Header: FC<HeaderProps> = ({ refId, queryType, onUpdateRefId, onUpdateExpressionType, onRemoveExpression }) => {
const styles = useStyles2(getStyles);
/**
* There are 3 edit modes:
*
* 1. "refId": Editing the refId (ie. A -> B)
* 2. "epressionType": Editing the type of the expression (ie. Reduce -> Math)
* 3. "false": This means we're not editing either of those
*/
const [editMode, setEditMode] = useState<'refId' | 'expressionType' | false>(false);
const editing = editMode !== false;
const editingRefId = editing && editMode === 'refId';
const editingType = editing && editMode === 'expressionType';
const selectedExpressionType = gelTypes.find((o) => o.value === queryType);
return (
<header className={styles.header.wrapper}>
<Stack direction="row" gap={0.5} alignItems="center">
<Stack direction="row" gap={1} alignItems="center" wrap={false}>
{!editingRefId && (
<div className={styles.editable} onClick={() => setEditMode('refId')}>
<div className={styles.expression.refId}>{refId}</div>
</div>
)}
{editingRefId && (
<AutoSizeInput
autoFocus
defaultValue={refId}
minWidth={5}
onChange={(event) => {
onUpdateRefId(event.currentTarget.value);
setEditMode(false);
}}
onFocus={(event) => event.target.select()}
onBlur={(event) => {
onUpdateRefId(event.currentTarget.value);
setEditMode(false);
}}
/>
)}
{!editingType && (
<div className={styles.editable} onClick={() => setEditMode('expressionType')}>
<div className={styles.mutedText}>{capitalize(queryType)}</div>
<Icon size="xs" name="pen" className={styles.mutedIcon} onClick={() => setEditMode('expressionType')} />
</div>
)}
{editingType && (
<Select
isOpen
autoFocus
onChange={(selection) => {
onUpdateExpressionType(selection.value ?? ExpressionQueryType.classic);
setEditMode(false);
}}
onBlur={() => {
setEditMode(false);
}}
options={gelTypes}
value={selectedExpressionType}
width={25}
/>
)}
</Stack>
<Spacer />
<IconButton
type="button"
name="trash-alt"
variant="secondary"
className={styles.mutedIcon}
onClick={onRemoveExpression}
/>
</Stack>
</header>
);
};
interface FrameProps extends Pick<ExpressionProps, 'isAlertCondition'> {
frame: DataFrame;
index: number;
}
const FrameRow: FC<FrameProps> = ({ frame, index, isAlertCondition }) => {
const styles = useStyles2(getStyles);
const name = getSeriesName(frame) || 'Series ' + index;
const value = getSeriesValue(frame);
const showFiring = isAlertCondition && value !== 0;
const showNormal = isAlertCondition && value === 0;
return (
<div className={styles.expression.resultsRow}>
<Stack direction="row" gap={1} alignItems="center">
<span className={cx(styles.mutedText, styles.expression.resultLabel)} title={name}>
{name}
</span>
<div className={styles.expression.resultValue}>{value}</div>
{showFiring && <AlertStateTag state={PromAlertingRuleState.Firing} size="sm" />}
{showNormal && <AlertStateTag state={PromAlertingRuleState.Inactive} size="sm" />}
</Stack>
</div>
);
};
const TimeseriesRow: FC<FrameProps & { index: number }> = ({ frame, index }) => {
const styles = useStyles2(getStyles);
const hasLabels = frame.fields[1].labels;
const name = hasLabels ? formatLabels(frame.fields[1].labels ?? {}) : 'Series ' + index;
const timestamps = frame.fields[0].values.toArray();
const getTimestampFromIndex = (index: number) => frame.fields[0].values.get(index);
const getValueFromIndex = (index: number) => frame.fields[1].values.get(index);
return (
<div className={styles.expression.resultsRow}>
<Stack direction="row" gap={1} alignItems="center">
<span className={cx(styles.mutedText, styles.expression.resultLabel)} title={name}>
{name}
</span>
<div className={styles.expression.resultValue}>
<HoverCard
placement="right"
wrapperClassName={styles.timeseriesTableWrapper}
content={
<table className={styles.timeseriesTable}>
<thead>
<tr>
<th>Timestamp</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{timestamps.map((_, index) => (
<tr key={index}>
<td className={styles.mutedText}>{dateTimeFormat(getTimestampFromIndex(index))}</td>
<td className={styles.expression.resultValue}>{getValueFromIndex(index)}</td>
</tr>
))}
</tbody>
</table>
}
>
<span>Time series data</span>
</HoverCard>
</div>
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
expression: {
wrapper: css`
display: flex;
border: solid 1px ${theme.colors.border.medium};
border-radius: ${theme.shape.borderRadius()};
max-width: 640px;
`,
stack: css`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0;
min-width: 0; // this one is important to prevent text overflow
`,
alertCondition: css``,
body: css`
padding: ${theme.spacing(1)};
flex: 1;
`,
refId: css`
font-weight: ${theme.typography.fontWeightBold};
color: ${theme.colors.primary.text};
`,
results: css`
border-top: solid 1px ${theme.colors.border.medium};
`,
noResults: css`
display: flex;
align-items: center;
justify-content: center;
`,
resultsRow: css`
padding: ${theme.spacing(0.75)} ${theme.spacing(1)};
&:nth-child(odd) {
background-color: ${theme.colors.background.secondary};
}
&:hover {
background-color: ${theme.colors.background.canvas};
}
`,
resultValue: css`
color: ${theme.colors.text.maxContrast};
text-align: right;
`,
resultLabel: css`
flex: 1;
`,
noData: css`
display: flex;
align-items: center;
justify-content: center;
padding: ${theme.spacing()};
`,
},
mutedText: css`
color: ${theme.colors.text.secondary};
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
header: {
wrapper: css`
background: ${theme.colors.background.secondary};
padding: ${theme.spacing(0.5)} ${theme.spacing(1)};
border-bottom: solid 1px ${theme.colors.border.medium};
`,
},
footer: css`
background: ${theme.colors.background.secondary};
padding: ${theme.spacing(1)};
border-top: solid 1px ${theme.colors.border.medium};
`,
draggableIcon: css`
cursor: grab;
`,
mutedIcon: css`
color: ${theme.colors.text.secondary};
`,
editable: css`
padding: ${theme.spacing(0.5)} ${theme.spacing(1)};
border: solid 1px ${theme.colors.border.weak};
border-radius: ${theme.shape.borderRadius()};
display: flex;
flex-direction: row;
align-items: center;
gap: ${theme.spacing(1)};
cursor: pointer;
`,
timeseriesTableWrapper: css`
max-height: 500px;
max-width: 300px;
overflow-y: scroll;
padding: 0 !important; // not sure why but style override doesn't work otherwise :( (Gilles)
`,
timeseriesTable: css`
table-layout: auto;
width: 100%;
height: 100%;
td,
th {
padding: ${theme.spacing(1)};
}
td {
background: ${theme.colors.background.primary};
}
th {
background: ${theme.colors.background.secondary};
}
tr {
border-bottom: 1px solid ${theme.colors.border.medium};
&:last-of-type {
border-bottom: none;
}
}
`,
});

View File

@ -0,0 +1,31 @@
import { DataFrame, Labels, roundDecimals } from '@grafana/data';
const getSeriesName = (frame: DataFrame): string => {
return frame.name ?? formatLabels(frame.fields[0].labels ?? {});
};
const getSeriesValue = (frame: DataFrame) => {
const value = frame.fields[0].values.get(0);
if (Number.isFinite(value)) {
return roundDecimals(value, 5);
}
return value;
};
const formatLabels = (labels: Labels): string => {
return Object.entries(labels)
.map(([key, value]) => key + '=' + value)
.join(', ');
};
const isEmptySeries = (series: DataFrame[]): boolean => {
const isEmpty = series.every((serie) =>
serie.fields.every((field) => field.values.toArray().every((value) => value == null))
);
return isEmpty;
};
export { getSeriesName, getSeriesValue, formatLabels, isEmptySeries };

View File

@ -25,7 +25,7 @@ import { DetailsStep } from './DetailsStep';
import { GrafanaEvaluationBehavior } from './GrafanaEvaluationBehavior';
import { NotificationsStep } from './NotificationsStep';
import { RuleInspector } from './RuleInspector';
import { QueryAndAlertConditionStep } from './query-and-alert-condition/QueryAndAlertConditionStep';
import { QueryAndExpressionsStep } from './query-and-alert-condition/QueryAndExpressionsStep';
type Props = {
existing?: RuleWithLocation;
@ -48,6 +48,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
return {
...getDefaultFormValues(),
queries: getDefaultQueries(),
condition: 'C',
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
type: RuleFormType.grafana,
};
@ -159,7 +160,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
<div className={styles.contentOuter}>
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<div className={styles.contentInner}>
<QueryAndAlertConditionStep editingExistingRule={!!existing} />
<QueryAndExpressionsStep editingExistingRule={!!existing} />
{showStep2 && (
<>
{type === RuleFormType.grafana ? <GrafanaEvaluationBehavior /> : <CloudEvaluationBehavior />}

View File

@ -1,39 +0,0 @@
import { render, screen } from '@testing-library/react';
import React, { FC } from 'react';
import { FormProvider, useForm, UseFormProps } from 'react-hook-form';
import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
import { RuleFormValues } from '../../types/rule-form';
import { ConditionField } from './ConditionField';
const FormProviderWrapper: FC<UseFormProps> = ({ children, ...props }) => {
const methods = useForm({ ...props });
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe('ConditionField', () => {
it('should render the correct condition when editing existing rule', () => {
const existingRule = {
name: 'ConditionsTest',
condition: 'B',
queries: [
{ refId: 'A' },
{ refId: 'B', datasourceUid: ExpressionDatasourceUID },
{ refId: 'C', datasourceUid: ExpressionDatasourceUID },
],
} as RuleFormValues;
const form = (
<FormProviderWrapper defaultValues={existingRule}>
<ConditionField existing={true} />
</FormProviderWrapper>
);
render(form);
expect(screen.getByLabelText(/^A/)).not.toBeChecked();
expect(screen.getByLabelText(/^B/)).toBeChecked();
expect(screen.getByLabelText(/^C/)).not.toBeChecked();
});
});

View File

@ -1,95 +0,0 @@
import { css } from '@emotion/css';
import { last } from 'lodash';
import React, { FC, useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Alert, Card, Field, InputControl, RadioButtonList, useStyles2 } from '@grafana/ui';
import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
import { RuleFormValues } from '../../types/rule-form';
interface Props {
existing?: boolean;
}
export const ConditionField: FC<Props> = ({ existing = false }) => {
const {
watch,
setValue,
formState: { errors },
} = useFormContext<RuleFormValues>();
const queries = watch('queries');
const condition = watch('condition');
const expressions = useMemo(() => {
return queries.filter((query) => query.datasourceUid === ExpressionDatasourceUID);
}, [queries]);
const options = useMemo(
() =>
queries
.filter((q) => !!q.refId)
.map<SelectableValue<string>>((q) => ({
value: q.refId,
label: `${q.refId} - ${expressions.includes(q) ? 'expression' : 'query'}`,
})),
[queries, expressions]
);
// automatically use the last expression when new expressions have been added
useEffect(() => {
const lastExpression = last(expressions);
if (lastExpression && !existing) {
setValue('condition', lastExpression.refId, { shouldValidate: true });
}
}, [expressions, setValue, existing]);
// reset condition if option no longer exists or if it is unset, but there are options available
useEffect(() => {
const lastExpression = last(expressions);
const conditionExists = options.find(({ value }) => value === condition);
if (condition && !conditionExists) {
setValue('condition', lastExpression?.refId ?? null);
} else if (!condition && lastExpression) {
setValue('condition', lastExpression.refId, { shouldValidate: true });
}
}, [condition, expressions, options, setValue]);
const styles = useStyles2(getStyles);
return options.length ? (
<Card className={styles.container}>
<Card.Heading>Set alert condition</Card.Heading>
<Card.Meta>Select one of your queries or expressions set above that contains your alert condition.</Card.Meta>
<Card.Actions>
<Field error={errors.condition?.message} invalid={!!errors.condition?.message}>
<InputControl
name="condition"
render={({ field: { onChange, ref, ...field } }) => (
<RadioButtonList options={options} onChange={onChange} {...field} />
)}
rules={{
required: {
value: true,
message: 'Please select the condition to alert on',
},
}}
/>
</Field>
</Card.Actions>
</Card>
) : (
<Alert title="No queries or expressions have been configured" severity="warning" className={styles.container}>
Create at least one query or expression to be alerted on
</Alert>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
max-width: ${theme.breakpoints.values.sm}px;
`,
});

View File

@ -0,0 +1,68 @@
import React, { FC, useMemo } from 'react';
import { PanelData } from '@grafana/data';
import { Stack } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { Expression } from '../expressions/Expression';
import { errorFromSeries, warningFromSeries } from './util';
interface Props {
condition: string | null;
onSetCondition: (refId: string) => void;
panelData: Record<string, PanelData | undefined>;
queries: AlertQuery[];
onRemoveExpression: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onUpdateQueryExpression: (query: ExpressionQuery) => void;
}
export const ExpressionsEditor: FC<Props> = ({
condition,
onSetCondition,
queries,
panelData,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType,
onUpdateQueryExpression,
}) => {
const expressionQueries = useMemo(() => {
return queries.reduce((acc: ExpressionQuery[], query) => {
return isExpressionQuery(query.model) ? acc.concat(query.model) : acc;
}, []);
}, [queries]);
return (
<Stack direction="row" alignItems="stretch">
{expressionQueries.map((query) => {
const data = panelData[query.refId];
const isAlertCondition = condition === query.refId;
const error = isAlertCondition && data ? errorFromSeries(data.series) : undefined;
const warning = isAlertCondition && data ? warningFromSeries(data.series) : undefined;
return (
<Expression
key={query.refId}
isAlertCondition={isAlertCondition}
data={data}
error={error}
warning={warning}
queries={queries}
query={query}
onSetCondition={onSetCondition}
onRemoveExpression={onRemoveExpression}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={onUpdateExpressionType}
onChangeQuery={onUpdateQueryExpression}
/>
);
})}
</Stack>
);
};

View File

@ -16,7 +16,6 @@ import { CollapseToggle } from '../CollapseToggle';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { PreviewRule } from './PreviewRule';
import { RuleEditorSection } from './RuleEditorSection';
const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@ -161,7 +160,6 @@ export const GrafanaEvaluationBehavior: FC = () => {
</Field>
</>
)}
<PreviewRule />
</RuleEditorSection>
);
};

View File

@ -1,111 +0,0 @@
import { render } from '@testing-library/react';
import React from 'react';
import { byLabelText, byTestId, byText } from 'testing-library-selector';
import { getDefaultRelativeTimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { setDataSourceSrv } from '@grafana/runtime';
import { MockDataSourceApi } from '../../../../../../test/mocks/datasource_srv';
import { ExpressionDatasourceUID, instanceSettings } from '../../../../expressions/ExpressionDatasource';
import { mockDataSource, MockDataSourceSrv } from '../../mocks';
import { getDefaultQueries } from '../../utils/rule-form';
import { QueryEditor } from './QueryEditor';
const ui = {
queryNames: byTestId<HTMLButtonElement>('query-name-div'),
dataSourcePicker: byLabelText<HTMLDivElement>(selectors.components.DataSourcePicker.container),
noDataSourcesWarning: byText('You appear to have no compatible data sources'),
};
const onChangeMock = jest.fn();
describe('Query Editor', () => {
it('should maintain the original query time range when duplicating it', () => {
const query = {
refId: 'A',
queryType: '',
datasourceUid: '',
model: { refId: 'A', hide: false },
relativeTimeRange: { from: 100, to: 0 },
};
const queryEditor = new QueryEditor({
onChange: onChangeMock,
value: [query],
});
queryEditor.onDuplicateQuery(query);
expect(onChangeMock).toHaveBeenCalledWith([
query,
{ ...query, ...{ refId: 'B', model: { refId: 'B', hide: false } } },
]);
});
it('should use the default query time range if none is set when duplicating a query', () => {
const query = {
refId: 'A',
queryType: '',
datasourceUid: '',
model: { refId: 'A', hide: false },
};
const queryEditor = new QueryEditor({
onChange: onChangeMock,
value: [query],
});
queryEditor.onDuplicateQuery(query);
const defaultRange = getDefaultRelativeTimeRange();
expect(onChangeMock).toHaveBeenCalledWith([
query,
{ ...query, ...{ refId: 'B', relativeTimeRange: defaultRange, model: { refId: 'B', hide: false } } },
]);
});
it('should select first data source supporting alerting when there is no default data source', async () => {
const dsServer = new MockDataSourceSrv({
influx: mockDataSource({ name: 'influx' }, { alerting: true }),
postgres: mockDataSource({ name: 'postgres' }, { alerting: true }),
[ExpressionDatasourceUID]: instanceSettings,
});
dsServer.get = () => Promise.resolve(new MockDataSourceApi());
setDataSourceSrv(dsServer);
const defaultQueries = getDefaultQueries();
render(<QueryEditor onChange={() => null} value={defaultQueries} />);
const queryRef = await ui.queryNames.findAll();
const select = await ui.dataSourcePicker.find();
expect(queryRef).toHaveLength(2);
expect(queryRef[0]).toHaveTextContent('A');
expect(queryRef[1]).toHaveTextContent('B');
expect(select).toHaveTextContent('influx'); // Alphabetical order
expect(ui.noDataSourcesWarning.query()).not.toBeInTheDocument();
});
it('should select the default data source when specified', async () => {
const dsServer = new MockDataSourceSrv({
influx: mockDataSource({ name: 'influx' }, { alerting: true }),
postgres: mockDataSource({ name: 'postgres', isDefault: true }, { alerting: true }),
[ExpressionDatasourceUID]: instanceSettings,
});
dsServer.get = () => Promise.resolve(new MockDataSourceApi());
setDataSourceSrv(dsServer);
const defaultQueries = getDefaultQueries();
render(<QueryEditor onChange={() => null} value={defaultQueries} />);
const queryRef = await ui.queryNames.findAll();
const select = await ui.dataSourcePicker.find();
expect(queryRef).toHaveLength(2);
expect(select).toHaveTextContent('postgres'); // Default data source
});
});

View File

@ -1,227 +1,52 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import React, { FC } from 'react';
import {
DataQuery,
getDefaultRelativeTimeRange,
GrafanaTheme2,
LoadingState,
PanelData,
RelativeTimeRange,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { Button, HorizontalGroup, stylesFactory, Tooltip } from '@grafana/ui';
import { getNextRefIdChar } from 'app/core/utils/query';
import {
dataSource as expressionDatasource,
ExpressionDatasourceUID,
} from 'app/features/expressions/ExpressionDatasource';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
import { GrafanaTheme2, PanelData } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { AlertingQueryRunner } from '../../state/AlertingQueryRunner';
import { getDefaultOrFirstCompatibleDataSource } from '../../utils/datasource';
import { QueryRows } from './QueryRows';
interface Props {
value?: AlertQuery[];
onChange: (queries: AlertQuery[]) => void;
panelData: Record<string, PanelData>;
queries: AlertQuery[];
onRunQueries: () => void;
onChangeQueries: (queries: AlertQuery[]) => void;
onDuplicateQuery: (query: AlertQuery) => void;
condition: string | null;
onSetCondition: (refId: string) => void;
}
interface State {
panelDataByRefId: Record<string, PanelData>;
}
export const QueryEditor: FC<Props> = ({
queries,
panelData,
onRunQueries,
onChangeQueries,
onDuplicateQuery,
condition,
onSetCondition,
}) => {
const styles = useStyles2(getStyles);
export class QueryEditor extends PureComponent<Props, State> {
private runner: AlertingQueryRunner;
private queries: AlertQuery[];
constructor(props: Props) {
super(props);
this.state = { panelDataByRefId: {} };
this.runner = new AlertingQueryRunner();
this.queries = props.value ?? [];
}
componentDidMount() {
this.runner.get().subscribe((data) => {
this.setState({ panelDataByRefId: data });
});
}
componentWillUnmount() {
this.runner.destroy();
}
onRunQueries = () => {
const { queries } = this;
this.runner.run(queries);
};
onCancelQueries = () => {
this.runner.cancel();
};
onChangeQueries = (queries: AlertQuery[]) => {
this.queries = queries;
this.props.onChange(queries);
};
onDuplicateQuery = (query: AlertQuery) => {
const { queries } = this;
this.onChangeQueries(addQuery(queries, query));
};
onNewAlertingQuery = () => {
const { queries } = this;
const datasource = getDefaultOrFirstCompatibleDataSource();
if (!datasource) {
return;
}
this.onChangeQueries(
addQuery(queries, {
datasourceUid: datasource.uid,
model: {
refId: '',
datasource: {
type: datasource.type,
uid: datasource.uid,
},
},
})
);
};
onNewExpressionQuery = () => {
const { queries } = this;
const lastQuery = queries.at(-1);
const defaultParams = lastQuery ? [lastQuery.refId] : [];
this.onChangeQueries(
addQuery(queries, {
datasourceUid: ExpressionDatasourceUID,
model: expressionDatasource.newQuery({
type: ExpressionQueryType.classic,
conditions: [{ ...defaultCondition, query: { params: defaultParams } }],
expression: lastQuery?.refId,
}),
})
);
};
isRunning() {
const data = Object.values(this.state.panelDataByRefId).find((d) => Boolean(d));
return data?.state === LoadingState.Loading;
}
renderRunQueryButton() {
const isRunning = this.isRunning();
if (isRunning) {
return (
<Button icon="fa fa-spinner" type="button" variant="destructive" onClick={this.onCancelQueries}>
Cancel
</Button>
);
}
return (
<Button icon="sync" type="button" onClick={this.onRunQueries}>
Run queries
</Button>
);
}
render() {
const { value = [] } = this.props;
const { panelDataByRefId } = this.state;
const styles = getStyles(config.theme2);
const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
return (
<div className={styles.container}>
<QueryRows
data={panelDataByRefId}
queries={value}
onQueriesChange={this.onChangeQueries}
onDuplicateQuery={this.onDuplicateQuery}
onRunQueries={this.onRunQueries}
/>
<HorizontalGroup spacing="sm" align="flex-start">
<Tooltip content={'You appear to have no compatible data sources'} show={noCompatibleDataSources}>
<Button
type="button"
icon="plus"
onClick={this.onNewAlertingQuery}
variant="secondary"
aria-label={selectors.components.QueryTab.addQuery}
disabled={noCompatibleDataSources}
>
Add query
</Button>
</Tooltip>
{config.expressionsEnabled && (
<Button type="button" icon="plus" onClick={this.onNewExpressionQuery} variant="secondary">
Add expression
</Button>
)}
{this.renderRunQueryButton()}
</HorizontalGroup>
</div>
);
}
}
const addQuery = (
queries: AlertQuery[],
queryToAdd: Pick<AlertQuery, 'model' | 'datasourceUid' | 'relativeTimeRange'>
): AlertQuery[] => {
const refId = getNextRefIdChar(queries);
const query: AlertQuery = {
...queryToAdd,
refId,
queryType: '',
model: {
...queryToAdd.model,
hide: false,
refId,
},
relativeTimeRange: queryToAdd.relativeTimeRange || defaultTimeRange(queryToAdd.model),
};
return [...queries, query];
return (
<div className={styles.container}>
<QueryRows
data={panelData}
queries={queries}
onRunQueries={onRunQueries}
onQueriesChange={onChangeQueries}
onDuplicateQuery={onDuplicateQuery}
condition={condition}
onSetCondition={onSetCondition}
/>
</div>
);
};
const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => {
if (isExpressionQuery(model)) {
return;
}
return getDefaultRelativeTimeRange();
};
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
container: css`
background-color: ${theme.colors.background.primary};
height: 100%;
max-width: ${theme.breakpoints.values.xxl}px;
`,
runWrapper: css`
margin-top: ${theme.spacing(1)};
`,
editorWrapper: css`
border: 1px solid ${theme.colors.border.medium};
border-radius: ${theme.shape.borderRadius()};
`,
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
background-color: ${theme.colors.background.primary};
height: 100%;
max-width: ${theme.breakpoints.values.xxl}px;
`,
});

View File

@ -19,36 +19,29 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
import { queriesWithUpdatedReferences } from './util';
import { errorFromSeries } from './util';
interface Props {
// The query configuration
queries: AlertQuery[];
data: Record<string, PanelData>;
onRunQueries: () => void;
// Query editing
onQueriesChange: (queries: AlertQuery[]) => void;
onDuplicateQuery: (query: AlertQuery) => void;
onRunQueries: () => void;
condition: string | null;
onSetCondition: (refId: string) => void;
}
interface State {
dataPerQuery: Record<string, PanelData>;
}
export class QueryRows extends PureComponent<Props, State> {
export class QueryRows extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.state = { dataPerQuery: {} };
}
onRemoveQuery = (query: DataQuery) => {
this.props.onQueriesChange(
this.props.queries.filter((item) => {
return item.model.refId !== query.refId;
})
);
const { queries, onQueriesChange } = this.props;
onQueriesChange(queries.filter((q) => q.refId !== query.refId));
};
onChangeTimeRange = (timeRange: RelativeTimeRange, index: number) => {
@ -119,12 +112,8 @@ export class QueryRows extends PureComponent<Props, State> {
onChangeQuery = (query: DataQuery, index: number) => {
const { queries, onQueriesChange } = this.props;
// find what queries still have a reference to the old name
const previousRefId = queries[index].refId;
const newRefId = query.refId;
onQueriesChange(
queriesWithUpdatedReferences(queries, previousRefId, newRefId).map((item, itemIndex) => {
queries.map((item, itemIndex) => {
if (itemIndex !== index) {
return item;
}
@ -162,13 +151,6 @@ export class QueryRows extends PureComponent<Props, State> {
onQueriesChange(update);
};
onDuplicateQuery = (query: DataQuery, source: AlertQuery): void => {
this.props.onDuplicateQuery({
...source,
model: query,
});
};
getDataSourceSettings = (query: AlertQuery): DataSourceInstanceSettings | undefined => {
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
};
@ -218,7 +200,7 @@ export class QueryRows extends PureComponent<Props, State> {
};
render() {
const { onDuplicateQuery, onRunQueries, queries } = this.props;
const { queries } = this.props;
const thresholdByRefId = this.getThresholdsForQueries(queries);
return (
@ -234,6 +216,9 @@ export class QueryRows extends PureComponent<Props, State> {
};
const dsSettings = this.getDataSourceSettings(query);
const isAlertCondition = this.props.condition === query.refId;
const error = isAlertCondition ? errorFromSeries(data.series) : undefined;
if (!dsSettings) {
return (
<DatasourceNotFound
@ -259,16 +244,19 @@ export class QueryRows extends PureComponent<Props, State> {
key={query.refId}
dsSettings={dsSettings}
data={data}
error={error}
query={query}
onChangeQuery={this.onChangeQuery}
onRemoveQuery={this.onRemoveQuery}
queries={queries}
onChangeDataSource={this.onChangeDataSource}
onDuplicateQuery={onDuplicateQuery}
onRunQueries={onRunQueries}
onDuplicateQuery={this.props.onDuplicateQuery}
onChangeTimeRange={this.onChangeTimeRange}
thresholds={thresholdByRefId[query.refId]}
onChangeThreshold={this.onChangeThreshold}
onRunQueries={this.props.onRunQueries}
condition={this.props.condition}
onSetCondition={this.props.onSetCondition}
/>
);
})}

View File

@ -13,18 +13,20 @@ import {
RelativeTimeRange,
ThresholdsConfig,
} from '@grafana/data';
import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon } from '@grafana/ui';
import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon, Stack } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { TABLE, TIMESERIES } from '../../utils/constants';
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
import { AlertConditionIndicator } from '../expressions/AlertConditionIndicator';
import { VizWrapper } from './VizWrapper';
interface Props {
data: PanelData;
error?: Error;
query: AlertQuery;
queries: AlertQuery[];
dsSettings: DataSourceInstanceSettings;
@ -37,10 +39,13 @@ interface Props {
index: number;
thresholds: ThresholdsConfig;
onChangeThreshold: (thresholds: ThresholdsConfig, index: number) => void;
condition: string | null;
onSetCondition: (refId: string) => void;
}
export const QueryWrapper: FC<Props> = ({
data,
error,
dsSettings,
index,
onChangeDataSource,
@ -53,6 +58,8 @@ export const QueryWrapper: FC<Props> = ({
queries,
thresholds,
onChangeThreshold,
condition,
onSetCondition,
}) => {
const styles = useStyles2(getStyles);
const isExpression = isExpressionQuery(query.model);
@ -84,12 +91,13 @@ export const QueryWrapper: FC<Props> = ({
);
}
function HeaderExtras({ query, index }: { query: AlertQuery; index: number }) {
// TODO add a warning label here too when the data looks like time series data and is used as an alert condition
function HeaderExtras({ query, error, index }: { query: AlertQuery; error?: Error; index: number }) {
if (isExpressionQuery(query.model)) {
return null;
} else {
return (
<>
<Stack direction="row" alignItems="center" gap={1}>
<SelectingDataSourceTooltip />
{onChangeTimeRange && (
<RelativeTimeRangePicker
@ -97,7 +105,12 @@ export const QueryWrapper: FC<Props> = ({
onChange={(range) => onChangeTimeRange(range, index)}
/>
)}
</>
<AlertConditionIndicator
onSetCondition={() => onSetCondition(query.refId)}
enabled={condition === query.refId}
error={error}
/>
</Stack>
);
}
}
@ -118,7 +131,7 @@ export const QueryWrapper: FC<Props> = ({
onAddQuery={() => onDuplicateQuery(cloneDeep(query))}
onRunQuery={onRunQueries}
queries={queries}
renderHeaderExtras={() => <HeaderExtras query={query} index={index} />}
renderHeaderExtras={() => <HeaderExtras query={query} index={index} error={error} />}
app={CoreApp.UnifiedAlerting}
visualization={
data.state !== LoadingState.NotStarted ? (
@ -152,7 +165,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
dsTooltip: css`
display: flex;
align-items: center;
margin-right: ${theme.spacing(2)};
&:hover {
opacity: 0.85;
cursor: pointer;

View File

@ -1,61 +0,0 @@
import React, { FC } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field, InputControl } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { ExpressionEditor } from '../ExpressionEditor';
import { QueryEditor } from '../QueryEditor';
export const Query: FC = () => {
const {
control,
watch,
formState: { errors },
} = useFormContext<RuleFormValues>();
const [type, dataSourceName] = watch(['type', 'dataSourceName']);
const isGrafanaManagedType = type === RuleFormType.grafana;
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
const isRecordingRuleType = type === RuleFormType.cloudRecording;
const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName;
return (
<div>
{/* This is the PromQL Editor for Cloud rules and recording rules */}
{showCloudExpressionEditor && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"
render={({ field: { ref, ...field } }) => {
return <ExpressionEditor {...field} dataSourceName={dataSourceName} />;
}}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
/>
</Field>
)}
{/* This is the editor for Grafana managed rules */}
{isGrafanaManagedType && (
<Field
invalid={!!errors.queries}
error={(!!errors.queries && 'Must provide at least one valid query.') || undefined}
>
<InputControl
name="queries"
render={({ field: { ref, ...field } }) => <QueryEditor {...field} />}
control={control}
rules={{
validate: (queries) => Array.isArray(queries) && !!queries.length,
}}
/>
</Field>
)}
</div>
);
};

View File

@ -1,28 +0,0 @@
import React, { FC } from 'react';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { ConditionField } from '../ConditionField';
import { RuleEditorSection } from '../RuleEditorSection';
import { AlertType } from './AlertType';
import { Query } from './Query';
interface Props {
editingExistingRule: boolean;
}
export const QueryAndAlertConditionStep: FC<Props> = ({ editingExistingRule }) => {
const { watch } = useFormContext<RuleFormValues>();
const type = watch('type');
const isGrafanaManagedType = type === RuleFormType.grafana;
return (
<RuleEditorSection stepNo={1} title="Set a query and alert condition">
<AlertType editingExistingRule={editingExistingRule} />
{type && <Query />}
{isGrafanaManagedType && <ConditionField existing={editingExistingRule} />}
</RuleEditorSection>
);
};

View File

@ -0,0 +1,253 @@
import React, { FC, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { LoadingState, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { Alert, Button, Field, InputControl, Stack, Tooltip } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { ExpressionEditor } from '../ExpressionEditor';
import { ExpressionsEditor } from '../ExpressionsEditor';
import { QueryEditor } from '../QueryEditor';
import { RuleEditorSection } from '../RuleEditorSection';
import { refIdExists } from '../util';
import { AlertType } from './AlertType';
import {
duplicateQuery,
addNewDataQuery,
addNewExpression,
queriesAndExpressionsReducer,
removeExpression,
rewireExpressions,
setDataQueries,
updateExpression,
updateExpressionRefId,
updateExpressionType,
} from './reducer';
interface Props {
editingExistingRule: boolean;
}
export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule }) => {
const runner = useRef(new AlertingQueryRunner());
const {
setValue,
getValues,
watch,
formState: { errors },
control,
} = useFormContext<RuleFormValues>();
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
const initialState = {
queries: getValues('queries'),
panelData: {},
};
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
const isGrafanaManagedType = type === RuleFormType.grafana;
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
const isRecordingRuleType = type === RuleFormType.cloudRecording;
const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName;
const cancelQueries = useCallback(() => {
runner.current.cancel();
}, []);
const runQueries = useCallback(() => {
runner.current.run(queries);
}, [queries]);
// whenever we update the queries we have to update the form too
useEffect(() => {
setValue('queries', queries, { shouldValidate: false });
}, [queries, runQueries, setValue]);
// set up the AlertQueryRunner
useEffect(() => {
const currentRunner = runner.current;
runner.current.get().subscribe((data) => {
setPanelData(data);
});
return () => currentRunner.destroy();
}, []);
const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
const isDataLoading = useMemo(() => {
return Object.values(panelData).some((d) => d.state === LoadingState.Loading);
}, [panelData]);
// data queries only
const dataQueries = useMemo(() => {
return queries.filter((query) => !isExpressionQuery(query.model));
}, [queries]);
const emptyQueries = queries.length === 0;
const onUpdateRefId = useCallback(
(oldRefId: string, newRefId: string) => {
const newRefIdExists = refIdExists(queries, newRefId);
// TODO we should set an error and explain what went wrong instead of just refusing to update
if (newRefIdExists) {
return;
}
dispatch(updateExpressionRefId({ oldRefId, newRefId }));
// update condition too if refId was updated
if (condition === oldRefId) {
setValue('condition', newRefId);
}
},
[condition, queries, setValue]
);
const onChangeQueries = useCallback(
(updatedQueries: AlertQuery[]) => {
dispatch(setDataQueries(updatedQueries));
// check if we need to rewire expressions
updatedQueries.forEach((query, index) => {
const oldRefId = queries[index].refId;
const newRefId = query.refId;
if (oldRefId !== newRefId) {
dispatch(rewireExpressions({ oldRefId, newRefId }));
}
});
},
[queries]
);
const onDuplicateQuery = useCallback((query: AlertQuery) => {
dispatch(duplicateQuery(query));
}, []);
// update the condition if it's been removed
useEffect(() => {
if (!refIdExists(queries, condition)) {
const lastRefId = queries.at(-1)?.refId ?? null;
setValue('condition', lastRefId);
}
}, [condition, queries, setValue]);
return (
<RuleEditorSection stepNo={1} title="Set a query and alert condition">
<AlertType editingExistingRule={editingExistingRule} />
{/* This is the PromQL Editor for Cloud rules and recording rules */}
{showCloudExpressionEditor && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"
render={({ field: { ref, ...field } }) => {
return <ExpressionEditor {...field} dataSourceName={dataSourceName} />;
}}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
/>
</Field>
)}
{/* This is the editor for Grafana managed rules */}
{isGrafanaManagedType && (
<Stack direction="column">
{/* Data Queries */}
<QueryEditor
queries={dataQueries}
onRunQueries={runQueries}
onChangeQueries={onChangeQueries}
onDuplicateQuery={onDuplicateQuery}
panelData={panelData}
condition={condition}
onSetCondition={(refId) => {
setValue('condition', refId);
}}
/>
{/* Expression Queries */}
<ExpressionsEditor
queries={queries}
panelData={panelData}
condition={condition}
onSetCondition={(refId) => {
setValue('condition', refId);
}}
onRemoveExpression={(refId) => {
dispatch(removeExpression(refId));
}}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={(refId, type) => {
dispatch(updateExpressionType({ refId, type }));
}}
onUpdateQueryExpression={(model) => {
dispatch(updateExpression(model));
}}
/>
{/* action buttons */}
<Stack direction="row">
<Tooltip content={'You appear to have no compatible data sources'} show={noCompatibleDataSources}>
<Button
type="button"
icon="plus"
onClick={() => {
dispatch(addNewDataQuery());
}}
variant="secondary"
aria-label={selectors.components.QueryTab.addQuery}
disabled={noCompatibleDataSources}
>
Add query
</Button>
</Tooltip>
{config.expressionsEnabled && (
<Button
type="button"
icon="plus"
onClick={() => {
dispatch(addNewExpression());
}}
variant="secondary"
>
Add expression
</Button>
)}
{isDataLoading && (
<Button icon="fa fa-spinner" type="button" variant="destructive" onClick={cancelQueries}>
Cancel
</Button>
)}
{!isDataLoading && (
<Button icon="sync" type="button" onClick={() => runQueries()} disabled={emptyQueries}>
Preview
</Button>
)}
</Stack>
{/* No Queries */}
{emptyQueries && (
<Alert title="No queries or expressions have been configured" severity="warning">
Create at least one query or expression to be alerted on
</Alert>
)}
</Stack>
)}
</RuleEditorSection>
);
};

View File

@ -0,0 +1,382 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Query and expressions reducer should add a new expression 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [],
},
"reducer": Object {
"params": Array [],
"type": "avg",
},
"type": "query",
},
],
"datasource": Object {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"hide": false,
"refId": "B",
"type": "math",
},
"queryType": "",
"refId": "B",
"relativeTimeRange": undefined,
},
],
}
`;
exports[`Query and expressions reducer should add query 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
Object {
"datasourceUid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
"model": Object {
"datasource": Object {
"type": "prometheus",
"uid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
},
"hide": false,
"refId": "B",
},
"queryType": "",
"refId": "B",
"relativeTimeRange": Object {
"from": 600,
"to": 0,
},
},
],
}
`;
exports[`Query and expressions reducer should duplicate query 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
Object {
"datasourceUid": "abc123",
"model": Object {
"hide": false,
"refId": "B",
},
"queryType": "",
"refId": "B",
"relativeTimeRange": Object {
"from": 600,
"to": 0,
},
},
],
}
`;
exports[`Query and expressions reducer should remove an expression or alert query 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
],
}
`;
exports[`Query and expressions reducer should rewire expressions 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [
"C",
],
},
"reducer": Object {
"params": Array [],
"type": "avg",
},
"type": "query",
},
],
"datasource": Object {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "classic_conditions",
},
"queryType": "",
"refId": "B",
},
],
}
`;
exports[`Query and expressions reducer should set data queries 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [
"A",
],
},
"reducer": Object {
"params": Array [],
"type": "avg",
},
"type": "query",
},
],
"datasource": Object {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "classic_conditions",
},
"queryType": "",
"refId": "B",
},
],
}
`;
exports[`Query and expressions reducer should update an expression 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [
"A",
],
},
"reducer": Object {
"params": Array [],
"type": "avg",
},
"type": "query",
},
],
"datasource": Object {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "math",
},
"queryType": "",
"refId": "B",
},
],
}
`;
exports[`Query and expressions reducer should update an expression refId and rewire expressions 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "C",
},
"queryType": "query",
"refId": "C",
},
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [
"C",
],
},
"reducer": Object {
"params": Array [],
"type": "avg",
},
"type": "query",
},
],
"datasource": Object {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "classic_conditions",
},
"queryType": "",
"refId": "B",
},
],
}
`;
exports[`Query and expressions reducer should update expression type 1`] = `
Object {
"queries": Array [
Object {
"datasourceUid": "abc123",
"model": Object {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
Object {
"datasourceUid": "-100",
"model": Object {
"conditions": Array [
Object {
"evaluator": Object {
"params": Array [
0,
0,
],
"type": "gt",
},
"operator": Object {
"type": "and",
},
"query": Object {
"params": Array [],
},
"reducer": Object {
"params": Array [],
"type": "avg",
},
"type": "query",
},
],
"datasource": Object {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "reduce",
},
"queryType": "",
"refId": "B",
},
],
}
`;

View File

@ -0,0 +1,213 @@
import { getDefaultRelativeTimeRange } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime/src/services/__mocks__/dataSourceSrv';
import {
dataSource as expressionDatasource,
ExpressionDatasourceUID,
} from 'app/features/expressions/ExpressionDatasource';
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import {
addNewDataQuery,
addNewExpression,
duplicateQuery,
queriesAndExpressionsReducer,
QueriesAndExpressionsState,
removeExpression,
rewireExpressions,
setDataQueries,
updateExpression,
updateExpressionRefId,
updateExpressionType,
} from './reducer';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: getDataSourceSrv,
}));
const alertQuery: AlertQuery = {
refId: 'A',
queryType: 'query',
datasourceUid: 'abc123',
model: {
refId: 'A',
},
};
const expressionQuery: AlertQuery = {
datasourceUid: ExpressionDatasourceUID,
model: expressionDatasource.newQuery({
type: ExpressionQueryType.classic,
conditions: [{ ...defaultCondition, query: { params: ['A'] } }],
expression: '',
refId: 'B',
}),
refId: 'B',
queryType: '',
};
describe('Query and expressions reducer', () => {
it('should return initial state', () => {
expect(queriesAndExpressionsReducer(undefined, { type: undefined })).toEqual({
queries: [],
});
});
it('should duplicate query', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery],
};
const newState = queriesAndExpressionsReducer(initialState, duplicateQuery(alertQuery));
const newQuery = newState.queries.at(-1);
expect(newState).toMatchSnapshot();
expect(newQuery).toHaveProperty('relativeTimeRange', getDefaultRelativeTimeRange());
});
it('should duplicate query and copy time range', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery],
};
const customTimeRange = {
from: -200,
to: 800,
};
const query: AlertQuery = {
...initialState.queries[0],
relativeTimeRange: customTimeRange,
};
const previousState: QueriesAndExpressionsState = {
queries: [query],
};
const newState = queriesAndExpressionsReducer(previousState, duplicateQuery(query));
const newQuery = newState.queries.at(-1);
expect(newQuery).toHaveProperty('relativeTimeRange', customTimeRange);
});
it('should add query', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery],
};
const newState = queriesAndExpressionsReducer(initialState, addNewDataQuery());
expect(newState.queries).toHaveLength(2);
expect(newState).toMatchSnapshot();
});
it('should set data queries', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(initialState, setDataQueries([]));
expect(newState.queries).toHaveLength(1);
expect(newState).toMatchSnapshot();
});
it('should add a new expression', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery],
};
const newState = queriesAndExpressionsReducer(initialState, addNewExpression());
expect(newState.queries).toHaveLength(2);
expect(newState).toMatchSnapshot();
});
it('should remove an expression or alert query', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
let stateWithoutB = queriesAndExpressionsReducer(initialState, removeExpression('B'));
expect(stateWithoutB.queries).toHaveLength(1);
expect(stateWithoutB).toMatchSnapshot();
let stateWithoutAOrB = queriesAndExpressionsReducer(stateWithoutB, removeExpression('A'));
expect(stateWithoutAOrB.queries).toHaveLength(0);
});
it('should update an expression', () => {
const newExpression: ExpressionQuery = {
...expressionQuery.model,
type: ExpressionQueryType.math,
};
const initialState: QueriesAndExpressionsState = {
queries: [expressionQuery],
};
const newState = queriesAndExpressionsReducer(initialState, updateExpression(newExpression));
expect(newState).toMatchSnapshot();
});
it('should update an expression refId and rewire expressions', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(
initialState,
updateExpressionRefId({
oldRefId: 'A',
newRefId: 'C',
})
);
expect(newState).toMatchSnapshot();
});
it('should not update an expression when the refId exists', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(
initialState,
updateExpressionRefId({
oldRefId: 'A',
newRefId: 'B',
})
);
expect(newState).toEqual(initialState);
});
it('should rewire expressions', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(
initialState,
rewireExpressions({
oldRefId: 'A',
newRefId: 'C',
})
);
expect(newState).toMatchSnapshot();
});
it('should update expression type', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(
initialState,
updateExpressionType({
refId: 'B',
type: ExpressionQueryType.reduce,
})
);
expect(newState).toMatchSnapshot();
});
});

View File

@ -0,0 +1,163 @@
import { createAction, createReducer } from '@reduxjs/toolkit';
import { DataQuery, RelativeTimeRange, getDefaultRelativeTimeRange } from '@grafana/data';
import { getNextRefIdChar } from 'app/core/utils/query';
import {
dataSource as expressionDatasource,
ExpressionDatasourceUID,
} from 'app/features/expressions/ExpressionDatasource';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { queriesWithUpdatedReferences, refIdExists } from '../util';
export interface QueriesAndExpressionsState {
queries: AlertQuery[];
}
const initialState: QueriesAndExpressionsState = {
queries: [],
};
export const duplicateQuery = createAction<AlertQuery>('duplicateQuery');
export const addNewDataQuery = createAction('addNewDataQuery');
export const setDataQueries = createAction<AlertQuery[]>('setDataQueries');
export const addNewExpression = createAction('addNewExpression');
export const removeExpression = createAction<string>('removeExpression');
export const updateExpression = createAction<ExpressionQuery>('updateExpression');
export const updateExpressionRefId = createAction<{ oldRefId: string; newRefId: string }>('updateExpressionRefId');
export const rewireExpressions = createAction<{ oldRefId: string; newRefId: string }>('rewireExpressions');
export const updateExpressionType = createAction<{ refId: string; type: ExpressionQueryType }>('updateExpressionType');
export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => {
// data queries actions
builder
.addCase(duplicateQuery, (state, { payload }) => {
state.queries = addQuery(state.queries, payload);
})
.addCase(addNewDataQuery, (state) => {
const datasource = getDefaultOrFirstCompatibleDataSource();
if (!datasource) {
return;
}
state.queries = addQuery(state.queries, {
datasourceUid: datasource.uid,
model: {
refId: '',
datasource: {
type: datasource.type,
uid: datasource.uid,
},
},
});
})
.addCase(setDataQueries, (state, { payload }) => {
const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
state.queries = [...payload, ...expressionQueries];
});
// expressions actions
builder
.addCase(addNewExpression, (state) => {
state.queries = addQuery(state.queries, {
datasourceUid: ExpressionDatasourceUID,
model: expressionDatasource.newQuery({
type: ExpressionQueryType.math,
conditions: [{ ...defaultCondition, query: { params: [] } }],
expression: '',
}),
});
})
.addCase(removeExpression, (state, { payload }) => {
state.queries = state.queries.filter((query) => query.refId !== payload);
})
.addCase(updateExpression, (state, { payload }) => {
state.queries = state.queries.map((query) => {
return query.refId === payload.refId
? {
...query,
model: payload,
}
: query;
});
})
.addCase(updateExpressionRefId, (state, { payload }) => {
const { newRefId, oldRefId } = payload;
// if the new refId already exists we just refuse to update the state
const newRefIdExists = refIdExists(state.queries, newRefId);
if (newRefIdExists) {
return;
}
const updatedQueries = queriesWithUpdatedReferences(state.queries, oldRefId, newRefId);
state.queries = updatedQueries.map((query) => {
if (query.refId === oldRefId) {
return {
...query,
refId: newRefId,
model: {
...query.model,
refId: newRefId,
},
};
}
return query;
});
})
.addCase(rewireExpressions, (state, { payload }) => {
state.queries = queriesWithUpdatedReferences(state.queries, payload.oldRefId, payload.newRefId);
})
.addCase(updateExpressionType, (state, action) => {
state.queries = state.queries.map((query) => {
return query.refId === action.payload.refId
? {
...query,
model: {
...expressionDatasource.newQuery({
type: action.payload.type,
conditions: [{ ...defaultCondition, query: { params: [] } }],
expression: '',
}),
refId: action.payload.refId,
},
}
: query;
});
});
});
const addQuery = (
queries: AlertQuery[],
queryToAdd: Pick<AlertQuery, 'model' | 'datasourceUid' | 'relativeTimeRange'>
): AlertQuery[] => {
const refId = getNextRefIdChar(queries);
const query: AlertQuery = {
...queryToAdd,
refId,
queryType: '',
model: {
...queryToAdd.model,
hide: false,
refId,
},
relativeTimeRange: queryToAdd.relativeTimeRange ?? defaultTimeRange(queryToAdd.model),
};
return [...queries, query];
};
const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => {
if (isExpressionQuery(model)) {
return;
}
return getDefaultRelativeTimeRange();
};

View File

@ -1,5 +1,7 @@
import { ValidateResult } from 'react-hook-form';
import { DataFrame } from '@grafana/data';
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { AlertQuery } from 'app/types/unified-alerting-dto';
@ -67,6 +69,10 @@ export function updateMathExpressionRefs(expression: string, previousRefId: stri
return expression.replace(oldExpression, newExpression);
}
export function refIdExists(queries: AlertQuery[], refId: string | null): boolean {
return queries.find((query) => query.refId === refId) !== undefined;
}
// some gateways (like Istio) will decode "/" and "\" characters this will cause 404 errors for any API call
// that includes these values in the URL (ie. /my/path%2fto/resource -> /my/path/to/resource)
//
@ -79,3 +85,25 @@ export function checkForPathSeparator(value: string): ValidateResult {
return true;
}
export function errorFromSeries(series: DataFrame[]): Error | undefined {
if (series.length === 0) {
return;
}
const isTimeSeriesResults = isTimeSeries(series);
let error;
if (isTimeSeriesResults) {
error = new Error('You cannot use time series data as an alert condition, consider adding a reduce expression.');
}
return error;
}
export function warningFromSeries(series: DataFrame[]): Error | undefined {
const notices = series[0]?.meta?.notices ?? [];
const warning = notices.find((notice) => notice.severity === 'warning')?.text;
return warning ? new Error(warning) : undefined;
}

View File

@ -7,8 +7,11 @@ import { alertStateToReadable, alertStateToState } from '../../utils/rules';
import { StateTag } from '../StateTag';
interface Props {
state: PromAlertingRuleState | GrafanaAlertState | GrafanaAlertStateWithReason | AlertState;
size?: 'md' | 'sm';
}
export const AlertStateTag: FC<Props> = ({ state }) => (
<StateTag state={alertStateToState(state)}>{alertStateToReadable(state)}</StateTag>
export const AlertStateTag: FC<Props> = ({ state, size = 'md' }) => (
<StateTag state={alertStateToState(state)} size={size}>
{alertStateToReadable(state)}
</StateTag>
);

View File

@ -198,7 +198,7 @@ export const getDefaultQueries = (): AlertQuery[] => {
const dataSource = getDefaultOrFirstCompatibleDataSource();
if (!dataSource) {
return [getDefaultExpression('A')];
return [...getDefaultExpressions('A', 'B')];
}
const relativeTimeRange = getDefaultRelativeTimeRange();
@ -213,15 +213,18 @@ export const getDefaultQueries = (): AlertQuery[] => {
hide: false,
},
},
getDefaultExpression('B'),
...getDefaultExpressions('B', 'C'),
];
};
const getDefaultExpression = (refId: string): AlertQuery => {
const model: ExpressionQuery = {
refId,
const getDefaultExpressions = (...refIds: [string, string]): AlertQuery[] => {
const refOne = refIds[0];
const refTwo = refIds[1];
const reduceExpression: ExpressionQuery = {
refId: refIds[0],
hide: false,
type: ExpressionQueryType.classic,
type: ExpressionQueryType.reduce,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
@ -230,14 +233,14 @@ const getDefaultExpression = (refId: string): AlertQuery => {
{
type: 'query',
evaluator: {
params: [3],
params: [],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
params: ['A'],
params: [refOne],
},
reducer: {
params: [],
@ -245,15 +248,54 @@ const getDefaultExpression = (refId: string): AlertQuery => {
},
},
],
reducer: 'last',
expression: 'A',
};
return {
refId,
datasourceUid: ExpressionDatasourceUID,
queryType: '',
model,
const thresholdExpression: ExpressionQuery = {
refId: refTwo,
hide: false,
type: ExpressionQueryType.threshold,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
},
conditions: [
{
type: 'query',
evaluator: {
params: [0],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
params: [refTwo],
},
reducer: {
params: [],
type: 'last',
},
},
],
expression: refOne,
};
return [
{
refId: refOne,
datasourceUid: ExpressionDatasourceUID,
queryType: '',
model: reduceExpression,
},
{
refId: refTwo,
datasourceUid: ExpressionDatasourceUID,
queryType: '',
model: thresholdExpression,
},
];
};
const dataQueriesToGrafanaQueries = async (
@ -338,7 +380,14 @@ export const panelToRuleFormValues = async (
}
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
queries.push(getDefaultExpression(getNextRefIdChar(queries.map((query) => query.model))));
const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefIdChar(queries), '-');
queries.push(reduceExpression);
const [_reduceExpression, thresholdExpression] = getDefaultExpressions(
reduceExpression.refId,
getNextRefIdChar(queries)
);
queries.push(thresholdExpression);
}
const { folderId, folderTitle } = dashboard.meta;

View File

@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import React, { FC, FormEvent } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, useStyles } from '@grafana/ui';
import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, Stack, useStyles } from '@grafana/ui';
import alertDef, { EvalFunction } from '../../alerting/state/alertDef';
import { ClassicCondition, ReducerType } from '../types';
@ -69,65 +69,70 @@ export const Condition: FC<Props> = ({ condition, index, onChange, onRemoveCondi
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange;
return (
<InlineFieldRow>
{index === 0 ? (
<div className={cx(styles.button, buttonWidth)}>WHEN</div>
) : (
<ButtonSelect
className={cx(styles.buttonSelectText, buttonWidth)}
options={evalOperators}
onChange={onEvalOperatorChange}
value={evalOperators.find((ea) => ea.value === condition.operator!.type)}
/>
)}
<Select
options={reducerFunctions}
onChange={onReducerFunctionChange}
width={20}
value={reducerFunctions.find((rf) => rf.value === condition.reducer.type)}
/>
<div className={styles.button}>OF</div>
<Select
onChange={onRefIdChange}
options={refIds}
width={15}
value={refIds.find((r) => r.value === condition.query.params[0])}
/>
<ButtonSelect
className={styles.buttonSelectText}
options={evalFunctions}
onChange={onEvalFunctionChange}
value={evalFunctions.find((ef) => ef.value === condition.evaluator.type)}
/>
{isRange ? (
<>
<Input
type="number"
width={10}
onChange={(event) => onEvaluateValueChange(event, 0)}
value={condition.evaluator.params[0]}
<Stack direction="row">
<div style={{ flex: 1 }}>
<InlineFieldRow>
{index === 0 ? (
<div className={cx(styles.button, buttonWidth)}>WHEN</div>
) : (
<ButtonSelect
className={cx(styles.buttonSelectText, buttonWidth)}
options={evalOperators}
onChange={onEvalOperatorChange}
value={evalOperators.find((ea) => ea.value === condition.operator!.type)}
/>
)}
<Select
options={reducerFunctions}
onChange={onReducerFunctionChange}
width={20}
value={reducerFunctions.find((rf) => rf.value === condition.reducer.type)}
/>
<div className={styles.button}>TO</div>
<Input
type="number"
width={10}
onChange={(event) => onEvaluateValueChange(event, 1)}
value={condition.evaluator.params[1]}
<div className={styles.button}>OF</div>
<Select
onChange={onRefIdChange}
options={refIds}
width={'auto'}
value={refIds.find((r) => r.value === condition.query.params[0])}
/>
</>
) : condition.evaluator.type !== EvalFunction.HasNoValue ? (
<Input
type="number"
width={10}
onChange={(event) => onEvaluateValueChange(event, 0)}
value={condition.evaluator.params[0]}
/>
) : null}
</InlineFieldRow>
<InlineFieldRow>
<ButtonSelect
className={styles.buttonSelectText}
options={evalFunctions}
onChange={onEvalFunctionChange}
value={evalFunctions.find((ef) => ef.value === condition.evaluator.type)}
/>
{isRange ? (
<>
<Input
type="number"
width={10}
onChange={(event) => onEvaluateValueChange(event, 0)}
value={condition.evaluator.params[0]}
/>
<div className={styles.button}>TO</div>
<Input
type="number"
width={10}
onChange={(event) => onEvaluateValueChange(event, 1)}
value={condition.evaluator.params[1]}
/>
</>
) : condition.evaluator.type !== EvalFunction.HasNoValue ? (
<Input
type="number"
width={10}
onChange={(event) => onEvaluateValueChange(event, 0)}
value={condition.evaluator.params[0]}
/>
) : null}
</InlineFieldRow>
</div>
<Button variant="secondary" type="button" onClick={() => onRemoveCondition(index)}>
<Icon name="trash-alt" />
</Button>
</InlineFieldRow>
</Stack>
);
};

View File

@ -1,14 +1,14 @@
import { css } from '@emotion/css';
import React, { ChangeEvent, FC } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Icon, InlineField, Stack, TextArea, useStyles2 } from '@grafana/ui';
import { Icon, InlineField, InlineLabel, Stack, TextArea, useStyles2 } from '@grafana/ui';
import { HoverCard } from 'app/features/alerting/unified/components/HoverCard';
import { ExpressionQuery } from '../types';
interface Props {
labelWidth: number;
labelWidth: number | 'auto';
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;
onRunQuery: () => void;
@ -19,13 +19,11 @@ const mathPlaceholder =
'The sum of two scalar values: $A + $B > 10';
export const Math: FC<Props> = ({ labelWidth, onChange, query, onRunQuery }) => {
const [showHelp, toggleShowHelp] = useToggle(false);
const onExpressionChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
onChange({ ...query, expression: event.target.value });
};
const styles = useStyles2((theme) => getStyles(theme, showHelp));
const styles = useStyles2(getStyles);
const executeQuery = () => {
if (query.expression) {
@ -36,93 +34,97 @@ export const Math: FC<Props> = ({ labelWidth, onChange, query, onRunQuery }) =>
return (
<Stack direction="row">
<InlineField
label="Expression"
label={
<InlineLabel width="auto">
<HoverCard
content={
<div className={styles.documentationContainer}>
<header className={styles.documentationHeader}>
<Icon name="book-open" /> Math operator
</header>
<div>
Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C
etc.
<br />
Example: <code>$A + $B</code>
</div>
<header className={styles.documentationHeader}>Available Math functions</header>
<div className={styles.documentationFunctions}>
<DocumentedFunction
name="abs"
description="returns the absolute value of its argument which can be a number or a series"
/>
<DocumentedFunction
name="is_inf"
description="returns 1 for Inf values (negative or positive) and 0 for other values. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="is_nan"
description="returns 1 for NaN values and 0 for other values. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="is_null"
description="returns 1 for null values and 0 for other values. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="is_number"
description="returns 1 for all real number values and 0 for non-number. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="log"
description="returns the natural logarithm of its argument, which can be a number or a series"
/>
<DocumentedFunction
name="inf, infn, nan, and null"
description="The inf for infinity positive, infn for infinity negative, nan, and null functions all return a single scalar value that matches its name."
/>
<DocumentedFunction
name="round"
description="returns a rounded integer value. It's able to operate on series or escalar values."
/>
<DocumentedFunction
name="ceil"
description="rounds the number up to the nearest integer value. It's able to operate on series or escalar values."
/>
<DocumentedFunction
name="floor"
description="rounds the number down to the nearest integer value. It's able to operate on series or escalar values."
/>
</div>
<div>
See our additional documentation on{' '}
<a
className={styles.documentationLink}
target="_blank"
href="https://grafana.com/docs/grafana/latest/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions/#math"
rel="noreferrer"
>
<Icon size="xs" name="external-link-alt" /> Math expressions
</a>
.
</div>
</div>
}
>
<span>
Expression <Icon name="info-circle" />
</span>
</HoverCard>
</InlineLabel>
}
labelWidth={labelWidth}
grow={true}
shrink={true}
className={css`
align-items: flex-start;
flex: 0.7;
`}
>
<>
<TextArea
value={query.expression}
onChange={onExpressionChange}
rows={5}
placeholder={mathPlaceholder}
onBlur={executeQuery}
/>
<Button variant="secondary" size="sm" onClick={toggleShowHelp}>
{showHelp === false ? 'Show' : 'Hide'} help
</Button>
</>
<TextArea
value={query.expression}
onChange={onExpressionChange}
rows={1}
placeholder={mathPlaceholder}
onBlur={executeQuery}
style={{ minWidth: 250, lineHeight: '26px', minHeight: 32 }}
/>
</InlineField>
<div className={styles.documentationContainer}>
<header className={styles.documentationHeader}>
<Icon name="book-open" /> Math operator
</header>
<div>
Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C etc.
<br />
Example: <code>$A + $B</code>
</div>
<header className={styles.documentationHeader}>Available Math functions</header>
<div className={styles.documentationFunctions}>
<DocumentedFunction
name="abs"
description="returns the absolute value of its argument which can be a number or a series"
/>
<DocumentedFunction
name="is_inf"
description="returns 1 for Inf values (negative or positive) and 0 for other values. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="is_nan"
description="returns 1 for NaN values and 0 for other values. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="is_null"
description="returns 1 for null values and 0 for other values. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="is_number"
description="returns 1 for all real number values and 0 for non-number. It's able to operate on series or scalar values."
/>
<DocumentedFunction
name="log"
description="returns the natural logarithm of its argument, which can be a number or a series"
/>
<DocumentedFunction
name="inf, infn, nan, and null"
description="The inf for infinity positive, infn for infinity negative, nan, and null functions all return a single scalar value that matches its name."
/>
<DocumentedFunction
name="round"
description="returns a rounded integer value. It's able to operate on series or escalar values."
/>
<DocumentedFunction
name="ceil"
description="rounds the number up to the nearest integer value. It's able to operate on series or escalar values."
/>
<DocumentedFunction
name="floor"
description="rounds the number down to the nearest integer value. It's able to operate on series or escalar values."
/>
</div>
<div>
See our additional documentation on{' '}
<a
className={styles.documentationLink}
target="_blank"
href="https://grafana.com/docs/grafana/latest/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions/#math"
rel="noreferrer"
>
<Icon size="xs" name="external-link-alt" /> Math expressions
</a>
.
</div>
</div>
</Stack>
);
};
@ -142,7 +144,7 @@ const DocumentedFunction = ({ name, description }: DocumentedFunctionProps) => {
);
};
const getStyles = (theme: GrafanaTheme2, showHelp?: boolean) => ({
const getStyles = (theme: GrafanaTheme2) => ({
documentationHeader: css`
font-size: ${theme.typography.h5.fontSize};
font-weight: ${theme.typography.h5.fontWeight};
@ -151,10 +153,12 @@ const getStyles = (theme: GrafanaTheme2, showHelp?: boolean) => ({
color: ${theme.colors.text.link};
`,
documentationContainer: css`
display: ${showHelp ? 'flex' : 'none'};
display: flex;
flex: 1;
flex-direction: column;
gap: ${theme.spacing(2)};
padding: ${theme.spacing(1)} ${theme.spacing(2)};
`,
documentationFunctions: css`
display: grid;

View File

@ -6,13 +6,13 @@ import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerMode, reducerTypes } from '../types';
interface Props {
labelWidth: number;
labelWidth?: number | 'auto';
refIds: Array<SelectableValue<string>>;
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;
}
export const Reduce: FC<Props> = ({ labelWidth, onChange, refIds, query }) => {
export const Reduce: FC<Props> = ({ labelWidth = 'auto', onChange, refIds, query }) => {
const reducer = reducerTypes.find((o) => o.value === query.reducer);
const onRefIdChange = (value: SelectableValue<string>) => {

View File

@ -8,11 +8,11 @@ import { downsamplingTypes, ExpressionQuery, upsamplingTypes } from '../types';
interface Props {
refIds: Array<SelectableValue<string>>;
query: ExpressionQuery;
labelWidth: number;
labelWidth?: number | 'auto';
onChange: (query: ExpressionQuery) => void;
}
export const Resample: FC<Props> = ({ labelWidth, onChange, refIds, query }) => {
export const Resample: FC<Props> = ({ labelWidth = 'auto', onChange, refIds, query }) => {
const downsampler = downsamplingTypes.find((o) => o.value === query.downsampler);
const upsampler = upsamplingTypes.find((o) => o.value === query.upsampler);

View File

@ -8,7 +8,7 @@ import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { ClassicCondition, ExpressionQuery, thresholdFunctions } from '../types';
interface Props {
labelWidth: number;
labelWidth: number | 'auto';
refIds: Array<SelectableValue<string>>;
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;

View File

@ -11,11 +11,34 @@ export enum ExpressionQueryType {
}
export const gelTypes: Array<SelectableValue<ExpressionQueryType>> = [
{ value: ExpressionQueryType.math, label: 'Math' },
{ value: ExpressionQueryType.reduce, label: 'Reduce' },
{ value: ExpressionQueryType.resample, label: 'Resample' },
{ value: ExpressionQueryType.classic, label: 'Classic condition' },
{ value: ExpressionQueryType.threshold, label: 'Threshold' },
{
value: ExpressionQueryType.math,
label: 'Math',
description: 'Free-form math formulas on time series or number data.',
},
{
value: ExpressionQueryType.reduce,
label: 'Reduce',
description:
'Takes one or more time series returned from a query or an expression and turns each series into a single number.',
},
{
value: ExpressionQueryType.resample,
label: 'Resample',
description: 'Changes the time stamps in each time series to have a consistent time interval.',
},
{
value: ExpressionQueryType.classic,
label: 'Classic condition',
description:
'Takes one or more time series returned from a query or an expression and checks if any of the series match the condition.',
},
{
value: ExpressionQueryType.threshold,
label: 'Threshold',
description:
'Takes one or more time series returned from a query or an expression and checks if any of the series match the threshold condition.',
},
];
export const reducerTypes: Array<SelectableValue<string>> = [