metricListItemDetails.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import {Fragment, startTransition, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  4. import LoadingIndicator from 'sentry/components/loadingIndicator';
  5. import {IconWarning} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {MetricMeta, Project} from 'sentry/types';
  9. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  10. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  11. import {
  12. getMetricsTagsQueryKey,
  13. useMetricsTags,
  14. } from 'sentry/utils/metrics/useMetricsTags';
  15. import {useQueryClient} from 'sentry/utils/queryClient';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. const MAX_PROJECTS_TO_SHOW = 3;
  18. const MAX_TAGS_TO_SHOW = 5;
  19. const STANDARD_TAGS = ['release', 'environment', 'transaction'];
  20. export function MetricListItemDetails({
  21. metric,
  22. selectedProjects,
  23. }: {
  24. metric: MetricMeta;
  25. selectedProjects: Project[];
  26. }) {
  27. const organization = useOrganization();
  28. const queryClient = useQueryClient();
  29. const isCustomMetric = parseMRI(metric.mri)?.useCase === 'custom';
  30. const projectIds = useMemo(
  31. () => selectedProjects.map(project => parseInt(project.id, 10)),
  32. [selectedProjects]
  33. );
  34. const [isQueryEnabled, setIsQueryEnabled] = useState(() => {
  35. // We only wnat to disable the query if there is no data in the cache
  36. const queryKey = getMetricsTagsQueryKey(organization, metric.mri, {
  37. projects: projectIds,
  38. });
  39. const data = queryClient.getQueryData(queryKey);
  40. return !!data;
  41. });
  42. const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
  43. // TODO: improve useMetricsTag interface
  44. isQueryEnabled ? metric.mri : undefined,
  45. {
  46. projects: projectIds,
  47. }
  48. );
  49. useEffect(() => {
  50. // Start querying tags after a short delay to avoid querying
  51. // for every metric if a user quickly hovers over them
  52. const timeout = setTimeout(() => {
  53. startTransition(() => setIsQueryEnabled(true));
  54. }, 200);
  55. return () => clearTimeout(timeout);
  56. }, []);
  57. const metricProjects = selectedProjects.filter(project =>
  58. metric.projectIds.includes(parseInt(project.id, 10))
  59. );
  60. const truncatedProjects = metricProjects.slice(0, MAX_PROJECTS_TO_SHOW);
  61. // Display custom tags first, then sort alphabetically
  62. const sortedTags = useMemo(
  63. () =>
  64. tagsData.toSorted((a, b) => {
  65. const aIsStandard = STANDARD_TAGS.includes(a.key);
  66. const bIsStandard = STANDARD_TAGS.includes(b.key);
  67. if (aIsStandard && !bIsStandard) {
  68. return 1;
  69. }
  70. if (!aIsStandard && bIsStandard) {
  71. return -1;
  72. }
  73. return a.key.localeCompare(b.key);
  74. }),
  75. [tagsData]
  76. );
  77. const truncatedTags = sortedTags.slice(0, MAX_TAGS_TO_SHOW);
  78. return (
  79. <DetailsWrapper>
  80. <MetricName>
  81. {/* Add zero width spaces at delimiter characters for nice word breaks */}
  82. {formatMRI(metric.mri).replaceAll(/([\.\/-_])/g, '\u200b$1')}
  83. {!isCustomMetric && (
  84. <SamplingWarning>
  85. <IconWarning color="yellow400" size="xs" />
  86. {t('Prone to client-side sampling')}
  87. </SamplingWarning>
  88. )}
  89. </MetricName>
  90. <DetailsGrid>
  91. <DetailsLabel>{t('Project')}</DetailsLabel>
  92. <DetailsValue>
  93. {truncatedProjects.map(project => (
  94. <ProjectBadge
  95. project={project}
  96. key={project.slug}
  97. avatarSize={12}
  98. disableLink
  99. />
  100. ))}
  101. {metricProjects.length > MAX_PROJECTS_TO_SHOW && (
  102. <span>{t('+%d more', metricProjects.length - MAX_PROJECTS_TO_SHOW)}</span>
  103. )}
  104. </DetailsValue>
  105. <DetailsLabel>{t('Type')}</DetailsLabel>
  106. <DetailsValue>{getReadableMetricType(metric.type)}</DetailsValue>
  107. <DetailsLabel>{t('Unit')}</DetailsLabel>
  108. <DetailsValue>{metric.unit}</DetailsValue>
  109. <DetailsLabel>{t('Tags')}</DetailsLabel>
  110. <DetailsValue>
  111. {tagsIsLoading || !isQueryEnabled ? (
  112. <StyledLoadingIndicator mini size={12} />
  113. ) : truncatedTags.length === 0 ? (
  114. t('(None)')
  115. ) : (
  116. <Fragment>
  117. {truncatedTags.map(tag => tag.key).join(', ')}
  118. {tagsData.length > MAX_TAGS_TO_SHOW && (
  119. <div>{t('+%d more', tagsData.length - MAX_TAGS_TO_SHOW)}</div>
  120. )}
  121. </Fragment>
  122. )}
  123. </DetailsValue>
  124. </DetailsGrid>
  125. </DetailsWrapper>
  126. );
  127. }
  128. const DetailsWrapper = styled('div')`
  129. width: 300px;
  130. line-height: 1.4;
  131. `;
  132. const MetricName = styled('div')`
  133. padding: ${space(0.75)} ${space(1.5)};
  134. word-break: break-word;
  135. `;
  136. const DetailsGrid = styled('div')`
  137. display: grid;
  138. grid-template-columns: max-content 1fr;
  139. & > div:nth-child(4n + 1),
  140. & > div:nth-child(4n + 2) {
  141. background-color: ${p => p.theme.backgroundSecondary};
  142. }
  143. `;
  144. const StyledLoadingIndicator = styled(LoadingIndicator)`
  145. && {
  146. margin: ${space(0.75)} 0 0;
  147. height: 12px;
  148. width: 12px;
  149. }
  150. `;
  151. const DetailsLabel = styled('div')`
  152. color: ${p => p.theme.subText};
  153. padding: ${space(0.75)} ${space(1)} ${space(0.75)} ${space(1.5)};
  154. border-top-left-radius: ${p => p.theme.borderRadius};
  155. border-bottom-left-radius: ${p => p.theme.borderRadius};
  156. `;
  157. const DetailsValue = styled('div')`
  158. white-space: pre-wrap;
  159. padding: ${space(0.75)} ${space(1.5)} ${space(0.75)} ${space(1)};
  160. border-top-right-radius: ${p => p.theme.borderRadius};
  161. border-bottom-right-radius: ${p => p.theme.borderRadius};
  162. min-width: 0;
  163. `;
  164. const SamplingWarning = styled('div')`
  165. display: grid;
  166. gap: ${space(0.25)};
  167. grid-template-columns: max-content 1fr;
  168. font-size: ${p => p.theme.fontSizeSmall};
  169. color: ${p => p.theme.yellow400};
  170. align-items: center;
  171. `;