queryBuilder.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {CompactSelect} from 'sentry/components/compactSelect';
  4. import SearchBar from 'sentry/components/events/searchBar';
  5. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  6. import Tag from 'sentry/components/tag';
  7. import {IconLightning, IconReleases} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {MetricsTag, SavedSearchType, TagCollection} from 'sentry/types';
  11. import {
  12. defaultMetricDisplayType,
  13. getReadableMetricType,
  14. getUseCaseFromMri,
  15. isAllowedOp,
  16. MetricDisplayType,
  17. MetricsQuery,
  18. useMetricsMeta,
  19. useMetricsTags,
  20. } from 'sentry/utils/metrics';
  21. import useApi from 'sentry/utils/useApi';
  22. import useKeyPress from 'sentry/utils/useKeyPress';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import usePageFilters from 'sentry/utils/usePageFilters';
  25. import {MetricWidgetProps} from 'sentry/views/ddm/widget';
  26. type QueryBuilderProps = {
  27. displayType: MetricDisplayType; // TODO(ddm): move display type out of the query builder
  28. metricsQuery: Pick<MetricsQuery, 'mri' | 'op' | 'query' | 'groupBy'>;
  29. onChange: (data: Partial<MetricWidgetProps>) => void;
  30. projects: number[];
  31. powerUserMode?: boolean;
  32. };
  33. export function QueryBuilder({
  34. metricsQuery,
  35. projects,
  36. displayType,
  37. powerUserMode,
  38. onChange,
  39. }: QueryBuilderProps) {
  40. const meta = useMetricsMeta(projects);
  41. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  42. const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names
  43. useEffect(() => {
  44. if (mriModeKeyPressed && !powerUserMode) {
  45. setMriMode(!mriMode);
  46. }
  47. // eslint-disable-next-line react-hooks/exhaustive-deps
  48. }, [mriModeKeyPressed, powerUserMode]);
  49. const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects);
  50. const metaArr = useMemo(() => {
  51. if (mriMode) {
  52. return Object.values(meta);
  53. }
  54. return Object.values(meta).filter(
  55. metric => metric.mri.includes(':custom/') || metric.mri === metricsQuery.mri
  56. );
  57. }, [meta, metricsQuery.mri, mriMode]);
  58. if (!meta) {
  59. return null;
  60. }
  61. return (
  62. <QueryBuilderWrapper>
  63. <QueryBuilderRow>
  64. <WrapPageFilterBar>
  65. <CompactSelect
  66. searchable
  67. sizeLimit={100}
  68. triggerProps={{prefix: t('Metric'), size: 'sm'}}
  69. options={metaArr.map(metric => ({
  70. label: mriMode ? metric.mri : metric.name,
  71. value: metric.mri,
  72. trailingItems: mriMode ? undefined : (
  73. <Fragment>
  74. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  75. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  76. </Fragment>
  77. ),
  78. }))}
  79. value={metricsQuery.mri}
  80. onChange={option => {
  81. const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
  82. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  83. ? metricsQuery.op
  84. : availableOps[0];
  85. onChange({
  86. mri: option.value,
  87. op: selectedOp,
  88. groupBy: undefined,
  89. focusedSeries: undefined,
  90. displayType: getWidgetDisplayType(option.value, selectedOp),
  91. });
  92. }}
  93. />
  94. <CompactSelect
  95. triggerProps={{prefix: t('Op'), size: 'sm'}}
  96. options={
  97. meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
  98. label: op,
  99. value: op,
  100. })) ?? []
  101. }
  102. disabled={!metricsQuery.mri}
  103. value={metricsQuery.op}
  104. onChange={option =>
  105. onChange({
  106. op: option.value,
  107. })
  108. }
  109. />
  110. <CompactSelect
  111. multiple
  112. triggerProps={{prefix: t('Group by'), size: 'sm'}}
  113. options={tags.map(tag => ({
  114. label: tag.key,
  115. value: tag.key,
  116. trailingItems: (
  117. <Fragment>
  118. {tag.key === 'release' && <IconReleases size="xs" />}
  119. {tag.key === 'transaction' && <IconLightning size="xs" />}
  120. </Fragment>
  121. ),
  122. }))}
  123. disabled={!metricsQuery.mri}
  124. value={metricsQuery.groupBy}
  125. onChange={options =>
  126. onChange({
  127. groupBy: options.map(o => o.value),
  128. focusedSeries: undefined,
  129. })
  130. }
  131. />
  132. <CompactSelect
  133. triggerProps={{prefix: t('Display'), size: 'sm'}}
  134. value={displayType ?? defaultMetricDisplayType}
  135. options={[
  136. {
  137. value: MetricDisplayType.LINE,
  138. label: t('Line'),
  139. },
  140. {
  141. value: MetricDisplayType.AREA,
  142. label: t('Area'),
  143. },
  144. {
  145. value: MetricDisplayType.BAR,
  146. label: t('Bar'),
  147. },
  148. ]}
  149. onChange={({value}) => {
  150. onChange({displayType: value});
  151. }}
  152. />
  153. </WrapPageFilterBar>
  154. </QueryBuilderRow>
  155. <QueryBuilderRow>
  156. <MetricSearchBar
  157. tags={tags}
  158. mri={metricsQuery.mri}
  159. disabled={!metricsQuery.mri}
  160. onChange={query => onChange({query})}
  161. query={metricsQuery.query}
  162. />
  163. </QueryBuilderRow>
  164. </QueryBuilderWrapper>
  165. );
  166. }
  167. type MetricSearchBarProps = {
  168. mri: string;
  169. onChange: (value: string) => void;
  170. tags: MetricsTag[];
  171. disabled?: boolean;
  172. query?: string;
  173. };
  174. function MetricSearchBar({tags, mri, disabled, onChange, query}: MetricSearchBarProps) {
  175. const org = useOrganization();
  176. const api = useApi();
  177. const {selection} = usePageFilters();
  178. const supportedTags: TagCollection = useMemo(
  179. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  180. [tags]
  181. );
  182. // TODO(ddm): try to use useApiQuery here
  183. const getTagValues = useCallback(
  184. async tag => {
  185. const tagsValues = await api.requestPromise(
  186. `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
  187. {
  188. query: {
  189. metric: mri,
  190. useCase: getUseCaseFromMri(mri),
  191. project: selection.projects,
  192. },
  193. }
  194. );
  195. return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value);
  196. },
  197. [api, mri, org.slug, selection.projects]
  198. );
  199. const handleChange = useCallback(
  200. (value: string, {validSearch} = {validSearch: true}) => {
  201. if (validSearch) {
  202. onChange(value);
  203. }
  204. },
  205. [onChange]
  206. );
  207. return (
  208. <WideSearchBar
  209. disabled={disabled}
  210. maxMenuHeight={220}
  211. organization={org}
  212. onGetTagValues={getTagValues}
  213. supportedTags={supportedTags}
  214. onClose={handleChange}
  215. onSearch={handleChange}
  216. placeholder={t('Filter by tags')}
  217. query={query}
  218. savedSearchType={SavedSearchType.METRIC}
  219. />
  220. );
  221. }
  222. function getWidgetDisplayType(
  223. mri: MetricsQuery['mri'],
  224. op: MetricsQuery['op']
  225. ): MetricDisplayType {
  226. if (mri?.startsWith('c') || op === 'count') {
  227. return MetricDisplayType.BAR;
  228. }
  229. return MetricDisplayType.LINE;
  230. }
  231. const QueryBuilderWrapper = styled('div')`
  232. display: flex;
  233. flex-direction: column;
  234. `;
  235. const QueryBuilderRow = styled('div')`
  236. padding: ${space(1)};
  237. padding-bottom: 0;
  238. `;
  239. const WideSearchBar = styled(SearchBar)`
  240. width: 100%;
  241. opacity: ${p => (p.disabled ? '0.6' : '1')};
  242. `;
  243. const WrapPageFilterBar = styled(PageFilterBar)`
  244. max-width: max-content;
  245. height: auto;
  246. flex-wrap: wrap;
  247. `;