pageLayout.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import {useCallback, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {isString} from '@sentry/utils';
  4. import type {Location} from 'history';
  5. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  6. import Feature from 'sentry/components/acl/feature';
  7. import {Alert} from 'sentry/components/alert';
  8. import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  9. import * as Layout from 'sentry/components/layouts/thirds';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  12. import PickProjectToContinue from 'sentry/components/pickProjectToContinue';
  13. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  14. import {Tabs} from 'sentry/components/tabs';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {Organization} from 'sentry/types/organization';
  18. import type {Project} from 'sentry/types/project';
  19. import {defined} from 'sentry/utils';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import {browserHistory} from 'sentry/utils/browserHistory';
  22. import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
  23. import type EventView from 'sentry/utils/discover/eventView';
  24. import {
  25. MetricsCardinalityProvider,
  26. useMetricsCardinalityContext,
  27. } from 'sentry/utils/performance/contexts/metricsCardinality';
  28. import {PerformanceEventViewProvider} from 'sentry/utils/performance/contexts/performanceEventViewContext';
  29. import {decodeScalar} from 'sentry/utils/queryString';
  30. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  31. import useRouter from 'sentry/utils/useRouter';
  32. import {aggregateWaterfallRouteWithQuery} from 'sentry/views/performance/transactionSummary/aggregateSpanWaterfall/utils';
  33. import {
  34. getPerformanceBaseUrl,
  35. getSelectedProjectPlatforms,
  36. getTransactionName,
  37. } from '../utils';
  38. import {anomaliesRouteWithQuery} from './transactionAnomalies/utils';
  39. import {eventsRouteWithQuery} from './transactionEvents/utils';
  40. import {profilesRouteWithQuery} from './transactionProfiles/utils';
  41. import {replaysRouteWithQuery} from './transactionReplays/utils';
  42. import {spansRouteWithQuery} from './transactionSpans/utils';
  43. import {tagsRouteWithQuery} from './transactionTags/utils';
  44. import {vitalsRouteWithQuery} from './transactionVitals/utils';
  45. import TransactionHeader, {type Props as TransactionHeaderProps} from './header';
  46. import Tab from './tabs';
  47. import type {TransactionThresholdMetric} from './transactionThresholdModal';
  48. import {generateTransactionSummaryRoute, transactionSummaryRouteWithQuery} from './utils';
  49. type TabEvents =
  50. | 'performance_views.vitals.vitals_tab_clicked'
  51. | 'performance_views.tags.tags_tab_clicked'
  52. | 'performance_views.events.events_tab_clicked'
  53. | 'performance_views.spans.spans_tab_clicked';
  54. export const TAB_ANALYTICS: Partial<Record<Tab, TabEvents>> = {
  55. [Tab.WEB_VITALS]: 'performance_views.vitals.vitals_tab_clicked',
  56. [Tab.TAGS]: 'performance_views.tags.tags_tab_clicked',
  57. [Tab.EVENTS]: 'performance_views.events.events_tab_clicked',
  58. [Tab.SPANS]: 'performance_views.spans.spans_tab_clicked',
  59. };
  60. export type ChildProps = {
  61. eventView: EventView;
  62. location: Location;
  63. organization: Organization;
  64. projectId: string;
  65. projects: Project[];
  66. setError: React.Dispatch<React.SetStateAction<string | undefined>>;
  67. transactionName: string;
  68. // These are used to trigger a reload when the threshold/metric changes.
  69. transactionThreshold?: number;
  70. transactionThresholdMetric?: TransactionThresholdMetric;
  71. };
  72. type Props = {
  73. childComponent: (props: ChildProps) => JSX.Element;
  74. generateEventView: (props: {
  75. location: Location;
  76. organization: Organization;
  77. transactionName: string;
  78. }) => EventView;
  79. getDocumentTitle: (name: string) => string;
  80. location: Location;
  81. organization: Organization;
  82. projects: Project[];
  83. tab: Tab;
  84. features?: string[];
  85. fillSpace?: boolean;
  86. };
  87. function PageLayout(props: Props) {
  88. const {
  89. location,
  90. organization,
  91. projects,
  92. tab,
  93. getDocumentTitle,
  94. generateEventView,
  95. childComponent: ChildComponent,
  96. features = [],
  97. } = props;
  98. let projectId: string | undefined;
  99. const filterProjects = location.query.project;
  100. if (isString(filterProjects) && filterProjects !== '-1') {
  101. projectId = filterProjects;
  102. }
  103. const router = useRouter();
  104. const transactionName = getTransactionName(location);
  105. const [error, setError] = useState<string | undefined>();
  106. const metricsCardinality = useMetricsCardinalityContext();
  107. const [transactionThreshold, setTransactionThreshold] = useState<number | undefined>();
  108. const [transactionThresholdMetric, setTransactionThresholdMetric] = useState<
  109. TransactionThresholdMetric | undefined
  110. >();
  111. const getNewRoute = useCallback(
  112. (newTab: Tab) => {
  113. if (!transactionName) {
  114. return {};
  115. }
  116. const routeQuery = {
  117. orgSlug: organization.slug,
  118. transaction: transactionName,
  119. projectID: projectId,
  120. query: location.query,
  121. };
  122. switch (newTab) {
  123. case Tab.TAGS:
  124. return tagsRouteWithQuery(routeQuery);
  125. case Tab.EVENTS:
  126. return eventsRouteWithQuery(routeQuery);
  127. case Tab.SPANS:
  128. return spansRouteWithQuery(routeQuery);
  129. case Tab.ANOMALIES:
  130. return anomaliesRouteWithQuery(routeQuery);
  131. case Tab.REPLAYS:
  132. return replaysRouteWithQuery(routeQuery);
  133. case Tab.PROFILING: {
  134. return profilesRouteWithQuery(routeQuery);
  135. }
  136. case Tab.AGGREGATE_WATERFALL:
  137. return aggregateWaterfallRouteWithQuery(routeQuery);
  138. case Tab.WEB_VITALS:
  139. return vitalsRouteWithQuery({
  140. orgSlug: organization.slug,
  141. transaction: transactionName,
  142. projectID: decodeScalar(location.query.project),
  143. query: location.query,
  144. });
  145. case Tab.TRANSACTION_SUMMARY:
  146. default:
  147. return transactionSummaryRouteWithQuery(routeQuery);
  148. }
  149. },
  150. [location.query, organization.slug, projectId, transactionName]
  151. );
  152. const onTabChange = useCallback(
  153. (newTab: Tab) => {
  154. // Prevent infinite rerenders
  155. if (newTab === tab) {
  156. return;
  157. }
  158. const analyticsKey = TAB_ANALYTICS[newTab];
  159. if (analyticsKey) {
  160. trackAnalytics(analyticsKey, {
  161. organization,
  162. project_platforms: getSelectedProjectPlatforms(location, projects),
  163. });
  164. }
  165. browserHistory.push(normalizeUrl(getNewRoute(newTab)));
  166. },
  167. [getNewRoute, tab, organization, location, projects]
  168. );
  169. if (!defined(transactionName)) {
  170. redirectToPerformanceHomepage(organization, location);
  171. return null;
  172. }
  173. const eventView = generateEventView({location, transactionName, organization});
  174. if (!defined(projectId)) {
  175. // Using a discover query to get the projects associated
  176. // with a transaction name
  177. const nextView = eventView.clone();
  178. nextView.query = `transaction:"${transactionName}"`;
  179. nextView.fields = [
  180. {
  181. field: 'project',
  182. width: COL_WIDTH_UNDEFINED,
  183. },
  184. {
  185. field: 'count()',
  186. width: COL_WIDTH_UNDEFINED,
  187. },
  188. ];
  189. return (
  190. <DiscoverQuery
  191. eventView={nextView}
  192. location={location}
  193. orgSlug={organization.slug}
  194. queryExtras={{project: filterProjects ? filterProjects : undefined}}
  195. referrer="api.performance.transaction-summary"
  196. >
  197. {({isLoading, tableData, error: discoverQueryError}) => {
  198. if (discoverQueryError) {
  199. addErrorMessage(t('Unable to get projects associated with transaction'));
  200. redirectToPerformanceHomepage(organization, location);
  201. return null;
  202. }
  203. if (isLoading) {
  204. return <LoadingIndicator />;
  205. }
  206. const selectableProjects = tableData?.data
  207. .map(row => projects.find(project => project.slug === row.project))
  208. .filter((p): p is Project => p !== undefined);
  209. return (
  210. selectableProjects && (
  211. <PickProjectToContinue
  212. data-test-id="transaction-sumamry-project-picker-modal"
  213. projects={selectableProjects}
  214. router={router}
  215. nextPath={{
  216. pathname: generateTransactionSummaryRoute({orgSlug: organization.slug}),
  217. query: {
  218. project: projectId,
  219. transaction: transactionName,
  220. statsPeriod: eventView.statsPeriod,
  221. referrer: 'performance-transaction-summary',
  222. ...location.query,
  223. },
  224. }}
  225. noProjectRedirectPath="/performance/"
  226. allowAllProjectsSelection
  227. />
  228. )
  229. );
  230. }}
  231. </DiscoverQuery>
  232. );
  233. }
  234. const project = projects.find(p => p.id === projectId);
  235. let hasWebVitals: TransactionHeaderProps['hasWebVitals'] =
  236. tab === Tab.WEB_VITALS ? 'yes' : 'maybe';
  237. if (organization.features.includes('insights-domain-view')) {
  238. hasWebVitals = 'no';
  239. }
  240. return (
  241. <SentryDocumentTitle
  242. title={getDocumentTitle(transactionName)}
  243. orgSlug={organization.slug}
  244. projectSlug={project?.slug}
  245. >
  246. <Feature
  247. features={['performance-view', ...features]}
  248. organization={organization}
  249. renderDisabled={NoAccess}
  250. >
  251. <MetricsCardinalityProvider location={location} organization={organization}>
  252. <PerformanceEventViewProvider value={{eventView}}>
  253. <PageFiltersContainer
  254. shouldForceProject={defined(project)}
  255. forceProject={project}
  256. specificProjectSlugs={defined(project) ? [project.slug] : []}
  257. >
  258. <Tabs value={tab} onChange={onTabChange}>
  259. <Layout.Page>
  260. <TransactionHeader
  261. eventView={eventView}
  262. location={location}
  263. organization={organization}
  264. projects={projects}
  265. projectId={projectId}
  266. transactionName={transactionName}
  267. currentTab={tab}
  268. hasWebVitals={hasWebVitals}
  269. onChangeThreshold={(threshold, metric) => {
  270. setTransactionThreshold(threshold);
  271. setTransactionThresholdMetric(metric);
  272. }}
  273. metricsCardinality={metricsCardinality}
  274. />
  275. <StyledBody fillSpace={props.fillSpace} hasError={defined(error)}>
  276. {defined(error) && (
  277. <StyledAlert type="error" showIcon>
  278. {error}
  279. </StyledAlert>
  280. )}
  281. <ChildComponent
  282. location={location}
  283. organization={organization}
  284. projects={projects}
  285. eventView={eventView}
  286. projectId={projectId}
  287. transactionName={transactionName}
  288. setError={setError}
  289. transactionThreshold={transactionThreshold}
  290. transactionThresholdMetric={transactionThresholdMetric}
  291. />
  292. </StyledBody>
  293. </Layout.Page>
  294. </Tabs>
  295. </PageFiltersContainer>
  296. </PerformanceEventViewProvider>
  297. </MetricsCardinalityProvider>
  298. </Feature>
  299. </SentryDocumentTitle>
  300. );
  301. }
  302. export function NoAccess() {
  303. return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
  304. }
  305. const StyledAlert = styled(Alert)`
  306. grid-column: 1/3;
  307. margin: 0;
  308. `;
  309. const StyledBody = styled(Layout.Body)<{fillSpace?: boolean; hasError?: boolean}>`
  310. ${p =>
  311. p.fillSpace &&
  312. `
  313. display: flex;
  314. flex-direction: column;
  315. gap: ${space(3)};
  316. @media (min-width: ${p.theme.breakpoints.large}) {
  317. display: flex;
  318. flex-direction: column;
  319. gap: ${space(3)};
  320. }
  321. `}
  322. `;
  323. export function redirectToPerformanceHomepage(
  324. organization: Organization,
  325. location: Location
  326. ) {
  327. // If there is no transaction name, redirect to the Performance landing page
  328. browserHistory.replace(
  329. normalizeUrl({
  330. pathname: getPerformanceBaseUrl(organization.slug),
  331. query: {
  332. ...location.query,
  333. },
  334. })
  335. );
  336. }
  337. export default PageLayout;