teamMisery.tsx 9.4 KB

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