teamMisery.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import type {Location} from 'history';
  5. import {LinkButton} from 'sentry/components/button';
  6. import type {DateTimeObject} from 'sentry/components/charts/utils';
  7. import CollapsePanel, {COLLAPSE_COUNT} from 'sentry/components/collapsePanel';
  8. import Link from 'sentry/components/links/link';
  9. import LoadingError from 'sentry/components/loadingError';
  10. import {PanelTable} from 'sentry/components/panels/panelTable';
  11. import {IconStar} from 'sentry/icons';
  12. import {t, tct} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {Organization, SavedQueryVersions} from 'sentry/types/organization';
  15. import type {Project} from 'sentry/types/project';
  16. import type {TableData, TableDataRow} from 'sentry/utils/discover/discoverQuery';
  17. import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
  18. import EventView from 'sentry/utils/discover/eventView';
  19. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  20. import type {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
  21. import type {ColorOrAlias} from 'sentry/utils/theme';
  22. import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
  23. import {ProjectBadge, ProjectBadgeContainer} from './styles';
  24. import {groupByTrend} from './utils';
  25. type TeamMiseryProps = {
  26. isLoading: boolean;
  27. location: Location;
  28. organization: Organization;
  29. periodTableData: TableData | null;
  30. projects: Project[];
  31. weekTableData: TableData | null;
  32. error?: QueryError | null;
  33. period?: string | null;
  34. };
  35. function TeamMisery({
  36. organization,
  37. location,
  38. projects,
  39. periodTableData,
  40. weekTableData,
  41. isLoading,
  42. period,
  43. error,
  44. }: TeamMiseryProps) {
  45. const miseryRenderer =
  46. periodTableData?.meta &&
  47. getFieldRenderer('user_misery()', periodTableData.meta, false);
  48. // Calculate trend, so we can sort based on it
  49. const sortedTableData = (periodTableData?.data ?? [])
  50. .map(dataRow => {
  51. const weekRow = weekTableData?.data.find(
  52. row => row.project === dataRow.project && row.transaction === dataRow.transaction
  53. );
  54. const trend = weekRow
  55. ? ((dataRow['user_misery()'] as number) - (weekRow['user_misery()'] as number)) *
  56. 100
  57. : null;
  58. return {
  59. ...dataRow,
  60. trend,
  61. } as TableDataRow & {trend: number};
  62. })
  63. .filter(x => x.trend !== null)
  64. .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
  65. const groupedData = groupByTrend(sortedTableData);
  66. if (error) {
  67. return <LoadingError />;
  68. }
  69. return (
  70. <CollapsePanel items={groupedData.length}>
  71. {({isExpanded, showMoreButton}) => (
  72. <Fragment>
  73. <StyledPanelTable
  74. isEmpty={projects.length === 0 || periodTableData?.data?.length === 0}
  75. emptyMessage={t('No key transactions starred by this team')}
  76. emptyAction={
  77. <LinkButton
  78. size="sm"
  79. external
  80. href="https://docs.sentry.io/product/performance/transaction-summary/#starring-key-transactions"
  81. >
  82. {t('Learn More')}
  83. </LinkButton>
  84. }
  85. headers={[
  86. <FlexCenter key="transaction">
  87. <StyledIconStar isSolid color="yellow300" /> {t('Key transaction')}
  88. </FlexCenter>,
  89. t('Project'),
  90. tct('Last [period]', {period}),
  91. t('Last 7 Days'),
  92. <RightAligned key="change">{t('Change')}</RightAligned>,
  93. ]}
  94. isLoading={isLoading}
  95. >
  96. {groupedData.map((dataRow, idx) => {
  97. const project = projects.find(({slug}) => dataRow.project === slug);
  98. const {trend, project: projectId, transaction} = dataRow;
  99. const weekRow = weekTableData?.data.find(
  100. row => row.project === projectId && row.transaction === transaction
  101. );
  102. if (!weekRow || trend === null) {
  103. return null;
  104. }
  105. const periodMisery = miseryRenderer?.(dataRow, {organization, location});
  106. const weekMisery =
  107. weekRow && miseryRenderer?.(weekRow, {organization, location});
  108. const trendValue = Math.round(Math.abs(trend));
  109. if (idx >= COLLAPSE_COUNT && !isExpanded) {
  110. return null;
  111. }
  112. return (
  113. <Fragment key={idx}>
  114. <KeyTransactionTitleWrapper>
  115. <div>
  116. <StyledIconStar isSolid color="yellow300" />
  117. </div>
  118. <TransactionWrapper>
  119. <Link
  120. to={transactionSummaryRouteWithQuery({
  121. orgSlug: organization.slug,
  122. transaction: dataRow.transaction as string,
  123. projectID: project?.id,
  124. query: {query: 'transaction.duration:<15m'},
  125. })}
  126. >
  127. {dataRow.transaction}
  128. </Link>
  129. </TransactionWrapper>
  130. </KeyTransactionTitleWrapper>
  131. <FlexCenter>
  132. <ProjectBadgeContainer>
  133. {project && <ProjectBadge avatarSize={18} project={project} />}
  134. </ProjectBadgeContainer>
  135. </FlexCenter>
  136. <FlexCenter>{periodMisery}</FlexCenter>
  137. <FlexCenter>{weekMisery ?? '\u2014'}</FlexCenter>
  138. <ScoreWrapper>
  139. {trendValue === 0 ? (
  140. <SubText>
  141. {`0\u0025 `}
  142. {t('change')}
  143. </SubText>
  144. ) : (
  145. <TrendText color={trend >= 0 ? 'successText' : 'errorText'}>
  146. {`${trendValue}\u0025 `}
  147. {trend >= 0 ? t('better') : t('worse')}
  148. </TrendText>
  149. )}
  150. </ScoreWrapper>
  151. </Fragment>
  152. );
  153. })}
  154. </StyledPanelTable>
  155. {!isLoading && showMoreButton}
  156. </Fragment>
  157. )}
  158. </CollapsePanel>
  159. );
  160. }
  161. type Props = {
  162. location: Location;
  163. organization: Organization;
  164. projects: Project[];
  165. teamId: string;
  166. end?: string;
  167. period?: string | null;
  168. start?: string;
  169. } & DateTimeObject;
  170. function TeamMiseryWrapper({
  171. organization,
  172. teamId,
  173. projects,
  174. location,
  175. period,
  176. start,
  177. end,
  178. }: Props) {
  179. if (projects.length === 0) {
  180. return (
  181. <TeamMisery
  182. isLoading={false}
  183. organization={organization}
  184. location={location}
  185. projects={[]}
  186. period={period}
  187. periodTableData={{data: []}}
  188. weekTableData={{data: []}}
  189. />
  190. );
  191. }
  192. const commonEventView = {
  193. id: undefined,
  194. query: 'transaction.duration:<15m team_key_transaction:true',
  195. projects: [],
  196. version: 2 as SavedQueryVersions,
  197. orderby: '-tpm',
  198. teams: [Number(teamId)],
  199. fields: [
  200. 'transaction',
  201. 'project',
  202. 'tpm()',
  203. 'count_unique(user)',
  204. 'count_miserable(user)',
  205. 'user_misery()',
  206. ],
  207. };
  208. const periodEventView = EventView.fromSavedQuery({
  209. ...commonEventView,
  210. name: 'periodMisery',
  211. range: period ?? undefined,
  212. start,
  213. end,
  214. });
  215. const weekEventView = EventView.fromSavedQuery({
  216. ...commonEventView,
  217. name: 'weekMisery',
  218. range: '7d',
  219. });
  220. return (
  221. <DiscoverQuery
  222. eventView={periodEventView}
  223. orgSlug={organization.slug}
  224. location={location}
  225. >
  226. {({isLoading, tableData: periodTableData, error}) => (
  227. <DiscoverQuery
  228. eventView={weekEventView}
  229. orgSlug={organization.slug}
  230. location={location}
  231. >
  232. {({isLoading: isWeekLoading, tableData: weekTableData, error: weekError}) => (
  233. <TeamMisery
  234. isLoading={isLoading || isWeekLoading}
  235. organization={organization}
  236. location={location}
  237. projects={projects}
  238. period={period}
  239. periodTableData={periodTableData}
  240. weekTableData={weekTableData}
  241. error={error ?? weekError}
  242. />
  243. )}
  244. </DiscoverQuery>
  245. )}
  246. </DiscoverQuery>
  247. );
  248. }
  249. export default TeamMiseryWrapper;
  250. const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>`
  251. grid-template-columns: 1.25fr 0.5fr 112px 112px 0.25fr;
  252. font-size: ${p => p.theme.fontSizeMedium};
  253. white-space: nowrap;
  254. margin-bottom: 0;
  255. border: 0;
  256. box-shadow: unset;
  257. & > div {
  258. padding: ${space(1)} ${space(2)};
  259. }
  260. ${p =>
  261. p.isEmpty &&
  262. css`
  263. & > div:last-child {
  264. padding: 48px ${space(2)};
  265. }
  266. `}
  267. `;
  268. const FlexCenter = styled('div')`
  269. display: flex;
  270. align-items: center;
  271. `;
  272. const KeyTransactionTitleWrapper = styled('div')`
  273. ${p => p.theme.overflowEllipsis};
  274. display: flex;
  275. align-items: center;
  276. `;
  277. const StyledIconStar = styled(IconStar)`
  278. display: block;
  279. margin-right: ${space(1)};
  280. margin-bottom: ${space(0.5)};
  281. `;
  282. const TransactionWrapper = styled('div')`
  283. ${p => p.theme.overflowEllipsis};
  284. `;
  285. const RightAligned = styled('span')`
  286. text-align: right;
  287. `;
  288. const ScoreWrapper = styled('div')`
  289. display: flex;
  290. align-items: center;
  291. justify-content: flex-end;
  292. text-align: right;
  293. `;
  294. const SubText = styled('div')`
  295. color: ${p => p.theme.subText};
  296. `;
  297. const TrendText = styled('div')<{color: ColorOrAlias}>`
  298. color: ${p => p.theme[p.color]};
  299. `;