queryBuilder.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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, {SearchBarProps} 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 {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 {data: meta, isLoading: isMetaLoading} = 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. // Reset the query data if the selected metric is no longer available
  59. useEffect(() => {
  60. if (
  61. metricsQuery.mri &&
  62. !isMetaLoading &&
  63. !metaArr.find(metric => metric.mri === metricsQuery.mri)
  64. ) {
  65. onChange({mri: '', op: '', groupBy: []});
  66. }
  67. }, [isMetaLoading, metaArr, metricsQuery.mri, onChange]);
  68. if (!meta) {
  69. return null;
  70. }
  71. return (
  72. <QueryBuilderWrapper>
  73. <QueryBuilderRow>
  74. <WrapPageFilterBar>
  75. <CompactSelect
  76. searchable
  77. sizeLimit={100}
  78. triggerProps={{prefix: t('Metric'), size: 'sm'}}
  79. options={metaArr.map(metric => ({
  80. label: mriMode ? metric.mri : metric.name,
  81. value: metric.mri,
  82. trailingItems: mriMode ? undefined : (
  83. <Fragment>
  84. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  85. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  86. </Fragment>
  87. ),
  88. }))}
  89. value={metricsQuery.mri}
  90. onChange={option => {
  91. const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
  92. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  93. ? metricsQuery.op
  94. : availableOps[0];
  95. onChange({
  96. mri: option.value,
  97. op: selectedOp,
  98. groupBy: undefined,
  99. focusedSeries: undefined,
  100. displayType: getWidgetDisplayType(option.value, selectedOp),
  101. });
  102. }}
  103. />
  104. <CompactSelect
  105. triggerProps={{prefix: t('Op'), size: 'sm'}}
  106. options={
  107. meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
  108. label: op,
  109. value: op,
  110. })) ?? []
  111. }
  112. disabled={!metricsQuery.mri}
  113. value={metricsQuery.op}
  114. onChange={option =>
  115. onChange({
  116. op: option.value,
  117. })
  118. }
  119. />
  120. <CompactSelect
  121. multiple
  122. triggerProps={{prefix: t('Group by'), size: 'sm'}}
  123. options={tags.map(tag => ({
  124. label: tag.key,
  125. value: tag.key,
  126. trailingItems: (
  127. <Fragment>
  128. {tag.key === 'release' && <IconReleases size="xs" />}
  129. {tag.key === 'transaction' && <IconLightning size="xs" />}
  130. </Fragment>
  131. ),
  132. }))}
  133. disabled={!metricsQuery.mri}
  134. value={metricsQuery.groupBy}
  135. onChange={options =>
  136. onChange({
  137. groupBy: options.map(o => o.value),
  138. focusedSeries: undefined,
  139. })
  140. }
  141. />
  142. <CompactSelect
  143. triggerProps={{prefix: t('Display'), size: 'sm'}}
  144. value={displayType ?? defaultMetricDisplayType}
  145. options={[
  146. {
  147. value: MetricDisplayType.LINE,
  148. label: t('Line'),
  149. },
  150. {
  151. value: MetricDisplayType.AREA,
  152. label: t('Area'),
  153. },
  154. {
  155. value: MetricDisplayType.BAR,
  156. label: t('Bar'),
  157. },
  158. ]}
  159. onChange={({value}) => {
  160. onChange({displayType: value});
  161. }}
  162. />
  163. </WrapPageFilterBar>
  164. </QueryBuilderRow>
  165. <QueryBuilderRow>
  166. <MetricSearchBar
  167. // TODO(aknaus): clean up projectId type in ddm
  168. projectIds={projects.map(id => id.toString())}
  169. mri={metricsQuery.mri}
  170. disabled={!metricsQuery.mri}
  171. onChange={query => onChange({query})}
  172. query={metricsQuery.query}
  173. />
  174. </QueryBuilderRow>
  175. </QueryBuilderWrapper>
  176. );
  177. }
  178. interface MetricSearchBarProps
  179. extends Omit<Partial<SearchBarProps>, 'tags' | 'projectIds'> {
  180. onChange: (value: string) => void;
  181. projectIds: string[];
  182. disabled?: boolean;
  183. mri?: string;
  184. query?: string;
  185. }
  186. const EMPTY_ARRAY = [];
  187. export function MetricSearchBar({
  188. mri,
  189. disabled,
  190. onChange,
  191. query,
  192. projectIds,
  193. ...props
  194. }: MetricSearchBarProps) {
  195. const org = useOrganization();
  196. const api = useApi();
  197. const {selection} = usePageFilters();
  198. const projectIdNumbers = useMemo(
  199. () => projectIds.map(id => parseInt(id, 10)),
  200. [projectIds]
  201. );
  202. const {data: tags = EMPTY_ARRAY} = useMetricsTags(mri, projectIdNumbers);
  203. const supportedTags: TagCollection = useMemo(
  204. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  205. [tags]
  206. );
  207. // TODO(ddm): try to use useApiQuery here
  208. const getTagValues = useCallback(
  209. async tag => {
  210. const useCase = getUseCaseFromMRI(mri);
  211. const tagsValues = await api.requestPromise(
  212. `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
  213. {
  214. query: {
  215. metric: mri,
  216. useCase,
  217. project: selection.projects,
  218. },
  219. }
  220. );
  221. return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value);
  222. },
  223. [api, mri, org.slug, selection.projects]
  224. );
  225. const handleChange = useCallback(
  226. (value: string, {validSearch} = {validSearch: true}) => {
  227. if (validSearch) {
  228. onChange(value);
  229. }
  230. },
  231. [onChange]
  232. );
  233. return (
  234. <WideSearchBar
  235. disabled={disabled}
  236. maxMenuHeight={220}
  237. organization={org}
  238. onGetTagValues={getTagValues}
  239. supportedTags={supportedTags}
  240. onClose={handleChange}
  241. onSearch={handleChange}
  242. placeholder={t('Filter by tags')}
  243. query={query}
  244. savedSearchType={SavedSearchType.METRIC}
  245. projectIds={projectIdNumbers}
  246. {...props}
  247. />
  248. );
  249. }
  250. function getWidgetDisplayType(
  251. mri: MetricsQuery['mri'],
  252. op: MetricsQuery['op']
  253. ): MetricDisplayType {
  254. if (mri?.startsWith('c') || op === 'count') {
  255. return MetricDisplayType.BAR;
  256. }
  257. return MetricDisplayType.LINE;
  258. }
  259. const QueryBuilderWrapper = styled('div')`
  260. display: flex;
  261. flex-grow: 1;
  262. flex-direction: column;
  263. `;
  264. const QueryBuilderRow = styled('div')`
  265. padding: ${space(1)};
  266. padding-bottom: 0;
  267. `;
  268. const WideSearchBar = styled(SearchBar)`
  269. width: 100%;
  270. opacity: ${p => (p.disabled ? '0.6' : '1')};
  271. `;
  272. const WrapPageFilterBar = styled(PageFilterBar)`
  273. max-width: max-content;
  274. height: auto;
  275. flex-wrap: wrap;
  276. `;