queryBuilder.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import uniqBy from 'lodash/uniqBy';
  4. import type {SelectOption} from 'sentry/components/compactSelect';
  5. import {CompactSelect} from 'sentry/components/compactSelect';
  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 type {MetricMeta, MetricsOperation, MRI} from 'sentry/types';
  11. import {trackAnalytics} from 'sentry/utils/analytics';
  12. import {
  13. isAllowedOp,
  14. isCustomMetric,
  15. isSpanMeasurement,
  16. isSpanSelfTime,
  17. isTransactionDuration,
  18. isTransactionMeasurement,
  19. } from 'sentry/utils/metrics';
  20. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  21. import {formatMRI} from 'sentry/utils/metrics/mri';
  22. import type {MetricsQuery} from 'sentry/utils/metrics/types';
  23. import {useBreakpoints} from 'sentry/utils/metrics/useBreakpoints';
  24. import {useIncrementQueryMetric} from 'sentry/utils/metrics/useIncrementQueryMetric';
  25. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  26. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  27. import {middleEllipsis} from 'sentry/utils/middleEllipsis';
  28. import useKeyPress from 'sentry/utils/useKeyPress';
  29. import useOrganization from 'sentry/utils/useOrganization';
  30. import usePageFilters from 'sentry/utils/usePageFilters';
  31. import {MetricSearchBar} from 'sentry/views/metrics/metricSearchBar';
  32. type QueryBuilderProps = {
  33. metricsQuery: MetricsQuery;
  34. onChange: (data: Partial<MetricsQuery>) => void;
  35. projects: number[];
  36. };
  37. const isVisibleTransactionMetric = (metric: MetricMeta) =>
  38. isTransactionDuration(metric) || isTransactionMeasurement(metric);
  39. const isVisibleSpanMetric = (metric: MetricMeta) =>
  40. isSpanSelfTime(metric) || isSpanMeasurement(metric);
  41. const isShownByDefault = (metric: MetricMeta) =>
  42. isCustomMetric(metric) ||
  43. isVisibleTransactionMetric(metric) ||
  44. isVisibleSpanMetric(metric);
  45. function getOpsForMRI(mri: MRI, meta: MetricMeta[]) {
  46. return meta.find(metric => metric.mri === mri)?.operations.filter(isAllowedOp) ?? [];
  47. }
  48. function useMriMode() {
  49. const [mriMode, setMriMode] = useState(false);
  50. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  51. useEffect(() => {
  52. if (mriModeKeyPressed) {
  53. setMriMode(value => !value);
  54. }
  55. // eslint-disable-next-line react-hooks/exhaustive-deps
  56. }, [mriModeKeyPressed]);
  57. return mriMode;
  58. }
  59. export const QueryBuilder = memo(function QueryBuilder({
  60. metricsQuery,
  61. projects,
  62. onChange,
  63. }: QueryBuilderProps) {
  64. const organization = useOrganization();
  65. const pageFilters = usePageFilters();
  66. const {data: meta} = useMetricsMeta(pageFilters.selection);
  67. const breakpoints = useBreakpoints();
  68. const mriMode = useMriMode();
  69. const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
  70. metricsQuery.mri,
  71. {
  72. projects,
  73. }
  74. );
  75. const tags = useMemo(() => {
  76. return uniqBy(tagsData, 'key');
  77. }, [tagsData]);
  78. const displayedMetrics = useMemo(() => {
  79. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  80. return meta
  81. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  82. .sort(metric => (isSelected(metric) ? -1 : 1));
  83. }, [meta, metricsQuery.mri]);
  84. const selectedMeta = useMemo(() => {
  85. return meta.find(metric => metric.mri === metricsQuery.mri);
  86. }, [meta, metricsQuery.mri]);
  87. const incrementQueryMetric = useIncrementQueryMetric({
  88. ...metricsQuery,
  89. });
  90. const handleMRIChange = useCallback(
  91. ({value}) => {
  92. const availableOps = getOpsForMRI(value, meta);
  93. const selectedOp = availableOps.includes(
  94. (metricsQuery.op ?? '') as MetricsOperation
  95. )
  96. ? metricsQuery.op
  97. : availableOps?.[0];
  98. const queryChanges = {
  99. mri: value,
  100. op: selectedOp,
  101. groupBy: undefined,
  102. };
  103. trackAnalytics('ddm.widget.metric', {organization});
  104. incrementQueryMetric('ddm.widget.metric', queryChanges);
  105. onChange(queryChanges);
  106. },
  107. [incrementQueryMetric, meta, metricsQuery.op, onChange, organization]
  108. );
  109. const handleOpChange = useCallback(
  110. ({value}) => {
  111. trackAnalytics('ddm.widget.operation', {organization});
  112. incrementQueryMetric('ddm.widget.operation', {op: value});
  113. onChange({
  114. op: value,
  115. });
  116. },
  117. [incrementQueryMetric, onChange, organization]
  118. );
  119. const handleGroupByChange = useCallback(
  120. (options: SelectOption<string>[]) => {
  121. trackAnalytics('ddm.widget.group', {organization});
  122. incrementQueryMetric('ddm.widget.group', {
  123. groupBy: options.map(o => o.value),
  124. });
  125. onChange({
  126. groupBy: options.map(o => o.value),
  127. });
  128. },
  129. [incrementQueryMetric, onChange, organization]
  130. );
  131. const handleQueryChange = useCallback(
  132. (query: string) => {
  133. trackAnalytics('ddm.widget.filter', {organization});
  134. incrementQueryMetric('ddm.widget.filter', {query});
  135. onChange({query});
  136. },
  137. [incrementQueryMetric, onChange, organization]
  138. );
  139. const mriOptions = useMemo(
  140. () =>
  141. displayedMetrics.map<SelectOption<MRI>>(metric => ({
  142. label: mriMode ? metric.mri : formatMRI(metric.mri),
  143. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  144. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  145. value: metric.mri,
  146. trailingItems: mriMode ? undefined : (
  147. <Fragment>
  148. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  149. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  150. </Fragment>
  151. ),
  152. })),
  153. [displayedMetrics, mriMode]
  154. );
  155. const projectIdStrings = useMemo(() => projects.map(String), [projects]);
  156. return (
  157. <QueryBuilderWrapper>
  158. <FlexBlock>
  159. <MetricSelect
  160. searchable
  161. sizeLimit={100}
  162. size="md"
  163. triggerLabel={middleEllipsis(
  164. formatMRI(metricsQuery.mri) ?? '',
  165. breakpoints.large ? (breakpoints.xlarge ? 70 : 45) : 30,
  166. /\.|-|_/
  167. )}
  168. options={mriOptions}
  169. value={metricsQuery.mri}
  170. onChange={handleMRIChange}
  171. shouldUseVirtualFocus
  172. />
  173. <FlexBlock>
  174. <OpSelect
  175. size="md"
  176. triggerProps={{prefix: t('Agg')}}
  177. options={
  178. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  179. label: op,
  180. value: op,
  181. })) ?? []
  182. }
  183. triggerLabel={metricsQuery.op}
  184. disabled={!selectedMeta}
  185. value={metricsQuery.op}
  186. onChange={handleOpChange}
  187. />
  188. <CompactSelect
  189. multiple
  190. size="md"
  191. triggerProps={{prefix: t('Group by')}}
  192. options={tags.map(tag => ({
  193. label: tag.key,
  194. value: tag.key,
  195. trailingItems: (
  196. <Fragment>
  197. {tag.key === 'release' && <IconReleases size="xs" />}
  198. {tag.key === 'transaction' && <IconLightning size="xs" />}
  199. </Fragment>
  200. ),
  201. }))}
  202. disabled={!metricsQuery.mri || tagsIsLoading}
  203. value={metricsQuery.groupBy}
  204. onChange={handleGroupByChange}
  205. />
  206. </FlexBlock>
  207. </FlexBlock>
  208. <SearchBarWrapper>
  209. <MetricSearchBar
  210. mri={metricsQuery.mri}
  211. disabled={!metricsQuery.mri}
  212. onChange={handleQueryChange}
  213. query={metricsQuery.query}
  214. projectIds={projectIdStrings}
  215. blockedTags={selectedMeta?.blockingStatus?.flatMap(s => s.blockedTags) ?? []}
  216. />
  217. </SearchBarWrapper>
  218. </QueryBuilderWrapper>
  219. );
  220. });
  221. const QueryBuilderWrapper = styled('div')`
  222. display: flex;
  223. flex-grow: 1;
  224. gap: ${space(1)};
  225. flex-wrap: wrap;
  226. `;
  227. const FlexBlock = styled('div')`
  228. display: flex;
  229. gap: ${space(1)};
  230. flex-wrap: wrap;
  231. `;
  232. const MetricSelect = styled(CompactSelect)`
  233. min-width: 200px;
  234. & > button {
  235. width: 100%;
  236. }
  237. `;
  238. const OpSelect = styled(CompactSelect)`
  239. width: 128px;
  240. min-width: min-content;
  241. & > button {
  242. width: 100%;
  243. }
  244. `;
  245. const SearchBarWrapper = styled('div')`
  246. flex: 1;
  247. min-width: 200px;
  248. `;