mriSelect.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import {memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {ComboBox} from 'sentry/components/comboBox';
  4. import type {ComboBoxOption} from 'sentry/components/comboBox/types';
  5. import {IconWarning} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import type {MetricMeta, MRI} from 'sentry/types/metrics';
  8. import {type Fuse, useFuzzySearch} from 'sentry/utils/fuzzySearch';
  9. import {
  10. isCustomMetric,
  11. isSpanDuration,
  12. isSpanMeasurement,
  13. isTransactionDuration,
  14. isTransactionMeasurement,
  15. } from 'sentry/utils/metrics';
  16. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  17. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  18. import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
  19. import useKeyPress from 'sentry/utils/useKeyPress';
  20. import useProjects from 'sentry/utils/useProjects';
  21. import {MetricListItemDetails} from 'sentry/views/metrics/metricListItemDetails';
  22. type MRISelectProps = {
  23. isLoading: boolean;
  24. metricsMeta: MetricMeta[];
  25. onChange: (mri: MRI) => void;
  26. onOpenMenu: (isOpen: boolean) => void;
  27. onTagClick: (mri: MRI, tag: string) => void;
  28. projects: number[];
  29. value: MRI;
  30. };
  31. const isVisibleTransactionMetric = (metric: MetricMeta) =>
  32. isTransactionDuration(metric) || isTransactionMeasurement(metric);
  33. const isVisibleSpanMetric = (metric: MetricMeta) =>
  34. isSpanDuration(metric) || isSpanMeasurement(metric);
  35. const isShownByDefault = (metric: MetricMeta) =>
  36. isCustomMetric(metric) ||
  37. isVisibleTransactionMetric(metric) ||
  38. isVisibleSpanMetric(metric);
  39. function useMriMode() {
  40. const [mriMode, setMriMode] = useState(false);
  41. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  42. useEffect(() => {
  43. if (mriModeKeyPressed) {
  44. setMriMode(value => !value);
  45. }
  46. // eslint-disable-next-line react-hooks/exhaustive-deps
  47. }, [mriModeKeyPressed]);
  48. return mriMode;
  49. }
  50. /**
  51. * Returns a set of MRIs that have duplicate names but different units
  52. */
  53. export function getMetricsWithDuplicateNames(metrics: MetricMeta[]): Set<MRI> {
  54. const metricNameMap = new Map<string, MRI[]>();
  55. const duplicateNames: string[] = [];
  56. for (const metric of metrics) {
  57. const metricName = parseMRI(metric.mri)?.name;
  58. if (!metricName) {
  59. continue;
  60. }
  61. if (metricNameMap.has(metricName)) {
  62. const mapEntry = metricNameMap.get(metricName);
  63. mapEntry?.push(metric.mri);
  64. duplicateNames.push(metricName);
  65. } else {
  66. metricNameMap.set(metricName, [metric.mri]);
  67. }
  68. }
  69. const duplicateMetrics = new Set<MRI>();
  70. for (const name of duplicateNames) {
  71. const duplicates = metricNameMap.get(name);
  72. if (!duplicates) {
  73. continue;
  74. }
  75. duplicates.forEach(duplicate => duplicateMetrics.add(duplicate));
  76. }
  77. return duplicateMetrics;
  78. }
  79. /**
  80. * Returns a set of MRIs that have duplicate names but different units
  81. */
  82. function useMetricsWithDuplicateNames(metrics: MetricMeta[]): Set<MRI> {
  83. return useMemo(() => {
  84. return getMetricsWithDuplicateNames(metrics);
  85. }, [metrics]);
  86. }
  87. const SEARCH_OPTIONS: Fuse.IFuseOptions<any> = {
  88. keys: ['searchText'],
  89. threshold: 0.2,
  90. ignoreLocation: true,
  91. includeScore: false,
  92. includeMatches: false,
  93. };
  94. function useFilteredMRIs(
  95. metricsMeta: MetricMeta[],
  96. inputValue: string,
  97. mriMode: boolean
  98. ) {
  99. const searchEntries = useMemo(() => {
  100. return metricsMeta.map(metric => {
  101. return {
  102. value: metric.mri,
  103. searchText: mriMode
  104. ? // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  105. `${getReadableMetricType(metric.type)}${metric.mri}`
  106. : // enable search in the full formatted string
  107. formatMRI(metric.mri),
  108. };
  109. });
  110. }, [metricsMeta, mriMode]);
  111. const search = useFuzzySearch(searchEntries, SEARCH_OPTIONS);
  112. return useMemo(() => {
  113. if (!search || !inputValue) {
  114. return new Set(metricsMeta.map(metric => metric.mri));
  115. }
  116. const results = search.search(inputValue);
  117. return new Set(results.map(result => result.item.value));
  118. }, [inputValue, metricsMeta, search]);
  119. }
  120. export const MRISelect = memo(function MRISelect({
  121. projects: projectIds,
  122. onChange,
  123. onTagClick,
  124. onOpenMenu,
  125. metricsMeta,
  126. isLoading,
  127. value,
  128. }: MRISelectProps) {
  129. const {projects} = useProjects();
  130. const mriMode = useMriMode();
  131. const [inputValue, setInputValue] = useState('');
  132. const metricsWithDuplicateNames = useMetricsWithDuplicateNames(metricsMeta);
  133. const filteredMRIs = useFilteredMRIs(metricsMeta, inputValue, mriMode);
  134. const handleFilterOption = useCallback(
  135. (option: ComboBoxOption<MRI>) => {
  136. return filteredMRIs.has(option.value);
  137. },
  138. [filteredMRIs]
  139. );
  140. const selectedProjects = useMemo(
  141. () =>
  142. projects.filter(project =>
  143. projectIds[0] === -1
  144. ? true
  145. : projectIds.length === 0
  146. ? project.isMember
  147. : projectIds.includes(parseInt(project.id, 10))
  148. ),
  149. [projectIds, projects]
  150. );
  151. const displayedMetrics = useMemo(() => {
  152. const isSelected = (metric: MetricMeta) => metric.mri === value;
  153. const result = metricsMeta
  154. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  155. .sort(metric => (isSelected(metric) ? -1 : 1));
  156. // Add the selected metric to the top of the list if it's not already there
  157. if (result[0]?.mri !== value) {
  158. const parsedMri = parseMRI(value)!;
  159. return [
  160. {
  161. mri: value,
  162. type: parsedMri.type,
  163. unit: parsedMri.unit,
  164. operations: [],
  165. projectIds: [],
  166. blockingStatus: [],
  167. } satisfies MetricMeta,
  168. ...result,
  169. ];
  170. }
  171. return result;
  172. }, [metricsMeta, value]);
  173. const handleMRIChange = useCallback(
  174. option => {
  175. onChange(option.value);
  176. },
  177. [onChange]
  178. );
  179. const mriOptions = useMemo(
  180. () =>
  181. displayedMetrics.map<ComboBoxOption<MRI>>(metric => {
  182. const isDuplicateWithDifferentUnit = metricsWithDuplicateNames.has(metric.mri);
  183. const trailingItems: React.ReactNode[] = [];
  184. if (isDuplicateWithDifferentUnit) {
  185. trailingItems.push(<IconWarning key="warning" size="xs" color="yellow400" />);
  186. }
  187. if (parseMRI(metric.mri)?.useCase === 'custom' && !mriMode) {
  188. trailingItems.push(
  189. <CustomMetricInfoText key="text">{t('Custom')}</CustomMetricInfoText>
  190. );
  191. }
  192. return {
  193. label: mriMode
  194. ? metric.mri
  195. : middleEllipsis(formatMRI(metric.mri) ?? '', 46, /\.|-|_/),
  196. value: metric.mri,
  197. details:
  198. metric.projectIds.length > 0 ? (
  199. <MetricListItemDetails
  200. metric={metric}
  201. selectedProjects={selectedProjects}
  202. onTagClick={onTagClick}
  203. isDuplicateWithDifferentUnit={isDuplicateWithDifferentUnit}
  204. />
  205. ) : null,
  206. showDetailsInOverlay: true,
  207. trailingItems,
  208. };
  209. }),
  210. [displayedMetrics, metricsWithDuplicateNames, mriMode, onTagClick, selectedProjects]
  211. );
  212. return (
  213. <MetricComboBox
  214. aria-label={t('Metric')}
  215. filterOption={handleFilterOption}
  216. growingInput
  217. isLoading={isLoading}
  218. loadingMessage={t('Loading metrics...')}
  219. menuSize="sm"
  220. menuWidth="450px"
  221. onChange={handleMRIChange}
  222. onInputChange={setInputValue}
  223. onOpenChange={onOpenMenu}
  224. options={mriOptions}
  225. placeholder={t('Select a metric')}
  226. size="md"
  227. sizeLimit={100}
  228. value={value}
  229. />
  230. );
  231. });
  232. const CustomMetricInfoText = styled('span')`
  233. color: ${p => p.theme.subText};
  234. `;
  235. const MetricComboBox = styled(ComboBox<MRI>)`
  236. min-width: 200px;
  237. max-width: min(500px, 100%);
  238. `;