customMetricsTable.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import Tag from 'sentry/components/badge/tag';
  4. import Link from 'sentry/components/links/link';
  5. import {PanelTable} from 'sentry/components/panels/panelTable';
  6. import SearchBar from 'sentry/components/searchBar';
  7. import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
  8. import {Tooltip} from 'sentry/components/tooltip';
  9. import {IconArrow} from 'sentry/icons';
  10. import {IconWarning} from 'sentry/icons/iconWarning';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {MetricMeta} from 'sentry/types/metrics';
  14. import type {Project} from 'sentry/types/project';
  15. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  16. import {formatMRI, isExtractedCustomMetric} from 'sentry/utils/metrics/mri';
  17. import {useBlockMetric} from 'sentry/utils/metrics/useBlockMetric';
  18. import {useCardinalityLimitedMetricVolume} from 'sentry/utils/metrics/useCardinalityLimitedMetricVolume';
  19. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  20. import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import {useAccess} from 'sentry/views/settings/projectMetrics/access';
  23. import {BlockButton} from 'sentry/views/settings/projectMetrics/blockButton';
  24. import {useSearchQueryParam} from 'sentry/views/settings/projectMetrics/utils/useSearchQueryParam';
  25. type Props = {
  26. project: Project;
  27. };
  28. enum BlockingStatusTab {
  29. ACTIVE = 'active',
  30. DISABLED = 'disabled',
  31. }
  32. type MetricWithCardinality = MetricMeta & {cardinality: number};
  33. export function CustomMetricsTable({project}: Props) {
  34. const [selectedTab, setSelectedTab] = useState(BlockingStatusTab.ACTIVE);
  35. const [query, setQuery] = useSearchQueryParam('metricsQuery');
  36. const metricsMeta = useMetricsMeta(
  37. {projects: [parseInt(project.id, 10)]},
  38. ['custom'],
  39. false
  40. );
  41. const metricsCardinality = useCardinalityLimitedMetricVolume({
  42. projects: [parseInt(project.id, 10)],
  43. });
  44. const isLoading = metricsMeta.isLoading || metricsCardinality.isPending;
  45. const sortedMeta = useMemo(() => {
  46. if (!metricsMeta.data) {
  47. return [];
  48. }
  49. // Do not show internal extracted metrics in this table
  50. const filteredMeta = metricsMeta.data.filter(meta => !isExtractedCustomMetric(meta));
  51. if (!metricsCardinality.data) {
  52. return filteredMeta.map(meta => ({...meta, cardinality: 0}));
  53. }
  54. return filteredMeta
  55. .map(({mri, ...rest}) => {
  56. return {
  57. mri,
  58. cardinality: metricsCardinality.data[mri] ?? 0,
  59. ...rest,
  60. };
  61. })
  62. .sort((a, b) => {
  63. // First sort by cardinality (descending)
  64. if (b.cardinality !== a.cardinality) {
  65. return b.cardinality - a.cardinality;
  66. }
  67. // If cardinality is the same, sort by name (ascending)
  68. return a.mri.localeCompare(b.mri);
  69. }) as MetricWithCardinality[];
  70. }, [metricsCardinality.data, metricsMeta.data]);
  71. const metrics = sortedMeta.filter(
  72. ({mri, type, unit}) =>
  73. mri.includes(query) ||
  74. getReadableMetricType(type).includes(query) ||
  75. unit.includes(query)
  76. );
  77. return (
  78. <Fragment>
  79. <SearchWrapper>
  80. <Title>
  81. <h6>{t('Custom Metrics')}</h6>
  82. </Title>
  83. <SearchBar
  84. placeholder={t('Search Metrics')}
  85. onChange={setQuery}
  86. query={query}
  87. size="sm"
  88. />
  89. </SearchWrapper>
  90. <Tabs value={selectedTab} onChange={setSelectedTab}>
  91. <TabList>
  92. <TabList.Item key={BlockingStatusTab.ACTIVE}>{t('Active')}</TabList.Item>
  93. <TabList.Item key={BlockingStatusTab.DISABLED}>{t('Disabled')}</TabList.Item>
  94. </TabList>
  95. <TabPanels>
  96. <TabPanels.Item key={BlockingStatusTab.ACTIVE}>
  97. <MetricsTable
  98. metrics={metrics.filter(
  99. ({blockingStatus}) => !blockingStatus[0]?.isBlocked
  100. )}
  101. isLoading={isLoading}
  102. query={query}
  103. project={project}
  104. />
  105. </TabPanels.Item>
  106. <TabPanels.Item key={BlockingStatusTab.DISABLED}>
  107. <MetricsTable
  108. metrics={metrics.filter(({blockingStatus}) => blockingStatus[0]?.isBlocked)}
  109. isLoading={isLoading}
  110. query={query}
  111. project={project}
  112. />
  113. </TabPanels.Item>
  114. </TabPanels>
  115. </Tabs>
  116. </Fragment>
  117. );
  118. }
  119. interface MetricsTableProps {
  120. isLoading: boolean;
  121. metrics: MetricWithCardinality[];
  122. project: Project;
  123. query: string;
  124. }
  125. function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
  126. const blockMetricMutation = useBlockMetric(project);
  127. const organization = useOrganization();
  128. const {hasAccess} = useAccess({access: ['project:write'], project});
  129. return (
  130. <MetricsPanelTable
  131. headers={[
  132. <Cell key="metric">
  133. <IconArrow size="xs" direction="down" />
  134. {t('Metric')}
  135. </Cell>,
  136. <Cell right key="type">
  137. {t('Type')}
  138. </Cell>,
  139. <Cell right key="unit">
  140. {t('Unit')}
  141. </Cell>,
  142. <Cell right key="actions">
  143. {t('Actions')}
  144. </Cell>,
  145. ]}
  146. emptyMessage={
  147. query
  148. ? t('No metrics match the query.')
  149. : t('There are no custom metrics to display.')
  150. }
  151. isEmpty={metrics.length === 0}
  152. isLoading={isLoading}
  153. >
  154. {metrics.map(({mri, type, unit, cardinality, blockingStatus}) => {
  155. const isBlocked = blockingStatus[0]?.isBlocked;
  156. const isCardinalityLimited = cardinality > 0;
  157. return (
  158. <Fragment key={mri}>
  159. <Cell>
  160. {isCardinalityLimited && (
  161. <Tooltip
  162. title={t(
  163. 'The tag cardinality of this metric exceeded the limit, causing the data to be dropped.'
  164. )}
  165. >
  166. <StyledIconWarning size="sm" color="yellow300" />
  167. </Tooltip>
  168. )}
  169. <Link
  170. to={`/settings/${organization.slug}/projects/${project.slug}/metrics/${encodeURIComponent(
  171. mri
  172. )}`}
  173. >
  174. {middleEllipsis(formatMRI(mri), 65, /\.|-|_/)}
  175. </Link>
  176. </Cell>
  177. <Cell right>
  178. <Tag>{getReadableMetricType(type)}</Tag>
  179. </Cell>
  180. <Cell right>
  181. <Tag>{unit ?? 'none'}</Tag>
  182. </Cell>
  183. <Cell right>
  184. <BlockButton
  185. size="xs"
  186. hasAccess={hasAccess}
  187. disabled={blockMetricMutation.isPending}
  188. isBlocked={isBlocked}
  189. blockTarget="metric"
  190. onConfirm={() => {
  191. blockMetricMutation.mutate({
  192. mri,
  193. operationType: isBlocked ? 'unblockMetric' : 'blockMetric',
  194. });
  195. }}
  196. />
  197. </Cell>
  198. </Fragment>
  199. );
  200. })}
  201. </MetricsPanelTable>
  202. );
  203. }
  204. const SearchWrapper = styled('div')`
  205. display: flex;
  206. justify-content: space-between;
  207. align-items: flex-start;
  208. gap: ${space(1)};
  209. margin-top: ${space(4)};
  210. margin-bottom: 0;
  211. & > h6 {
  212. margin: 0;
  213. }
  214. `;
  215. const MetricsPanelTable = styled(PanelTable)`
  216. margin-top: ${space(2)};
  217. grid-template-columns: 1fr repeat(3, min-content);
  218. `;
  219. const Cell = styled('div')<{right?: boolean}>`
  220. display: flex;
  221. align-items: center;
  222. align-self: stretch;
  223. gap: ${space(0.5)};
  224. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  225. `;
  226. const StyledIconWarning = styled(IconWarning)`
  227. margin-top: ${space(0.5)};
  228. &:hover {
  229. cursor: pointer;
  230. }
  231. `;
  232. const Title = styled('div')`
  233. display: flex;
  234. align-items: center;
  235. flex-wrap: wrap;
  236. gap: ${space(0.5)};
  237. margin-bottom: ${space(3)};
  238. & > h6 {
  239. margin: 0;
  240. }
  241. `;