projectsPreviewTable.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import {Fragment, memo, useCallback, useMemo, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {hasEveryAccess} from 'sentry/components/acl/access';
  5. import {LinkButton} from 'sentry/components/button';
  6. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  7. import {InputGroup} from 'sentry/components/inputGroup';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import {PanelTable} from 'sentry/components/panels/panelTable';
  10. import {Tooltip} from 'sentry/components/tooltip';
  11. import {IconArrow, IconChevron, IconSettings} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {Project} from 'sentry/types/project';
  15. import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
  16. import {formatNumberWithDynamicDecimalPoints} from 'sentry/utils/number/formatNumberWithDynamicDecimalPoints';
  17. import oxfordizeArray from 'sentry/utils/oxfordizeArray';
  18. import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
  21. import {balanceSampleRate} from 'sentry/views/settings/dynamicSampling/utils/rebalancing';
  22. import {useProjectSampleCounts} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
  23. const {useFormField} = organizationSamplingForm;
  24. interface Props {
  25. period: '24h' | '30d';
  26. }
  27. export function ProjectsPreviewTable({period}: Props) {
  28. const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
  29. const {value: targetSampleRate, initialValue: initialTargetSampleRate} =
  30. useFormField('targetSampleRate');
  31. const {data, isPending, isError, refetch} = useProjectSampleCounts({period});
  32. const debouncedTargetSampleRate = useDebouncedValue(
  33. targetSampleRate,
  34. // For longer lists we debounce the input to avoid too many re-renders
  35. data.length > 100 ? 200 : 0
  36. );
  37. const {balancedItems} = useMemo(() => {
  38. const targetRate = Math.min(100, Math.max(0, Number(debouncedTargetSampleRate) || 0));
  39. return balanceSampleRate({
  40. targetSampleRate: targetRate / 100,
  41. items: data,
  42. });
  43. }, [debouncedTargetSampleRate, data]);
  44. const initialSampleRatesBySlug = useMemo(() => {
  45. const targetRate = Math.min(100, Math.max(0, Number(initialTargetSampleRate) || 0));
  46. const {balancedItems: initialBalancedItems} = balanceSampleRate({
  47. targetSampleRate: targetRate / 100,
  48. items: data,
  49. });
  50. return initialBalancedItems.reduce((acc, item) => {
  51. acc[item.id] = item.sampleRate;
  52. return acc;
  53. }, {});
  54. }, [initialTargetSampleRate, data]);
  55. const handleTableSort = useCallback(() => {
  56. setTableSort(value => (value === 'asc' ? 'desc' : 'asc'));
  57. }, []);
  58. if (isError) {
  59. return <LoadingError onRetry={refetch} />;
  60. }
  61. return (
  62. <ProjectsTable
  63. stickyHeaders
  64. emptyMessage={t('No active projects found in the selected period.')}
  65. isEmpty={!data.length}
  66. isLoading={isPending}
  67. headers={[
  68. t('Project'),
  69. <SortableHeader key="spans" onClick={handleTableSort}>
  70. {t('Spans')}
  71. <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
  72. </SortableHeader>,
  73. <RateHeaderCell key="projectedRate">{t('Projected Rate')}</RateHeaderCell>,
  74. ]}
  75. >
  76. {balancedItems
  77. .toSorted((a, b) => {
  78. if (tableSort === 'asc') {
  79. return a.count - b.count;
  80. }
  81. return b.count - a.count;
  82. })
  83. .map(({id, project, count, ownCount, sampleRate, subProjects}) => (
  84. <TableRow
  85. key={id}
  86. project={project}
  87. count={count}
  88. ownCount={ownCount}
  89. sampleRate={sampleRate}
  90. initialSampleRate={initialSampleRatesBySlug[project.slug]}
  91. subProjects={subProjects}
  92. />
  93. ))}
  94. </ProjectsTable>
  95. );
  96. }
  97. interface SubProject {
  98. count: number;
  99. slug: string;
  100. }
  101. function getSubProjectContent(
  102. ownSlug: string,
  103. subProjects: SubProject[],
  104. isExpanded: boolean
  105. ) {
  106. let subProjectContent: React.ReactNode = t('No distributed traces');
  107. if (subProjects.length > 1) {
  108. const truncatedSubProjects = subProjects.slice(0, MAX_PROJECTS_COLLAPSED);
  109. const overflowCount = subProjects.length - MAX_PROJECTS_COLLAPSED;
  110. const moreTranslation = t('+%d more', overflowCount);
  111. const stringifiedSubProjects =
  112. overflowCount > 0
  113. ? `${truncatedSubProjects.map(p => p.slug).join(', ')}, ${moreTranslation}`
  114. : oxfordizeArray(truncatedSubProjects.map(p => p.slug));
  115. subProjectContent = isExpanded ? (
  116. <Fragment>
  117. <div>{ownSlug}</div>
  118. {subProjects.map(subProject => (
  119. <div key={subProject.slug}>{subProject.slug}</div>
  120. ))}
  121. </Fragment>
  122. ) : (
  123. t('Including spans in ') + stringifiedSubProjects
  124. );
  125. }
  126. return subProjectContent;
  127. }
  128. function getSubSpansContent(
  129. ownCount: number,
  130. subProjects: SubProject[],
  131. isExpanded: boolean
  132. ) {
  133. let subSpansContent: React.ReactNode = '';
  134. if (subProjects.length > 1) {
  135. const subProjectSum = subProjects.reduce(
  136. (acc, subProject) => acc + subProject.count,
  137. 0
  138. );
  139. subSpansContent = isExpanded ? (
  140. <Fragment>
  141. <div>{formatAbbreviatedNumber(ownCount, 2)}</div>
  142. {subProjects.map(subProject => (
  143. <div key={subProject.slug}>{formatAbbreviatedNumber(subProject.count)}</div>
  144. ))}
  145. </Fragment>
  146. ) : (
  147. formatAbbreviatedNumber(subProjectSum)
  148. );
  149. }
  150. return subSpansContent;
  151. }
  152. const MAX_PROJECTS_COLLAPSED = 3;
  153. const TableRow = memo(function TableRow({
  154. project,
  155. count,
  156. ownCount,
  157. sampleRate,
  158. initialSampleRate,
  159. subProjects,
  160. }: {
  161. count: number;
  162. initialSampleRate: number;
  163. ownCount: number;
  164. project: Project;
  165. sampleRate: number;
  166. subProjects: SubProject[];
  167. }) {
  168. const organization = useOrganization();
  169. const [isExpanded, setIsExpanded] = useState(false);
  170. const isExpandable = subProjects.length > 0;
  171. const hasAccess = hasEveryAccess(['project:write'], {organization, project});
  172. const subProjectContent = getSubProjectContent(project.slug, subProjects, isExpanded);
  173. const subSpansContent = getSubSpansContent(ownCount, subProjects, isExpanded);
  174. return (
  175. <Fragment key={project.slug}>
  176. <Cell>
  177. <FirstCellLine data-has-chevron={isExpandable}>
  178. <HiddenButton
  179. disabled={!isExpandable}
  180. aria-label={isExpanded ? t('Collapse') : t('Expand')}
  181. onClick={() => setIsExpanded(value => !value)}
  182. >
  183. {isExpandable && (
  184. <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
  185. )}
  186. <ProjectBadge project={project} disableLink avatarSize={16} />
  187. </HiddenButton>
  188. {hasAccess && (
  189. <SettingsButton
  190. title={t('Open Project Settings')}
  191. aria-label={t('Open Project Settings')}
  192. size="xs"
  193. priority="link"
  194. icon={<IconSettings />}
  195. to={`/organizations/${organization.slug}/settings/projects/${project.slug}/performance`}
  196. />
  197. )}
  198. </FirstCellLine>
  199. <SubProjects>{subProjectContent}</SubProjects>
  200. </Cell>
  201. <Cell>
  202. <FirstCellLine data-align="right">{formatAbbreviatedNumber(count)}</FirstCellLine>
  203. <SubSpans>{subSpansContent}</SubSpans>
  204. </Cell>
  205. <Cell>
  206. <FirstCellLine>
  207. <Tooltip
  208. title={t('To edit project sample rates, switch to manual sampling mode.')}
  209. >
  210. <InputGroup
  211. css={css`
  212. width: 150px;
  213. `}
  214. >
  215. <InputGroup.Input
  216. disabled
  217. size="sm"
  218. value={formatNumberWithDynamicDecimalPoints(sampleRate * 100, 3)}
  219. />
  220. <InputGroup.TrailingItems>
  221. <TrailingPercent>%</TrailingPercent>
  222. </InputGroup.TrailingItems>
  223. </InputGroup>
  224. </Tooltip>
  225. </FirstCellLine>
  226. {sampleRate !== initialSampleRate && (
  227. <SmallPrint>
  228. {t(
  229. 'previous: %s%%',
  230. formatNumberWithDynamicDecimalPoints(initialSampleRate * 100, 3)
  231. )}
  232. </SmallPrint>
  233. )}
  234. </Cell>
  235. </Fragment>
  236. );
  237. });
  238. const ProjectsTable = styled(PanelTable)`
  239. grid-template-columns: 1fr max-content max-content;
  240. `;
  241. const SmallPrint = styled('span')`
  242. font-size: ${p => p.theme.fontSizeExtraSmall};
  243. color: ${p => p.theme.subText};
  244. line-height: 1.5;
  245. text-align: right;
  246. `;
  247. const SortableHeader = styled('button')`
  248. border: none;
  249. background: none;
  250. cursor: pointer;
  251. display: flex;
  252. text-transform: inherit;
  253. align-items: center;
  254. gap: ${space(0.5)};
  255. `;
  256. const RateHeaderCell = styled('div')`
  257. display: flex;
  258. justify-content: space-between;
  259. `;
  260. const Cell = styled('div')`
  261. display: flex;
  262. flex-direction: column;
  263. gap: ${space(0.25)};
  264. `;
  265. const FirstCellLine = styled('div')`
  266. display: flex;
  267. align-items: center;
  268. height: 32px;
  269. & > * {
  270. flex-shrink: 0;
  271. }
  272. &[data-align='right'] {
  273. justify-content: flex-end;
  274. }
  275. &[data-has-chevron='false'] {
  276. padding-left: ${space(2)};
  277. }
  278. `;
  279. const SubProjects = styled('div')`
  280. color: ${p => p.theme.subText};
  281. font-size: ${p => p.theme.fontSizeSmall};
  282. margin-left: ${space(2)};
  283. & > div {
  284. line-height: 2;
  285. margin-right: -${space(2)};
  286. padding-right: ${space(2)};
  287. margin-left: -${space(1)};
  288. padding-left: ${space(1)};
  289. border-top-left-radius: ${p => p.theme.borderRadius};
  290. border-bottom-left-radius: ${p => p.theme.borderRadius};
  291. &:nth-child(odd) {
  292. background: ${p => p.theme.backgroundSecondary};
  293. }
  294. }
  295. `;
  296. const SubSpans = styled('div')`
  297. color: ${p => p.theme.subText};
  298. font-size: ${p => p.theme.fontSizeSmall};
  299. text-align: right;
  300. & > div {
  301. line-height: 2;
  302. margin-left: -${space(2)};
  303. padding-left: ${space(2)};
  304. margin-right: -${space(1)};
  305. padding-right: ${space(1)};
  306. border-top-right-radius: ${p => p.theme.borderRadius};
  307. border-bottom-right-radius: ${p => p.theme.borderRadius};
  308. &:nth-child(odd) {
  309. background: ${p => p.theme.backgroundSecondary};
  310. }
  311. }
  312. `;
  313. const HiddenButton = styled('button')`
  314. background: none;
  315. border: none;
  316. padding: 0;
  317. cursor: pointer;
  318. display: flex;
  319. align-items: center;
  320. /* Overwrite the platform icon's cursor style */
  321. &:not([disabled]) img {
  322. cursor: pointer;
  323. }
  324. `;
  325. const StyledIconChevron = styled(IconChevron)`
  326. height: 12px;
  327. width: 12px;
  328. margin-right: ${space(0.5)};
  329. color: ${p => p.theme.subText};
  330. `;
  331. const SettingsButton = styled(LinkButton)`
  332. margin-left: ${space(0.5)};
  333. color: ${p => p.theme.subText};
  334. visibility: hidden;
  335. &:focus {
  336. visibility: visible;
  337. }
  338. ${Cell}:hover & {
  339. visibility: visible;
  340. }
  341. `;
  342. const TrailingPercent = styled('strong')`
  343. padding: 0 ${space(0.25)};
  344. `;