|
@@ -4,35 +4,39 @@ import styled from '@emotion/styled';
|
|
|
import {addErrorMessage} from 'app/actionCreators/indicator';
|
|
|
import {Client} from 'app/api';
|
|
|
import Feature from 'app/components/acl/feature';
|
|
|
-import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
|
|
|
+import SelectControl from 'app/components/forms/selectControl';
|
|
|
+import List from 'app/components/list';
|
|
|
+import ListItem from 'app/components/list/listItem';
|
|
|
+import {Panel, PanelBody} from 'app/components/panels';
|
|
|
import Tooltip from 'app/components/tooltip';
|
|
|
import {t, tct} from 'app/locale';
|
|
|
import space from 'app/styles/space';
|
|
|
import {Environment, Organization} from 'app/types';
|
|
|
-import {defined} from 'app/utils';
|
|
|
import {getDisplayName} from 'app/utils/environment';
|
|
|
import theme from 'app/utils/theme';
|
|
|
-import {DATA_SOURCE_LABELS} from 'app/views/alerts/utils';
|
|
|
+import {
|
|
|
+ convertDatasetEventTypesToSource,
|
|
|
+ DATA_SOURCE_LABELS,
|
|
|
+ DATA_SOURCE_TO_SET_AND_EVENT_TYPES,
|
|
|
+} from 'app/views/alerts/utils';
|
|
|
import SearchBar from 'app/views/events/searchBar';
|
|
|
-import RadioGroup from 'app/views/settings/components/forms/controls/radioGroup';
|
|
|
-import FieldLabel from 'app/views/settings/components/forms/field/fieldLabel';
|
|
|
import FormField from 'app/views/settings/components/forms/formField';
|
|
|
import SelectField from 'app/views/settings/components/forms/selectField';
|
|
|
|
|
|
-import {DATASET_EVENT_TYPE_FILTERS, DEFAULT_AGGREGATE} from './constants';
|
|
|
+import {DEFAULT_AGGREGATE} from './constants';
|
|
|
import MetricField from './metricField';
|
|
|
-import {Dataset, IncidentRule, TimeWindow} from './types';
|
|
|
+import {Datasource, IncidentRule, TimeWindow} from './types';
|
|
|
|
|
|
const TIME_WINDOW_MAP: Record<TimeWindow, string> = {
|
|
|
- [TimeWindow.ONE_MINUTE]: t('1 minute'),
|
|
|
- [TimeWindow.FIVE_MINUTES]: t('5 minutes'),
|
|
|
- [TimeWindow.TEN_MINUTES]: t('10 minutes'),
|
|
|
- [TimeWindow.FIFTEEN_MINUTES]: t('15 minutes'),
|
|
|
- [TimeWindow.THIRTY_MINUTES]: t('30 minutes'),
|
|
|
- [TimeWindow.ONE_HOUR]: t('1 hour'),
|
|
|
- [TimeWindow.TWO_HOURS]: t('2 hours'),
|
|
|
- [TimeWindow.FOUR_HOURS]: t('4 hours'),
|
|
|
- [TimeWindow.ONE_DAY]: t('24 hours'),
|
|
|
+ [TimeWindow.ONE_MINUTE]: t('1 minute window'),
|
|
|
+ [TimeWindow.FIVE_MINUTES]: t('5 minute window'),
|
|
|
+ [TimeWindow.TEN_MINUTES]: t('10 minute window'),
|
|
|
+ [TimeWindow.FIFTEEN_MINUTES]: t('15 minute window'),
|
|
|
+ [TimeWindow.THIRTY_MINUTES]: t('30 minute window'),
|
|
|
+ [TimeWindow.ONE_HOUR]: t('1 hour window'),
|
|
|
+ [TimeWindow.TWO_HOURS]: t('2 hour window'),
|
|
|
+ [TimeWindow.FOUR_HOURS]: t('4 hour window'),
|
|
|
+ [TimeWindow.ONE_DAY]: t('24 hour window'),
|
|
|
};
|
|
|
|
|
|
type Props = {
|
|
@@ -40,7 +44,7 @@ type Props = {
|
|
|
organization: Organization;
|
|
|
projectSlug: string;
|
|
|
disabled: boolean;
|
|
|
- thresholdChart: React.ReactNode;
|
|
|
+ thresholdChart: React.ReactElement;
|
|
|
onFilterSearch: (query: string) => void;
|
|
|
};
|
|
|
|
|
@@ -48,7 +52,7 @@ type State = {
|
|
|
environments: Environment[] | null;
|
|
|
};
|
|
|
|
|
|
-class RuleConditionsForm extends React.PureComponent<Props, State> {
|
|
|
+class RuleConditionsFormWithGuiFilters extends React.PureComponent<Props, State> {
|
|
|
state: State = {
|
|
|
environments: null,
|
|
|
};
|
|
@@ -79,15 +83,12 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
|
|
|
const {organization, disabled, onFilterSearch} = this.props;
|
|
|
const {environments} = this.state;
|
|
|
|
|
|
- const environmentList: [IncidentRule['environment'], React.ReactNode][] = defined(
|
|
|
- environments
|
|
|
- )
|
|
|
- ? environments.map((env: Environment) => [env.name, getDisplayName(env)])
|
|
|
- : [];
|
|
|
+ const environmentList: [IncidentRule['environment'], React.ReactNode][] =
|
|
|
+ environments?.map((env: Environment) => [env.name, getDisplayName(env)]) ?? [];
|
|
|
|
|
|
const anyEnvironmentLabel = (
|
|
|
<React.Fragment>
|
|
|
- {t('All Environments')}
|
|
|
+ {t('All')}
|
|
|
<div className="all-environment-note">
|
|
|
{tct(
|
|
|
`This will count events across every environment. For example,
|
|
@@ -100,65 +101,141 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
|
|
|
);
|
|
|
environmentList.unshift([null, anyEnvironmentLabel]);
|
|
|
|
|
|
+ const formElemBaseStyle = {
|
|
|
+ padding: `${space(0.5)}`,
|
|
|
+ border: 'none',
|
|
|
+ };
|
|
|
+
|
|
|
return (
|
|
|
- <React.Fragment>
|
|
|
- <Feature requireAll features={['organizations:performance-view']}>
|
|
|
- <StyledPanel>
|
|
|
- <PanelHeader>{t('Alert Conditions')}</PanelHeader>
|
|
|
- <PanelBody>
|
|
|
- <FormField required name="dataset" label="Data source">
|
|
|
- {({onChange, onBlur, value, model, label}) => (
|
|
|
- <RadioGroup
|
|
|
- orientInline
|
|
|
- disabled={disabled}
|
|
|
- value={value}
|
|
|
- label={label}
|
|
|
- onChange={(id, e) => {
|
|
|
- onChange(id, e);
|
|
|
- onBlur(id, e);
|
|
|
- // Reset the aggregate to the default (which works across
|
|
|
- // datatypes), otherwise we may send snuba an invalid query
|
|
|
- // (transaction aggregate on events datasource = bad).
|
|
|
- model.setValue('aggregate', DEFAULT_AGGREGATE);
|
|
|
- }}
|
|
|
- choices={[
|
|
|
- [Dataset.ERRORS, DATA_SOURCE_LABELS[Dataset.ERRORS]],
|
|
|
- [Dataset.TRANSACTIONS, DATA_SOURCE_LABELS[Dataset.TRANSACTIONS]],
|
|
|
- ]}
|
|
|
- />
|
|
|
- )}
|
|
|
- </FormField>
|
|
|
- </PanelBody>
|
|
|
- </StyledPanel>
|
|
|
- </Feature>
|
|
|
+ <Panel>
|
|
|
+ <StyledPanelBody>
|
|
|
+ <StyledList symbol="colored-numeric">
|
|
|
+ <ListItem>{t('Select events')}</ListItem>
|
|
|
+ <FormRow>
|
|
|
+ <SelectField
|
|
|
+ name="environment"
|
|
|
+ placeholder={t('All')}
|
|
|
+ style={{
|
|
|
+ ...formElemBaseStyle,
|
|
|
+ minWidth: 250,
|
|
|
+ flex: 1,
|
|
|
+ }}
|
|
|
+ styles={{
|
|
|
+ singleValue: (base: any) => ({
|
|
|
+ ...base,
|
|
|
+ '.all-environment-note': {display: 'none'},
|
|
|
+ }),
|
|
|
+ option: (base: any, state: any) => ({
|
|
|
+ ...base,
|
|
|
+ '.all-environment-note': {
|
|
|
+ ...(!state.isSelected && !state.isFocused
|
|
|
+ ? {color: theme.gray400}
|
|
|
+ : {}),
|
|
|
+ fontSize: theme.fontSizeSmall,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ }}
|
|
|
+ choices={environmentList}
|
|
|
+ isDisabled={disabled || this.state.environments === null}
|
|
|
+ isClearable
|
|
|
+ inline={false}
|
|
|
+ flexibleControlStateSize
|
|
|
+ inFieldLabel={t('Environment: ')}
|
|
|
+ />
|
|
|
+ <Feature requireAll features={['organizations:performance-view']}>
|
|
|
+ <FormField
|
|
|
+ name="datasource"
|
|
|
+ inline={false}
|
|
|
+ style={{
|
|
|
+ ...formElemBaseStyle,
|
|
|
+ minWidth: 300,
|
|
|
+ flex: 2,
|
|
|
+ }}
|
|
|
+ flexibleControlStateSize
|
|
|
+ >
|
|
|
+ {({onChange, onBlur, model}) => {
|
|
|
+ const formDataset = model.getValue('dataset');
|
|
|
+ const formEventTypes = model.getValue('eventTypes');
|
|
|
+ const mappedValue = convertDatasetEventTypesToSource(
|
|
|
+ formDataset,
|
|
|
+ formEventTypes
|
|
|
+ );
|
|
|
+ return (
|
|
|
+ <SelectControl
|
|
|
+ value={mappedValue}
|
|
|
+ inFieldLabel={t('Data Source: ')}
|
|
|
+ onChange={optionObj => {
|
|
|
+ const optionValue = optionObj.value;
|
|
|
+ onChange(optionValue, {});
|
|
|
+ onBlur(optionValue, {});
|
|
|
+ // Reset the aggregate to the default (which works across
|
|
|
+ // datatypes), otherwise we may send snuba an invalid query
|
|
|
+ // (transaction aggregate on events datasource = bad).
|
|
|
+ model.setValue('aggregate', DEFAULT_AGGREGATE);
|
|
|
|
|
|
- <div>
|
|
|
- {/* Contained in the same div for the css sticky overlay */}
|
|
|
- {this.props.thresholdChart}
|
|
|
- <StyledPanel>
|
|
|
- <PanelHeader>{t('Alert Conditions')}</PanelHeader>
|
|
|
- <PanelBody>
|
|
|
- <FormField name="query" inline={false}>
|
|
|
+ // set the value of the dataset and event type from data source
|
|
|
+ const {dataset, eventTypes} =
|
|
|
+ DATA_SOURCE_TO_SET_AND_EVENT_TYPES[optionValue] ?? {};
|
|
|
+ model.setValue('dataset', dataset);
|
|
|
+ model.setValue('eventTypes', eventTypes);
|
|
|
+ }}
|
|
|
+ options={[
|
|
|
+ {
|
|
|
+ label: t('Errors'),
|
|
|
+ options: [
|
|
|
+ {
|
|
|
+ value: Datasource.ERROR_DEFAULT,
|
|
|
+ label: DATA_SOURCE_LABELS[Datasource.ERROR_DEFAULT],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: Datasource.DEFAULT,
|
|
|
+ label: DATA_SOURCE_LABELS[Datasource.DEFAULT],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: Datasource.ERROR,
|
|
|
+ label: DATA_SOURCE_LABELS[Datasource.ERROR],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: t('Transactions'),
|
|
|
+ options: [
|
|
|
+ {
|
|
|
+ value: Datasource.TRANSACTION,
|
|
|
+ label: DATA_SOURCE_LABELS[Datasource.TRANSACTION],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ]}
|
|
|
+ isDisabled={disabled}
|
|
|
+ required
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ </FormField>
|
|
|
+ </Feature>
|
|
|
+ <FormField
|
|
|
+ name="query"
|
|
|
+ inline={false}
|
|
|
+ style={{
|
|
|
+ ...formElemBaseStyle,
|
|
|
+ flex: '6 0 700px',
|
|
|
+ }}
|
|
|
+ flexibleControlStateSize
|
|
|
+ >
|
|
|
{({onChange, onBlur, onKeyDown, initialData, model}) => (
|
|
|
<SearchContainer>
|
|
|
- <SearchLabel>{t('Filter')}</SearchLabel>
|
|
|
<StyledSearchBar
|
|
|
defaultQuery={initialData?.query ?? ''}
|
|
|
- inlineLabel={
|
|
|
- <Tooltip
|
|
|
- title={t(
|
|
|
- 'Metric alerts are automatically filtered to your data source'
|
|
|
- )}
|
|
|
- >
|
|
|
- <SearchEventTypeNote>
|
|
|
- {DATASET_EVENT_TYPE_FILTERS[model.getValue('dataset')]}
|
|
|
- </SearchEventTypeNote>
|
|
|
- </Tooltip>
|
|
|
- }
|
|
|
omitTags={['event.type']}
|
|
|
disabled={disabled}
|
|
|
useFormWrapper={false}
|
|
|
organization={organization}
|
|
|
+ placeholder={
|
|
|
+ model.getValue('dataset') === 'events'
|
|
|
+ ? t('Filter events by level, message, or other properties...')
|
|
|
+ : t('Filter transactions by URL, tags, and other properties...')
|
|
|
+ }
|
|
|
onChange={onChange}
|
|
|
onKeyDown={e => {
|
|
|
/**
|
|
@@ -184,90 +261,83 @@ class RuleConditionsForm extends React.PureComponent<Props, State> {
|
|
|
</SearchContainer>
|
|
|
)}
|
|
|
</FormField>
|
|
|
+ </FormRow>
|
|
|
+ <ListItem>{t('Choose a metric')}</ListItem>
|
|
|
+ <FormRow>
|
|
|
<MetricField
|
|
|
name="aggregate"
|
|
|
- label={t('Metric')}
|
|
|
+ help={null}
|
|
|
organization={organization}
|
|
|
disabled={disabled}
|
|
|
- required
|
|
|
- />
|
|
|
- <SelectField
|
|
|
- name="timeWindow"
|
|
|
- label={t('Time Window')}
|
|
|
- help={
|
|
|
- <React.Fragment>
|
|
|
- <div>{t('The time window over which the Metric is evaluated')}</div>
|
|
|
- <div>
|
|
|
- {t(
|
|
|
- 'Note: Triggers are evaluated every minute regardless of this value.'
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </React.Fragment>
|
|
|
- }
|
|
|
- choices={Object.entries(TIME_WINDOW_MAP)}
|
|
|
- required
|
|
|
- isDisabled={disabled}
|
|
|
- getValue={value => Number(value)}
|
|
|
- setValue={value => `${value}`}
|
|
|
- />
|
|
|
- <SelectField
|
|
|
- name="environment"
|
|
|
- label={t('Environment')}
|
|
|
- placeholder={t('All Environments')}
|
|
|
- help={t('Choose which environment events must match')}
|
|
|
- styles={{
|
|
|
- singleValue: (base: any) => ({
|
|
|
- ...base,
|
|
|
- '.all-environment-note': {display: 'none'},
|
|
|
- }),
|
|
|
- option: (base: any, state: any) => ({
|
|
|
- ...base,
|
|
|
- '.all-environment-note': {
|
|
|
- ...(!state.isSelected && !state.isFocused
|
|
|
- ? {color: theme.gray400}
|
|
|
- : {}),
|
|
|
- fontSize: theme.fontSizeSmall,
|
|
|
- },
|
|
|
- }),
|
|
|
+ style={{
|
|
|
+ ...formElemBaseStyle,
|
|
|
}}
|
|
|
- choices={environmentList}
|
|
|
- isDisabled={disabled || this.state.environments === null}
|
|
|
- isClearable
|
|
|
+ inline={false}
|
|
|
+ flexibleControlStateSize
|
|
|
+ columnWidth={250}
|
|
|
+ inFieldLabels
|
|
|
+ required
|
|
|
/>
|
|
|
- </PanelBody>
|
|
|
- </StyledPanel>
|
|
|
- </div>
|
|
|
- </React.Fragment>
|
|
|
+ <FormRowText>{t('over a')}</FormRowText>
|
|
|
+ <Tooltip
|
|
|
+ title={t('Triggers are evaluated every minute regardless of this value.')}
|
|
|
+ >
|
|
|
+ <SelectField
|
|
|
+ name="timeWindow"
|
|
|
+ style={{
|
|
|
+ ...formElemBaseStyle,
|
|
|
+ flex: 1,
|
|
|
+ minWidth: 180,
|
|
|
+ }}
|
|
|
+ choices={Object.entries(TIME_WINDOW_MAP)}
|
|
|
+ required
|
|
|
+ isDisabled={disabled}
|
|
|
+ getValue={value => Number(value)}
|
|
|
+ setValue={value => `${value}`}
|
|
|
+ inline={false}
|
|
|
+ flexibleControlStateSize
|
|
|
+ />
|
|
|
+ </Tooltip>
|
|
|
+ </FormRow>
|
|
|
+ </StyledList>
|
|
|
+ {this.props.thresholdChart}
|
|
|
+ </StyledPanelBody>
|
|
|
+ </Panel>
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const StyledPanel = styled(Panel)`
|
|
|
- /* Sticky graph panel cannot have margin-bottom */
|
|
|
- margin-top: ${space(2)};
|
|
|
+const StyledPanelBody = styled(PanelBody)`
|
|
|
+ ol,
|
|
|
+ h4 {
|
|
|
+ margin-bottom: ${space(1)};
|
|
|
+ }
|
|
|
`;
|
|
|
|
|
|
const SearchContainer = styled('div')`
|
|
|
display: flex;
|
|
|
`;
|
|
|
|
|
|
-const SearchLabel = styled(FieldLabel)`
|
|
|
- align-items: center;
|
|
|
- margin-right: ${space(1)};
|
|
|
-`;
|
|
|
-
|
|
|
const StyledSearchBar = styled(SearchBar)`
|
|
|
flex-grow: 1;
|
|
|
`;
|
|
|
|
|
|
-const SearchEventTypeNote = styled('div')`
|
|
|
- font: ${p => p.theme.fontSizeExtraSmall} ${p => p.theme.text.familyMono};
|
|
|
- color: ${p => p.theme.subText};
|
|
|
- background: ${p => p.theme.backgroundSecondary};
|
|
|
- border-radius: 2px;
|
|
|
- padding: ${space(0.5)} ${space(0.75)};
|
|
|
- margin: 0 ${space(0.5)} 0 ${space(1)};
|
|
|
- user-select: none;
|
|
|
+const StyledList = styled(List)`
|
|
|
+ padding: ${space(3)} ${space(3)} 0 ${space(3)};
|
|
|
+`;
|
|
|
+
|
|
|
+const FormRow = styled('div')`
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: flex-end;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ margin-bottom: ${space(2)};
|
|
|
+`;
|
|
|
+
|
|
|
+const FormRowText = styled('div')`
|
|
|
+ padding: ${space(0.5)};
|
|
|
+ /* Match the height of the select controls */
|
|
|
+ line-height: 36px;
|
|
|
`;
|
|
|
|
|
|
-export default RuleConditionsForm;
|
|
|
+export default RuleConditionsFormWithGuiFilters;
|