projectsTable.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. import type React from 'react';
  2. import {Fragment, memo, useCallback, useRef, useState} from 'react';
  3. import {AutoSizer, List, type ListRowRenderer} from 'react-virtualized';
  4. import styled from '@emotion/styled';
  5. import {hasEveryAccess} from 'sentry/components/acl/access';
  6. import {LinkButton} from 'sentry/components/button';
  7. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  8. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  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 oxfordizeArray from 'sentry/utils/oxfordizeArray';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput';
  19. import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
  20. import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
  21. import type {
  22. ProjectionSamplePeriod,
  23. ProjectSampleCount,
  24. } from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
  25. type SubProject = ProjectSampleCount['subProjects'][number];
  26. interface ProjectItem {
  27. count: number;
  28. initialSampleRate: string;
  29. ownCount: number;
  30. project: Project;
  31. sampleRate: string;
  32. subProjects: SubProject[];
  33. error?: string;
  34. }
  35. interface Props {
  36. emptyMessage: React.ReactNode;
  37. isLoading: boolean;
  38. items: ProjectItem[];
  39. period: ProjectionSamplePeriod;
  40. rateHeader: React.ReactNode;
  41. canEdit?: boolean;
  42. inputTooltip?: string;
  43. onChange?: (projectId: string, value: string) => void;
  44. }
  45. const COLUMN_COUNT = 4;
  46. const BASE_ROW_HEIGHT = 68;
  47. export function ProjectsTable({
  48. items,
  49. inputTooltip,
  50. canEdit,
  51. rateHeader,
  52. onChange,
  53. period,
  54. isLoading,
  55. emptyMessage,
  56. }: Props) {
  57. const hasAccess = useHasDynamicSamplingWriteAccess();
  58. const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
  59. // We store the expanded items at list level to allow calculating item height
  60. const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
  61. const listRef = useRef<List | null>(null);
  62. const handleToggleItemExpanded = useCallback((id: string) => {
  63. setExpandedItems(value => {
  64. const newSet = new Set(value);
  65. if (newSet.has(id)) {
  66. newSet.delete(id);
  67. } else {
  68. newSet.add(id);
  69. }
  70. return newSet;
  71. });
  72. listRef.current?.recomputeRowHeights();
  73. }, []);
  74. const handleTableSort = useCallback(() => {
  75. setTableSort(value => (value === 'asc' ? 'desc' : 'asc'));
  76. listRef.current?.recomputeRowHeights();
  77. }, []);
  78. const itemsWithExpanded = items.map(item => ({
  79. ...item,
  80. isExpanded: expandedItems.has(item.project.id),
  81. }));
  82. const sortedItems = itemsWithExpanded.toSorted((a: any, b: any) => {
  83. if (a.count === b.count) {
  84. return a.project.slug.localeCompare(b.project.slug);
  85. }
  86. if (tableSort === 'asc') {
  87. return a.count - b.count;
  88. }
  89. return b.count - a.count;
  90. });
  91. const rowRenderer: ListRowRenderer = ({key, index, style}) => {
  92. const item = sortedItems[index];
  93. if (!item) {
  94. return null;
  95. }
  96. return (
  97. <TableRow
  98. key={key}
  99. style={style}
  100. canEdit={canEdit}
  101. onChange={onChange}
  102. inputTooltip={inputTooltip}
  103. toggleExpanded={handleToggleItemExpanded}
  104. hasAccess={hasAccess}
  105. {...item}
  106. />
  107. );
  108. };
  109. const estimatedListSize = sortedItems.length * BASE_ROW_HEIGHT;
  110. return (
  111. <Fragment>
  112. <TableHeader>
  113. <HeaderCell>{t('Originating Project')}</HeaderCell>
  114. <SortableHeader type="button" key="spans" onClick={handleTableSort}>
  115. {t('Accepted Spans')}
  116. <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
  117. </SortableHeader>
  118. <HeaderCell data-align="right">
  119. {period === '24h' ? t('Stored Spans (24h)') : t('Stored Spans (30d)')}
  120. </HeaderCell>
  121. <HeaderCell data-align="right">{rateHeader}</HeaderCell>
  122. </TableHeader>
  123. {isLoading && <LoadingIndicator />}
  124. {items.length === 0 && !isLoading && (
  125. <EmptyStateWarning>
  126. <p>{emptyMessage}</p>
  127. </EmptyStateWarning>
  128. )}
  129. {!isLoading && items.length && (
  130. <SizingWrapper style={{height: `${estimatedListSize}px`}}>
  131. <AutoSizer>
  132. {({width, height}) => (
  133. <List
  134. ref={list => (listRef.current = list)}
  135. width={width}
  136. height={height}
  137. rowCount={sortedItems.length}
  138. rowRenderer={rowRenderer}
  139. rowHeight={({index}) =>
  140. sortedItems[index]?.isExpanded
  141. ? BASE_ROW_HEIGHT + (sortedItems[index]?.subProjects.length + 1) * 21
  142. : BASE_ROW_HEIGHT
  143. }
  144. columnCount={COLUMN_COUNT}
  145. />
  146. )}
  147. </AutoSizer>
  148. </SizingWrapper>
  149. )}
  150. </Fragment>
  151. );
  152. }
  153. function getSubProjectContent(
  154. ownSlug: string,
  155. subProjects: SubProject[],
  156. isExpanded: boolean
  157. ) {
  158. let subProjectContent: React.ReactNode = (
  159. <Ellipsis>{t('No distributed traces')}</Ellipsis>
  160. );
  161. if (subProjects.length > 0) {
  162. const truncatedSubProjects = subProjects.slice(0, MAX_PROJECTS_COLLAPSED);
  163. const overflowCount = subProjects.length - MAX_PROJECTS_COLLAPSED;
  164. const moreTranslation = t('+%d more', overflowCount);
  165. const stringifiedSubProjects =
  166. overflowCount > 0
  167. ? `${truncatedSubProjects.map(p => p.project.slug).join(', ')}, ${moreTranslation}`
  168. : oxfordizeArray(truncatedSubProjects.map(p => p.project.slug));
  169. subProjectContent = isExpanded ? (
  170. <Fragment>
  171. <div>{ownSlug}</div>
  172. {subProjects.map(subProject => (
  173. <div key={subProject.project.slug}>{subProject.project.slug}</div>
  174. ))}
  175. </Fragment>
  176. ) : (
  177. <Ellipsis>{t('Including spans in ') + stringifiedSubProjects}</Ellipsis>
  178. );
  179. }
  180. return subProjectContent;
  181. }
  182. function getSubSpansContent(
  183. ownCount: number,
  184. subProjects: SubProject[],
  185. isExpanded: boolean
  186. ) {
  187. let subSpansContent: React.ReactNode = '';
  188. if (subProjects.length > 0) {
  189. const subProjectSum = subProjects.reduce(
  190. (acc, subProject) => acc + subProject.count,
  191. 0
  192. );
  193. subSpansContent = isExpanded ? (
  194. <Fragment>
  195. <div>{formatAbbreviatedNumber(ownCount, 2)}</div>
  196. {subProjects.map(subProject => (
  197. <div key={subProject.project.slug}>
  198. {formatAbbreviatedNumber(subProject.count)}
  199. </div>
  200. ))}
  201. </Fragment>
  202. ) : (
  203. formatAbbreviatedNumber(subProjectSum)
  204. );
  205. }
  206. return subSpansContent;
  207. }
  208. function getStoredSpansContent(
  209. ownCount: number,
  210. subProjects: SubProject[],
  211. sampleRate: number,
  212. isExpanded: boolean
  213. ) {
  214. let subSpansContent: React.ReactNode = '';
  215. if (subProjects.length > 0) {
  216. const subProjectSum = subProjects.reduce(
  217. (acc, subProject) => acc + Math.floor(subProject.count * sampleRate),
  218. 0
  219. );
  220. subSpansContent = isExpanded ? (
  221. <Fragment>
  222. <div>{formatAbbreviatedNumber(Math.floor(ownCount * sampleRate), 2)}</div>
  223. {subProjects.map(subProject => (
  224. <div key={subProject.project.slug}>
  225. {formatAbbreviatedNumber(Math.floor(subProject.count * sampleRate))}
  226. </div>
  227. ))}
  228. </Fragment>
  229. ) : (
  230. formatAbbreviatedNumber(subProjectSum)
  231. );
  232. }
  233. return subSpansContent;
  234. }
  235. const MAX_PROJECTS_COLLAPSED = 3;
  236. const TableRow = memo(function TableRow({
  237. project,
  238. hasAccess,
  239. canEdit,
  240. count,
  241. ownCount,
  242. sampleRate,
  243. initialSampleRate,
  244. isExpanded,
  245. toggleExpanded,
  246. subProjects,
  247. error,
  248. inputTooltip: inputTooltipProp,
  249. onChange,
  250. style,
  251. }: {
  252. count: number;
  253. hasAccess: boolean;
  254. initialSampleRate: string;
  255. isExpanded: boolean;
  256. ownCount: number;
  257. project: Project;
  258. sampleRate: string;
  259. style: React.CSSProperties;
  260. subProjects: SubProject[];
  261. toggleExpanded: (id: string) => void;
  262. canEdit?: boolean;
  263. error?: string;
  264. inputTooltip?: string;
  265. onChange?: (projectId: string, value: string) => void;
  266. }) {
  267. const organization = useOrganization();
  268. const isExpandable = subProjects.length > 0;
  269. const hasProjectAccess = hasEveryAccess(['project:write'], {organization, project});
  270. const subProjectContent = getSubProjectContent(project.slug, subProjects, isExpanded);
  271. const subSpansContent = getSubSpansContent(ownCount, subProjects, isExpanded);
  272. let inputTooltip = inputTooltipProp;
  273. if (!hasAccess) {
  274. inputTooltip = t('You do not have permission to change the sample rate.');
  275. }
  276. const handleChange = useCallback(
  277. (event: React.ChangeEvent<HTMLInputElement>) => {
  278. onChange?.(project.id, event.target.value);
  279. },
  280. [onChange, project.id]
  281. );
  282. const storedSpans = Math.floor(count * parsePercent(sampleRate));
  283. return (
  284. <TableRowWrapper style={style}>
  285. <Cell>
  286. <FirstCellLine data-has-chevron={isExpandable}>
  287. <HiddenButton
  288. type="button"
  289. disabled={!isExpandable}
  290. aria-label={isExpanded ? t('Collapse') : t('Expand')}
  291. onClick={() => {
  292. toggleExpanded(project.id);
  293. }}
  294. >
  295. {isExpandable && (
  296. <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
  297. )}
  298. <ProjectBadge project={project} disableLink avatarSize={16} />
  299. </HiddenButton>
  300. {hasProjectAccess && (
  301. <SettingsButton
  302. tabIndex={-1}
  303. title={t('Open Project Settings')}
  304. aria-label={t('Open Project Settings')}
  305. size="xs"
  306. priority="link"
  307. icon={<IconSettings />}
  308. to={`/organizations/${organization.slug}/settings/projects/${project.slug}/performance`}
  309. />
  310. )}
  311. </FirstCellLine>
  312. <SubProjects data-is-first-column>{subProjectContent}</SubProjects>
  313. </Cell>
  314. <Cell>
  315. <FirstCellLine data-align="right">{formatAbbreviatedNumber(count)}</FirstCellLine>
  316. <SubContent>{subSpansContent}</SubContent>
  317. </Cell>
  318. <Cell>
  319. <FirstCellLine data-align="right">
  320. {formatAbbreviatedNumber(storedSpans)}
  321. </FirstCellLine>
  322. <SubContent data-is-last-column>
  323. {getStoredSpansContent(
  324. ownCount,
  325. subProjects,
  326. parsePercent(sampleRate),
  327. isExpanded
  328. )}
  329. </SubContent>
  330. </Cell>
  331. <Cell>
  332. <FirstCellLine>
  333. <Tooltip disabled={!inputTooltip} title={inputTooltip}>
  334. <PercentInput
  335. type="number"
  336. disabled={!canEdit || !hasAccess}
  337. onChange={handleChange}
  338. size="sm"
  339. value={sampleRate}
  340. />
  341. </Tooltip>
  342. </FirstCellLine>
  343. {error ? (
  344. <ErrorMessage>{error}</ErrorMessage>
  345. ) : sampleRate !== initialSampleRate ? (
  346. <SmallPrint>{t('previous: %s%%', initialSampleRate)}</SmallPrint>
  347. ) : null}
  348. </Cell>
  349. </TableRowWrapper>
  350. );
  351. });
  352. const SizingWrapper = styled('div')`
  353. max-height: 400px;
  354. `;
  355. const SmallPrint = styled('span')`
  356. font-size: ${p => p.theme.fontSizeExtraSmall};
  357. color: ${p => p.theme.subText};
  358. line-height: 1.5;
  359. text-align: right;
  360. `;
  361. const Ellipsis = styled('span')`
  362. display: block;
  363. overflow: hidden;
  364. text-overflow: ellipsis;
  365. white-space: nowrap;
  366. `;
  367. const ErrorMessage = styled('span')`
  368. color: ${p => p.theme.error};
  369. font-size: ${p => p.theme.fontSizeExtraSmall};
  370. line-height: 1.5;
  371. text-align: right;
  372. `;
  373. const SortableHeader = styled('button')`
  374. border: none;
  375. background: none;
  376. cursor: pointer;
  377. display: flex;
  378. text-transform: inherit;
  379. align-items: center;
  380. justify-content: flex-end;
  381. gap: ${space(0.5)};
  382. `;
  383. const TableRowWrapper = styled('div')`
  384. display: grid;
  385. grid-template-columns: 1fr 165px 165px 152px;
  386. overflow: hidden;
  387. &:not(:last-child) {
  388. border-bottom: 1px solid ${p => p.theme.innerBorder};
  389. }
  390. `;
  391. const Cell = styled('div')`
  392. display: flex;
  393. flex-direction: column;
  394. gap: ${space(0.25)};
  395. padding: ${space(1)} ${space(2)};
  396. min-width: 0;
  397. &[data-align='right'] {
  398. align-items: flex-end;
  399. }
  400. `;
  401. const HeaderCell = styled(Cell)`
  402. padding: ${space(2)};
  403. `;
  404. const FirstCellLine = styled('div')`
  405. display: flex;
  406. align-items: center;
  407. height: 32px;
  408. & > * {
  409. flex-shrink: 0;
  410. }
  411. &[data-align='right'] {
  412. justify-content: flex-end;
  413. }
  414. &[data-has-chevron='false'] {
  415. padding-left: ${space(2)};
  416. }
  417. `;
  418. const SubContent = styled('div')`
  419. color: ${p => p.theme.subText};
  420. font-size: ${p => p.theme.fontSizeSmall};
  421. text-align: right;
  422. white-space: nowrap;
  423. & > div {
  424. line-height: 2;
  425. margin-left: -${space(2)};
  426. padding-left: ${space(2)};
  427. margin-right: -${space(2)};
  428. padding-right: ${space(2)};
  429. text-overflow: ellipsis;
  430. overflow: hidden;
  431. &:nth-child(odd) {
  432. background: ${p => p.theme.backgroundSecondary};
  433. }
  434. }
  435. &[data-is-first-column] > div {
  436. margin-left: -${space(1)};
  437. padding-left: ${space(1)};
  438. border-top-left-radius: ${p => p.theme.borderRadius};
  439. border-bottom-left-radius: ${p => p.theme.borderRadius};
  440. }
  441. &[data-is-last-column] > div {
  442. margin-right: -${space(1)};
  443. padding-right: ${space(1)};
  444. border-top-right-radius: ${p => p.theme.borderRadius};
  445. border-bottom-right-radius: ${p => p.theme.borderRadius};
  446. }
  447. `;
  448. const SubProjects = styled(SubContent)`
  449. text-align: left;
  450. margin-left: ${space(2)};
  451. `;
  452. const HiddenButton = styled('button')`
  453. background: none;
  454. border: none;
  455. padding: 0;
  456. cursor: pointer;
  457. display: flex;
  458. align-items: center;
  459. /* Overwrite the platform icon's cursor style */
  460. &:not([disabled]) img {
  461. cursor: pointer;
  462. }
  463. `;
  464. const StyledIconChevron = styled(IconChevron)`
  465. height: 12px;
  466. width: 12px;
  467. margin-right: ${space(0.5)};
  468. color: ${p => p.theme.subText};
  469. `;
  470. const SettingsButton = styled(LinkButton)`
  471. margin-left: ${space(0.5)};
  472. color: ${p => p.theme.subText};
  473. visibility: hidden;
  474. &:focus {
  475. visibility: visible;
  476. }
  477. ${Cell}:hover & {
  478. visibility: visible;
  479. }
  480. `;
  481. const TableHeader = styled(TableRowWrapper)`
  482. color: ${p => p.theme.subText};
  483. font-size: ${p => p.theme.fontSizeSmall};
  484. font-weight: ${p => p.theme.fontWeightBold};
  485. text-transform: uppercase;
  486. border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
  487. background: ${p => p.theme.backgroundSecondary};
  488. white-space: nowrap;
  489. line-height: 1;
  490. height: 45px;
  491. `;