projectsTable.tsx 14 KB

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