changedTransactions.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  1. import {Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import {Client} from 'sentry/api';
  6. import {Button} from 'sentry/components/button';
  7. import {HeaderTitleLegend} from 'sentry/components/charts/styles';
  8. import Count from 'sentry/components/count';
  9. import DropdownLink from 'sentry/components/dropdownLink';
  10. import Duration from 'sentry/components/duration';
  11. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  12. import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup';
  13. import IdBadge from 'sentry/components/idBadge';
  14. import Link from 'sentry/components/links/link';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import MenuItem from 'sentry/components/menuItem';
  17. import Pagination, {CursorHandler} from 'sentry/components/pagination';
  18. import {Panel} from 'sentry/components/panels';
  19. import QuestionTooltip from 'sentry/components/questionTooltip';
  20. import Radio from 'sentry/components/radio';
  21. import {Tooltip} from 'sentry/components/tooltip';
  22. import {IconArrow, IconEllipsis} from 'sentry/icons';
  23. import {t} from 'sentry/locale';
  24. import {space} from 'sentry/styles/space';
  25. import {AvatarProject, Organization, Project} from 'sentry/types';
  26. import {trackAnalytics} from 'sentry/utils/analytics';
  27. import {formatPercentage, getDuration} from 'sentry/utils/formatters';
  28. import {useMetricsCardinalityContext} from 'sentry/utils/performance/contexts/metricsCardinality';
  29. import TrendsDiscoverQuery from 'sentry/utils/performance/trends/trendsDiscoverQuery';
  30. import {decodeScalar} from 'sentry/utils/queryString';
  31. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  32. import useApi from 'sentry/utils/useApi';
  33. import withOrganization from 'sentry/utils/withOrganization';
  34. import withProjects from 'sentry/utils/withProjects';
  35. import {
  36. DisplayModes,
  37. transactionSummaryRouteWithQuery,
  38. } from 'sentry/views/performance/transactionSummary/utils';
  39. import {getSelectedTransaction} from 'sentry/views/performance/utils';
  40. import Chart from './chart';
  41. import {
  42. NormalizedTrendsTransaction,
  43. TrendChangeType,
  44. TrendColumnField,
  45. TrendFunctionField,
  46. TrendsStats,
  47. TrendView,
  48. } from './types';
  49. import {
  50. getCurrentTrendFunction,
  51. getCurrentTrendParameter,
  52. getSelectedQueryKey,
  53. getTrendProjectId,
  54. modifyTrendView,
  55. normalizeTrends,
  56. transformDeltaSpread,
  57. transformValueDelta,
  58. trendCursorNames,
  59. trendToColor,
  60. } from './utils';
  61. type Props = {
  62. location: Location;
  63. organization: Organization;
  64. projects: Project[];
  65. setError: (msg: string | undefined) => void;
  66. trendChangeType: TrendChangeType;
  67. trendView: TrendView;
  68. previousTrendColumn?: TrendColumnField;
  69. previousTrendFunction?: TrendFunctionField;
  70. withBreakpoint?: boolean;
  71. };
  72. type TrendsCursorQuery = {
  73. improvedCursor?: string;
  74. regressionCursor?: string;
  75. };
  76. const makeTrendsCursorHandler =
  77. (trendChangeType: TrendChangeType): CursorHandler =>
  78. (cursor, path, query) => {
  79. const cursorQuery = {} as TrendsCursorQuery;
  80. if (trendChangeType === TrendChangeType.IMPROVED) {
  81. cursorQuery.improvedCursor = cursor;
  82. } else if (trendChangeType === TrendChangeType.REGRESSION) {
  83. cursorQuery.regressionCursor = cursor;
  84. }
  85. const selectedQueryKey = getSelectedQueryKey(trendChangeType);
  86. delete query[selectedQueryKey];
  87. browserHistory.push({
  88. pathname: path,
  89. query: {...query, ...cursorQuery},
  90. });
  91. };
  92. function getChartTitle(trendChangeType: TrendChangeType): string {
  93. switch (trendChangeType) {
  94. case TrendChangeType.IMPROVED:
  95. return t('Most Improved Transactions');
  96. case TrendChangeType.REGRESSION:
  97. return t('Most Regressed Transactions');
  98. default:
  99. throw new Error('No trend type passed');
  100. }
  101. }
  102. function handleChangeSelected(
  103. location: Location,
  104. organization: Organization,
  105. trendChangeType: TrendChangeType
  106. ) {
  107. return function updateSelected(transaction?: NormalizedTrendsTransaction) {
  108. const selectedQueryKey = getSelectedQueryKey(trendChangeType);
  109. const query = {
  110. ...location.query,
  111. };
  112. if (!transaction) {
  113. delete query[selectedQueryKey];
  114. } else {
  115. query[selectedQueryKey] = transaction
  116. ? `${transaction.transaction}-${transaction.project}`
  117. : undefined;
  118. }
  119. browserHistory.push({
  120. pathname: location.pathname,
  121. query,
  122. });
  123. trackAnalytics('performance_views.trends.widget_interaction', {
  124. organization,
  125. widget_type: trendChangeType,
  126. });
  127. };
  128. }
  129. enum FilterSymbols {
  130. GREATER_THAN_EQUALS = '>=',
  131. LESS_THAN_EQUALS = '<=',
  132. }
  133. function handleFilterTransaction(location: Location, transaction: string) {
  134. const queryString = decodeScalar(location.query.query);
  135. const conditions = new MutableSearch(queryString ?? '');
  136. conditions.addFilterValues('!transaction', [transaction]);
  137. const query = conditions.formatString();
  138. browserHistory.push({
  139. pathname: location.pathname,
  140. query: {
  141. ...location.query,
  142. query: String(query).trim(),
  143. },
  144. });
  145. }
  146. function handleFilterDuration(
  147. location: Location,
  148. organization: Organization,
  149. value: number,
  150. symbol: FilterSymbols,
  151. trendChangeType: TrendChangeType,
  152. projects: Project[],
  153. projectIds: Readonly<number[]>
  154. ) {
  155. const durationTag = getCurrentTrendParameter(location, projects, projectIds).column;
  156. const queryString = decodeScalar(location.query.query);
  157. const conditions = new MutableSearch(queryString ?? '');
  158. const existingValues = conditions.getFilterValues(durationTag);
  159. const alternateSymbol = symbol === FilterSymbols.GREATER_THAN_EQUALS ? '>' : '<';
  160. if (existingValues) {
  161. existingValues.forEach(existingValue => {
  162. if (existingValue.startsWith(symbol) || existingValue.startsWith(alternateSymbol)) {
  163. conditions.removeFilterValue(durationTag, existingValue);
  164. }
  165. });
  166. }
  167. conditions.addFilterValues(durationTag, [`${symbol}${value}`]);
  168. const query = conditions.formatString();
  169. browserHistory.push({
  170. pathname: location.pathname,
  171. query: {
  172. ...location.query,
  173. query: String(query).trim(),
  174. },
  175. });
  176. trackAnalytics('performance_views.trends.change_duration', {
  177. organization,
  178. widget_type: getChartTitle(trendChangeType),
  179. value: `${symbol}${value}`,
  180. });
  181. }
  182. function ChangedTransactions(props: Props) {
  183. const {
  184. location,
  185. trendChangeType,
  186. previousTrendFunction,
  187. previousTrendColumn,
  188. organization,
  189. projects,
  190. setError,
  191. withBreakpoint,
  192. } = props;
  193. const api = useApi();
  194. const {isLoading: isCardinalityCheckLoading, outcome} = useMetricsCardinalityContext();
  195. const trendView = props.trendView.clone();
  196. const chartTitle = getChartTitle(trendChangeType);
  197. modifyTrendView(trendView, location, trendChangeType, projects, organization);
  198. const onCursor = makeTrendsCursorHandler(trendChangeType);
  199. const cursor = decodeScalar(location.query[trendCursorNames[trendChangeType]]);
  200. const paginationAnalyticsEvent = (direction: string) => {
  201. trackAnalytics('performance_views.trends.widget_pagination', {
  202. organization,
  203. direction,
  204. widget_type: getChartTitle(trendChangeType),
  205. });
  206. };
  207. return (
  208. <TrendsDiscoverQuery
  209. eventView={trendView}
  210. orgSlug={organization.slug}
  211. location={location}
  212. trendChangeType={trendChangeType}
  213. cursor={cursor}
  214. limit={5}
  215. setError={error => setError(error?.message)}
  216. withBreakpoint={withBreakpoint && !outcome?.forceTransactionsOnly}
  217. >
  218. {({isLoading, trendsData, pageLinks}) => {
  219. const trendFunction = getCurrentTrendFunction(location);
  220. const trendParameter = getCurrentTrendParameter(
  221. location,
  222. projects,
  223. trendView.project
  224. );
  225. const events = normalizeTrends(
  226. (trendsData && trendsData.events && trendsData.events.data) || []
  227. );
  228. const selectedTransaction = getSelectedTransaction(
  229. location,
  230. trendChangeType,
  231. events
  232. );
  233. const statsData = trendsData?.stats || {};
  234. const transactionsList = events && events.slice ? events.slice(0, 5) : [];
  235. const currentTrendFunction =
  236. isLoading && previousTrendFunction
  237. ? previousTrendFunction
  238. : trendFunction.field;
  239. const currentTrendColumn =
  240. isLoading && previousTrendColumn ? previousTrendColumn : trendParameter.column;
  241. const titleTooltipContent = t(
  242. 'This compares the baseline (%s) of the past with the present.',
  243. trendFunction.legendLabel
  244. );
  245. return (
  246. <TransactionsListContainer data-test-id="changed-transactions">
  247. <TrendsTransactionPanel>
  248. <StyledHeaderTitleLegend>
  249. {chartTitle}
  250. <QuestionTooltip size="sm" position="top" title={titleTooltipContent} />
  251. </StyledHeaderTitleLegend>
  252. {isLoading || isCardinalityCheckLoading ? (
  253. <LoadingIndicator
  254. style={{
  255. margin: '237px auto',
  256. }}
  257. />
  258. ) : (
  259. <Fragment>
  260. {transactionsList.length ? (
  261. <Fragment>
  262. <ChartContainer>
  263. <Chart
  264. statsData={statsData}
  265. query={trendView.query}
  266. project={trendView.project}
  267. environment={trendView.environment}
  268. start={trendView.start}
  269. end={trendView.end}
  270. statsPeriod={trendView.statsPeriod}
  271. transaction={selectedTransaction}
  272. isLoading={isLoading}
  273. {...props}
  274. />
  275. </ChartContainer>
  276. {transactionsList.map((transaction, index) => (
  277. <TrendsListItem
  278. api={api}
  279. currentTrendFunction={currentTrendFunction}
  280. currentTrendColumn={currentTrendColumn}
  281. trendView={props.trendView}
  282. organization={organization}
  283. transaction={transaction}
  284. key={transaction.transaction}
  285. index={index}
  286. trendChangeType={trendChangeType}
  287. transactions={transactionsList}
  288. location={location}
  289. projects={projects}
  290. statsData={statsData}
  291. handleSelectTransaction={handleChangeSelected(
  292. location,
  293. organization,
  294. trendChangeType
  295. )}
  296. />
  297. ))}
  298. </Fragment>
  299. ) : (
  300. <StyledEmptyStateWarning small>
  301. {t('No results')}
  302. </StyledEmptyStateWarning>
  303. )}
  304. </Fragment>
  305. )}
  306. </TrendsTransactionPanel>
  307. <Pagination
  308. pageLinks={pageLinks}
  309. onCursor={onCursor}
  310. paginationAnalyticsEvent={paginationAnalyticsEvent}
  311. />
  312. </TransactionsListContainer>
  313. );
  314. }}
  315. </TrendsDiscoverQuery>
  316. );
  317. }
  318. type TrendsListItemProps = {
  319. api: Client;
  320. currentTrendColumn: string;
  321. currentTrendFunction: string;
  322. handleSelectTransaction: (transaction: NormalizedTrendsTransaction) => void;
  323. index: number;
  324. location: Location;
  325. organization: Organization;
  326. projects: Project[];
  327. statsData: TrendsStats;
  328. transaction: NormalizedTrendsTransaction;
  329. transactions: NormalizedTrendsTransaction[];
  330. trendChangeType: TrendChangeType;
  331. trendView: TrendView;
  332. };
  333. function TrendsListItem(props: TrendsListItemProps) {
  334. const {
  335. transaction,
  336. transactions,
  337. trendChangeType,
  338. currentTrendFunction,
  339. currentTrendColumn,
  340. index,
  341. location,
  342. organization,
  343. projects,
  344. handleSelectTransaction,
  345. trendView,
  346. } = props;
  347. const color = trendToColor[trendChangeType].default;
  348. const selectedTransaction = getSelectedTransaction(
  349. location,
  350. trendChangeType,
  351. transactions
  352. );
  353. const isSelected = selectedTransaction === transaction;
  354. const project = projects.find(
  355. ({slug}) => slug === transaction.project
  356. ) as AvatarProject;
  357. const currentPeriodValue = transaction.aggregate_range_2;
  358. const previousPeriodValue = transaction.aggregate_range_1;
  359. const absolutePercentChange = formatPercentage(
  360. Math.abs(transaction.trend_percentage - 1),
  361. 0
  362. );
  363. const previousDuration = getDuration(
  364. previousPeriodValue / 1000,
  365. previousPeriodValue < 1000 && previousPeriodValue > 10 ? 0 : 2
  366. );
  367. const currentDuration = getDuration(
  368. currentPeriodValue / 1000,
  369. currentPeriodValue < 1000 && currentPeriodValue > 10 ? 0 : 2
  370. );
  371. const percentChangeExplanation = t(
  372. 'Over this period, the %s for %s has %s %s from %s to %s',
  373. currentTrendFunction,
  374. currentTrendColumn,
  375. trendChangeType === TrendChangeType.IMPROVED ? t('decreased') : t('increased'),
  376. absolutePercentChange,
  377. previousDuration,
  378. currentDuration
  379. );
  380. const longestPeriodValue =
  381. trendChangeType === TrendChangeType.IMPROVED
  382. ? previousPeriodValue
  383. : currentPeriodValue;
  384. const longestDuration =
  385. trendChangeType === TrendChangeType.IMPROVED ? previousDuration : currentDuration;
  386. return (
  387. <ListItemContainer data-test-id={'trends-list-item-' + trendChangeType}>
  388. <ItemRadioContainer color={color}>
  389. {transaction.count_range_1 && transaction.count_range_2 ? (
  390. <Tooltip
  391. title={
  392. <TooltipContent>
  393. <span>{t('Total Events')}</span>
  394. <span>
  395. <Count value={transaction.count_range_1} />
  396. <StyledIconArrow direction="right" size="xs" />
  397. <Count value={transaction.count_range_2} />
  398. </span>
  399. </TooltipContent>
  400. }
  401. disableForVisualTest // Disabled tooltip in snapshots because of overlap order issues.
  402. >
  403. <RadioLineItem index={index} role="radio">
  404. <Radio
  405. checked={isSelected}
  406. onChange={() => handleSelectTransaction(transaction)}
  407. />
  408. </RadioLineItem>
  409. </Tooltip>
  410. ) : (
  411. <RadioLineItem index={index} role="radio">
  412. <Radio
  413. checked={isSelected}
  414. onChange={() => handleSelectTransaction(transaction)}
  415. />
  416. </RadioLineItem>
  417. )}
  418. </ItemRadioContainer>
  419. <TransactionSummaryLink {...props} />
  420. <ItemTransactionPercentage>
  421. <Tooltip title={percentChangeExplanation}>
  422. <Fragment>
  423. {trendChangeType === TrendChangeType.REGRESSION ? '+' : ''}
  424. {formatPercentage(transaction.trend_percentage - 1, 0)}
  425. </Fragment>
  426. </Tooltip>
  427. </ItemTransactionPercentage>
  428. <DropdownLink
  429. caret={false}
  430. anchorRight
  431. title={
  432. <StyledButton
  433. size="xs"
  434. icon={<IconEllipsis data-test-id="trends-item-action" size="xs" />}
  435. aria-label={t('Actions')}
  436. />
  437. }
  438. >
  439. {!organization.features.includes('performance-new-trends') && (
  440. <Fragment>
  441. <MenuItem
  442. onClick={() =>
  443. handleFilterDuration(
  444. location,
  445. organization,
  446. longestPeriodValue,
  447. FilterSymbols.LESS_THAN_EQUALS,
  448. trendChangeType,
  449. projects,
  450. trendView.project
  451. )
  452. }
  453. >
  454. <MenuAction>{t('Show \u2264 %s', longestDuration)}</MenuAction>
  455. </MenuItem>
  456. <MenuItem
  457. onClick={() =>
  458. handleFilterDuration(
  459. location,
  460. organization,
  461. longestPeriodValue,
  462. FilterSymbols.GREATER_THAN_EQUALS,
  463. trendChangeType,
  464. projects,
  465. trendView.project
  466. )
  467. }
  468. >
  469. <MenuAction>{t('Show \u2265 %s', longestDuration)}</MenuAction>
  470. </MenuItem>
  471. </Fragment>
  472. )}
  473. <MenuItem
  474. onClick={() => handleFilterTransaction(location, transaction.transaction)}
  475. >
  476. <MenuAction>{t('Hide from list')}</MenuAction>
  477. </MenuItem>
  478. </DropdownLink>
  479. <ItemTransactionDurationChange>
  480. {project && (
  481. <Tooltip title={transaction.project}>
  482. <IdBadge avatarSize={16} project={project} hideName />
  483. </Tooltip>
  484. )}
  485. <CompareDurations {...props} />
  486. </ItemTransactionDurationChange>
  487. <ItemTransactionStatus color={color}>
  488. <ValueDelta {...props} />
  489. </ItemTransactionStatus>
  490. </ListItemContainer>
  491. );
  492. }
  493. export function CompareDurations({
  494. transaction,
  495. }: {
  496. transaction: TrendsListItemProps['transaction'];
  497. }) {
  498. const {fromSeconds, toSeconds, showDigits} = transformDeltaSpread(
  499. transaction.aggregate_range_1,
  500. transaction.aggregate_range_2
  501. );
  502. return (
  503. <DurationChange>
  504. <Duration seconds={fromSeconds} fixedDigits={showDigits ? 1 : 0} abbreviation />
  505. <StyledIconArrow direction="right" size="xs" />
  506. <Duration seconds={toSeconds} fixedDigits={showDigits ? 1 : 0} abbreviation />
  507. </DurationChange>
  508. );
  509. }
  510. function ValueDelta({transaction, trendChangeType}: TrendsListItemProps) {
  511. const {seconds, fixedDigits, changeLabel} = transformValueDelta(
  512. transaction.trend_difference,
  513. trendChangeType
  514. );
  515. return (
  516. <span>
  517. <Duration seconds={seconds} fixedDigits={fixedDigits} abbreviation /> {changeLabel}
  518. </span>
  519. );
  520. }
  521. type TransactionSummaryLinkProps = TrendsListItemProps & {};
  522. function TransactionSummaryLink(props: TransactionSummaryLinkProps) {
  523. const {
  524. organization,
  525. trendView: eventView,
  526. transaction,
  527. projects,
  528. currentTrendFunction,
  529. currentTrendColumn,
  530. } = props;
  531. const summaryView = eventView.clone();
  532. const projectID = getTrendProjectId(transaction, projects);
  533. const target = transactionSummaryRouteWithQuery({
  534. orgSlug: organization.slug,
  535. transaction: String(transaction.transaction),
  536. query: summaryView.generateQueryStringObject(),
  537. projectID,
  538. display: DisplayModes.TREND,
  539. trendFunction: currentTrendFunction,
  540. trendColumn: currentTrendColumn,
  541. });
  542. return (
  543. <ItemTransactionName to={target} data-test-id="item-transaction-name">
  544. {transaction.transaction}
  545. </ItemTransactionName>
  546. );
  547. }
  548. const TransactionsListContainer = styled('div')`
  549. display: flex;
  550. flex-direction: column;
  551. `;
  552. const TrendsTransactionPanel = styled(Panel)`
  553. margin: 0;
  554. flex-grow: 1;
  555. `;
  556. const ChartContainer = styled('div')`
  557. padding: ${space(3)};
  558. `;
  559. const StyledHeaderTitleLegend = styled(HeaderTitleLegend)`
  560. border-radius: ${p => p.theme.borderRadius};
  561. margin: ${space(2)} ${space(3)};
  562. `;
  563. const StyledButton = styled(Button)`
  564. vertical-align: middle;
  565. `;
  566. const MenuAction = styled('div')<{['data-test-id']?: string}>`
  567. white-space: nowrap;
  568. color: ${p => p.theme.textColor};
  569. `;
  570. MenuAction.defaultProps = {
  571. 'data-test-id': 'menu-action',
  572. };
  573. const StyledEmptyStateWarning = styled(EmptyStateWarning)`
  574. min-height: 300px;
  575. justify-content: center;
  576. `;
  577. const ListItemContainer = styled('div')`
  578. display: grid;
  579. grid-template-columns: 24px auto 100px 30px;
  580. grid-template-rows: repeat(2, auto);
  581. grid-column-gap: ${space(1)};
  582. border-top: 1px solid ${p => p.theme.border};
  583. padding: ${space(1)} ${space(2)};
  584. `;
  585. const ItemRadioContainer = styled('div')`
  586. grid-row: 1/3;
  587. input {
  588. cursor: pointer;
  589. }
  590. input:checked::after {
  591. background-color: ${p => p.color};
  592. }
  593. `;
  594. const ItemTransactionName = styled(Link)`
  595. font-size: ${p => p.theme.fontSizeMedium};
  596. margin-right: ${space(1)};
  597. ${p => p.theme.overflowEllipsis};
  598. `;
  599. const ItemTransactionDurationChange = styled('div')`
  600. display: flex;
  601. align-items: center;
  602. font-size: ${p => p.theme.fontSizeSmall};
  603. `;
  604. const DurationChange = styled('span')`
  605. color: ${p => p.theme.gray300};
  606. margin: 0 ${space(1)};
  607. `;
  608. const ItemTransactionPercentage = styled('div')`
  609. text-align: right;
  610. font-size: ${p => p.theme.fontSizeMedium};
  611. `;
  612. const ItemTransactionStatus = styled('div')`
  613. color: ${p => p.color};
  614. text-align: right;
  615. font-size: ${p => p.theme.fontSizeSmall};
  616. `;
  617. const TooltipContent = styled('div')`
  618. display: flex;
  619. flex-direction: column;
  620. align-items: center;
  621. `;
  622. const StyledIconArrow = styled(IconArrow)`
  623. margin: 0 ${space(1)};
  624. `;
  625. export default withProjects(withOrganization(ChangedTransactions));