teamMisery.tsx 9.4 KB

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