teamMisery.tsx 9.5 KB

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