vitalDetailContent.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import {Component, Fragment} from 'react';
  2. import {browserHistory, InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import omit from 'lodash/omit';
  6. import {Client} from 'sentry/api';
  7. import Feature from 'sentry/components/acl/feature';
  8. import Alert from 'sentry/components/alert';
  9. import Button from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import {getInterval} from 'sentry/components/charts/utils';
  12. import {CreateAlertFromViewButton} from 'sentry/components/createAlertButton';
  13. import SearchBar from 'sentry/components/events/searchBar';
  14. import * as Layout from 'sentry/components/layouts/thirds';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  17. import * as TeamKeyTransactionManager from 'sentry/components/performance/teamKeyTransactionsManager';
  18. import {IconCheckmark, IconChevron, IconClose} from 'sentry/icons';
  19. import {IconFlag} from 'sentry/icons/iconFlag';
  20. import {t} from 'sentry/locale';
  21. import space from 'sentry/styles/space';
  22. import {Organization, Project} from 'sentry/types';
  23. import {generateQueryWithTag} from 'sentry/utils';
  24. import {getUtcToLocalDateObject} from 'sentry/utils/dates';
  25. import EventView from 'sentry/utils/discover/eventView';
  26. import {WebVital} from 'sentry/utils/discover/fields';
  27. import MetricsRequest from 'sentry/utils/metrics/metricsRequest';
  28. import {Browser} from 'sentry/utils/performance/vitals/constants';
  29. import {decodeScalar} from 'sentry/utils/queryString';
  30. import Teams from 'sentry/utils/teams';
  31. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  32. import withProjects from 'sentry/utils/withProjects';
  33. import {transformMetricsToArea} from 'sentry/views/performance/landing/widgets/transforms/transformMetricsToArea';
  34. import Breadcrumb from '../breadcrumb';
  35. import MetricsSearchBar from '../metricsSearchBar';
  36. import {MetricsSwitch} from '../metricsSwitch';
  37. import {getTransactionSearchQuery} from '../utils';
  38. import Table from './table';
  39. import {
  40. vitalDescription,
  41. vitalMap,
  42. vitalSupportedBrowsers,
  43. vitalToMetricsField,
  44. } from './utils';
  45. import VitalChart from './vitalChart';
  46. import VitalChartMetrics from './vitalChartMetrics';
  47. import VitalInfo from './vitalInfo';
  48. const FRONTEND_VITALS = [WebVital.FCP, WebVital.LCP, WebVital.FID, WebVital.CLS];
  49. type Props = {
  50. api: Client;
  51. eventView: EventView;
  52. isMetricsData: boolean;
  53. location: Location;
  54. organization: Organization;
  55. projects: Project[];
  56. router: InjectedRouter;
  57. vitalName: WebVital;
  58. };
  59. type State = {
  60. error: string | undefined;
  61. incompatibleAlertNotice: React.ReactNode;
  62. };
  63. function getSummaryConditions(query: string) {
  64. const parsed = new MutableSearch(query);
  65. parsed.freeText = [];
  66. return parsed.formatString();
  67. }
  68. class VitalDetailContent extends Component<Props, State> {
  69. state: State = {
  70. incompatibleAlertNotice: null,
  71. error: undefined,
  72. };
  73. handleSearch = (query: string) => {
  74. const {location} = this.props;
  75. const queryParams = normalizeDateTimeParams({
  76. ...(location.query || {}),
  77. query,
  78. });
  79. // do not propagate pagination when making a new search
  80. const searchQueryParams = omit(queryParams, 'cursor');
  81. browserHistory.push({
  82. pathname: location.pathname,
  83. query: searchQueryParams,
  84. });
  85. };
  86. generateTagUrl = (key: string, value: string) => {
  87. const {location} = this.props;
  88. const query = generateQueryWithTag(location.query, {key, value});
  89. return {
  90. ...location,
  91. query,
  92. };
  93. };
  94. handleIncompatibleQuery: React.ComponentProps<
  95. typeof CreateAlertFromViewButton
  96. >['onIncompatibleQuery'] = (incompatibleAlertNoticeFn, _errors) => {
  97. const incompatibleAlertNotice = incompatibleAlertNoticeFn(() =>
  98. this.setState({incompatibleAlertNotice: null})
  99. );
  100. this.setState({incompatibleAlertNotice});
  101. };
  102. renderCreateAlertButton() {
  103. const {eventView, organization, projects} = this.props;
  104. return (
  105. <CreateAlertFromViewButton
  106. eventView={eventView}
  107. organization={organization}
  108. projects={projects}
  109. onIncompatibleQuery={this.handleIncompatibleQuery}
  110. onSuccess={() => {}}
  111. referrer="performance"
  112. />
  113. );
  114. }
  115. renderVitalSwitcher() {
  116. const {vitalName, location} = this.props;
  117. const position = FRONTEND_VITALS.indexOf(vitalName);
  118. if (position < 0) {
  119. return null;
  120. }
  121. const previousDisabled = position === 0;
  122. const nextDisabled = position === FRONTEND_VITALS.length - 1;
  123. const switchVital = newVitalName => {
  124. return () => {
  125. browserHistory.push({
  126. pathname: location.pathname,
  127. query: {
  128. ...location.query,
  129. vitalName: newVitalName,
  130. },
  131. });
  132. };
  133. };
  134. return (
  135. <ButtonBar merged>
  136. <Button
  137. icon={<IconChevron direction="left" size="sm" />}
  138. aria-label={t('Previous')}
  139. disabled={previousDisabled}
  140. onClick={switchVital(FRONTEND_VITALS[position - 1])}
  141. />
  142. <Button
  143. icon={<IconChevron direction="right" size="sm" />}
  144. aria-label={t('Next')}
  145. disabled={nextDisabled}
  146. onClick={switchVital(FRONTEND_VITALS[position + 1])}
  147. />
  148. </ButtonBar>
  149. );
  150. }
  151. setError = (error: string | undefined) => {
  152. this.setState({error});
  153. };
  154. renderError() {
  155. const {error} = this.state;
  156. if (!error) {
  157. return null;
  158. }
  159. return (
  160. <Alert type="error" icon={<IconFlag size="md" />}>
  161. {error}
  162. </Alert>
  163. );
  164. }
  165. renderContent(vital: WebVital) {
  166. const {isMetricsData, location, organization, eventView, api, projects} = this.props;
  167. const {fields, start, end, statsPeriod, environment, project} = eventView;
  168. const query = decodeScalar(location.query.query, '');
  169. const orgSlug = organization.slug;
  170. const localDateStart = start ? getUtcToLocalDateObject(start) : null;
  171. const localDateEnd = end ? getUtcToLocalDateObject(end) : null;
  172. const interval = getInterval(
  173. {start: localDateStart, end: localDateEnd, period: statsPeriod},
  174. 'high'
  175. );
  176. if (isMetricsData) {
  177. const field = `p75(${vitalToMetricsField[vital]})`;
  178. return (
  179. <Fragment>
  180. <StyledMetricsSearchBar
  181. searchSource="performance_vitals_metrics"
  182. orgSlug={orgSlug}
  183. projectIds={project}
  184. query={query}
  185. onSearch={this.handleSearch}
  186. />
  187. <MetricsRequest
  188. api={api}
  189. orgSlug={orgSlug}
  190. start={start}
  191. end={end}
  192. statsPeriod={statsPeriod}
  193. project={project}
  194. environment={environment}
  195. field={[field]}
  196. query={new MutableSearch(query).formatString()} // TODO(metrics): not all tags will be compatible with metrics
  197. interval={interval}
  198. >
  199. {p75RequestProps => {
  200. const {loading, errored, response, reloading} = p75RequestProps;
  201. const p75Data = transformMetricsToArea(
  202. {
  203. location,
  204. fields: [field],
  205. },
  206. p75RequestProps
  207. );
  208. return (
  209. <Fragment>
  210. <VitalChartMetrics
  211. start={localDateStart}
  212. end={localDateEnd}
  213. statsPeriod={statsPeriod}
  214. project={project}
  215. environment={environment}
  216. loading={loading}
  217. response={response}
  218. errored={errored}
  219. reloading={reloading}
  220. field={field}
  221. vital={vital}
  222. />
  223. <StyledVitalInfo>
  224. <VitalInfo
  225. orgSlug={orgSlug}
  226. location={location}
  227. vital={vital}
  228. project={project}
  229. environment={environment}
  230. start={start}
  231. end={end}
  232. statsPeriod={statsPeriod}
  233. isMetricsData={isMetricsData}
  234. isLoading={loading}
  235. p75AllTransactions={p75Data.dataMean?.[0].mean}
  236. />
  237. </StyledVitalInfo>
  238. <div>TODO</div>
  239. </Fragment>
  240. );
  241. }}
  242. </MetricsRequest>
  243. </Fragment>
  244. );
  245. }
  246. const filterString = getTransactionSearchQuery(location);
  247. const summaryConditions = getSummaryConditions(filterString);
  248. return (
  249. <Fragment>
  250. <StyledSearchBar
  251. searchSource="performance_vitals"
  252. organization={organization}
  253. projectIds={project}
  254. query={query}
  255. fields={fields}
  256. onSearch={this.handleSearch}
  257. />
  258. <VitalChart
  259. organization={organization}
  260. query={query}
  261. project={project}
  262. environment={environment}
  263. start={localDateStart}
  264. end={localDateEnd}
  265. statsPeriod={statsPeriod}
  266. interval={interval}
  267. />
  268. <StyledVitalInfo>
  269. <VitalInfo
  270. orgSlug={orgSlug}
  271. location={location}
  272. vital={vital}
  273. project={project}
  274. environment={environment}
  275. start={start}
  276. end={end}
  277. statsPeriod={statsPeriod}
  278. />
  279. </StyledVitalInfo>
  280. <Teams provideUserTeams>
  281. {({teams, initiallyLoaded}) =>
  282. initiallyLoaded ? (
  283. <TeamKeyTransactionManager.Provider
  284. organization={organization}
  285. teams={teams}
  286. selectedTeams={['myteams']}
  287. selectedProjects={project.map(String)}
  288. >
  289. <Table
  290. eventView={eventView}
  291. projects={projects}
  292. organization={organization}
  293. location={location}
  294. setError={this.setError}
  295. summaryConditions={summaryConditions}
  296. />
  297. </TeamKeyTransactionManager.Provider>
  298. ) : (
  299. <LoadingIndicator />
  300. )
  301. }
  302. </Teams>
  303. </Fragment>
  304. );
  305. }
  306. render() {
  307. const {location, organization, vitalName} = this.props;
  308. const {incompatibleAlertNotice} = this.state;
  309. const vital = vitalName || WebVital.LCP;
  310. return (
  311. <Fragment>
  312. <Layout.Header>
  313. <Layout.HeaderContent>
  314. <Breadcrumb
  315. organization={organization}
  316. location={location}
  317. vitalName={vital}
  318. />
  319. <Layout.Title>{vitalMap[vital]}</Layout.Title>
  320. </Layout.HeaderContent>
  321. <Layout.HeaderActions>
  322. <ButtonBar gap={1}>
  323. <MetricsSwitch onSwitch={() => this.handleSearch('')} />
  324. <Feature organization={organization} features={['incidents']}>
  325. {({hasFeature}) => hasFeature && this.renderCreateAlertButton()}
  326. </Feature>
  327. {this.renderVitalSwitcher()}
  328. </ButtonBar>
  329. </Layout.HeaderActions>
  330. </Layout.Header>
  331. <Layout.Body>
  332. {this.renderError()}
  333. {incompatibleAlertNotice && (
  334. <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
  335. )}
  336. <Layout.Main fullWidth>
  337. <StyledDescription>{vitalDescription[vitalName]}</StyledDescription>
  338. <SupportedBrowsers>
  339. {Object.values(Browser).map(browser => (
  340. <BrowserItem key={browser}>
  341. {vitalSupportedBrowsers[vitalName]?.includes(browser) ? (
  342. <IconCheckmark color="green200" size="sm" />
  343. ) : (
  344. <IconClose color="red300" size="sm" />
  345. )}
  346. {browser}
  347. </BrowserItem>
  348. ))}
  349. </SupportedBrowsers>
  350. {this.renderContent(vital)}
  351. </Layout.Main>
  352. </Layout.Body>
  353. </Fragment>
  354. );
  355. }
  356. }
  357. export default withProjects(VitalDetailContent);
  358. const StyledDescription = styled('div')`
  359. font-size: ${p => p.theme.fontSizeMedium};
  360. margin-bottom: ${space(3)};
  361. `;
  362. const StyledSearchBar = styled(SearchBar)`
  363. margin-bottom: ${space(2)};
  364. `;
  365. const StyledVitalInfo = styled('div')`
  366. margin-bottom: ${space(3)};
  367. `;
  368. const StyledMetricsSearchBar = styled(MetricsSearchBar)`
  369. margin-bottom: ${space(2)};
  370. `;
  371. const SupportedBrowsers = styled('div')`
  372. display: inline-flex;
  373. gap: ${space(2)};
  374. margin-bottom: ${space(3)};
  375. `;
  376. const BrowserItem = styled('div')`
  377. display: flex;
  378. align-items: center;
  379. gap: ${space(1)};
  380. `;