quickContext.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. import {Fragment, useEffect} 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 AvatarList from 'sentry/components/avatar/avatarList';
  7. import {QuickContextCommitRow} from 'sentry/components/discover/quickContextCommitRow';
  8. import EventCause from 'sentry/components/events/eventCause';
  9. import {CauseHeader, DataSection} from 'sentry/components/events/styles';
  10. import FeatureBadge from 'sentry/components/featureBadge';
  11. import AssignedTo from 'sentry/components/group/assignedTo';
  12. import {
  13. getStacktrace,
  14. StackTracePreviewContent,
  15. } from 'sentry/components/groupPreviewTooltip/stackTracePreview';
  16. import {Body, Hovercard} from 'sentry/components/hovercard';
  17. import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable';
  18. import LoadingIndicator from 'sentry/components/loadingIndicator';
  19. import {Panel} from 'sentry/components/panels';
  20. import * as SidebarSection from 'sentry/components/sidebarSection';
  21. import TimeSince from 'sentry/components/timeSince';
  22. import Tooltip from 'sentry/components/tooltip';
  23. import {VersionHoverHeader} from 'sentry/components/versionHoverCard';
  24. import {IconAdd, IconCheckmark, IconMute, IconNot} from 'sentry/icons';
  25. import {t, tct} from 'sentry/locale';
  26. import ConfigStore from 'sentry/stores/configStore';
  27. import GroupStore from 'sentry/stores/groupStore';
  28. import space from 'sentry/styles/space';
  29. import {Event, Group, Organization, Project, ReleaseWithHealth, User} from 'sentry/types';
  30. import EventView, {EventData} from 'sentry/utils/discover/eventView';
  31. import {getDuration} from 'sentry/utils/formatters';
  32. import TraceMetaQuery from 'sentry/utils/performance/quickTrace/traceMetaQuery';
  33. import {getTraceTimeRangeFromEvent} from 'sentry/utils/performance/quickTrace/utils';
  34. import {useQuery, useQueryClient} from 'sentry/utils/queryClient';
  35. import toArray from 'sentry/utils/toArray';
  36. import useApi from 'sentry/utils/useApi';
  37. import {useLocation} from 'sentry/utils/useLocation';
  38. import {
  39. getStatusBodyText,
  40. HttpStatus,
  41. } from 'sentry/views/performance/transactionDetails/eventMetas';
  42. // Will extend this enum as we add contexts for more columns
  43. export enum ContextType {
  44. ISSUE = 'issue',
  45. RELEASE = 'release',
  46. EVENT = 'event',
  47. }
  48. const HOVER_DELAY: number = 400;
  49. function isReleaseContext(contextType: ContextType): boolean {
  50. return contextType === ContextType.RELEASE;
  51. }
  52. function getHoverBody(
  53. api: Client,
  54. dataRow: EventData,
  55. contextType: ContextType,
  56. organization?: Organization,
  57. location?: Location,
  58. projects?: Project[],
  59. eventView?: EventView
  60. ) {
  61. const noContext = (
  62. <NoContextWrapper>{t('There is no context available.')}</NoContextWrapper>
  63. );
  64. switch (contextType) {
  65. case ContextType.ISSUE:
  66. return <IssueContext api={api} dataRow={dataRow} eventID={dataRow.id} />;
  67. case ContextType.RELEASE:
  68. return organization ? (
  69. <ReleaseContext api={api} dataRow={dataRow} organization={organization} />
  70. ) : (
  71. noContext
  72. );
  73. case ContextType.EVENT:
  74. return organization ? (
  75. <EventContext
  76. api={api}
  77. dataRow={dataRow}
  78. organization={organization}
  79. location={location}
  80. projects={projects}
  81. eventView={eventView}
  82. />
  83. ) : (
  84. noContext
  85. );
  86. default:
  87. return noContext;
  88. }
  89. }
  90. // NOTE: Will be adding switch cases as more contexts require headers.
  91. function getHoverHeader(dataRow: EventData, contextType: ContextType) {
  92. return isReleaseContext(contextType) ? (
  93. <VersionHoverHeader releaseVersion={dataRow.release} />
  94. ) : null;
  95. }
  96. const addFieldAsColumn = (
  97. fieldName: string,
  98. location?: Location,
  99. eventView?: EventView
  100. ) => {
  101. const oldField = location?.query.field || eventView?.fields.map(field => field.field);
  102. const newField = toArray(oldField).concat(fieldName);
  103. browserHistory.push({
  104. ...location,
  105. query: {
  106. ...location?.query,
  107. field: newField,
  108. },
  109. });
  110. };
  111. const fiveMinutesInMs = 5 * 60 * 1000;
  112. type IssueContextProps = {
  113. api: Client;
  114. dataRow: EventData;
  115. eventID?: string;
  116. };
  117. function IssueContext(props: IssueContextProps) {
  118. const statusTitle = t('Issue Status');
  119. const {isLoading, isError, data} = useQuery<Group>(
  120. [
  121. `/issues/${props.dataRow['issue.id']}/`,
  122. {
  123. query: {
  124. collapse: 'release',
  125. expand: 'inbox',
  126. },
  127. },
  128. ],
  129. {
  130. onSuccess: group => {
  131. GroupStore.add([group]);
  132. },
  133. staleTime: fiveMinutesInMs,
  134. retry: false,
  135. }
  136. );
  137. const renderStatus = () =>
  138. data && (
  139. <IssueContextContainer data-test-id="quick-context-issue-status-container">
  140. <ContextHeader>
  141. {statusTitle}
  142. <FeatureBadge type="alpha" />
  143. </ContextHeader>
  144. <ContextBody>
  145. {data.status === 'ignored' ? (
  146. <IconMute
  147. data-test-id="quick-context-ignored-icon"
  148. color="gray500"
  149. size="sm"
  150. />
  151. ) : data.status === 'resolved' ? (
  152. <IconCheckmark color="gray500" size="sm" />
  153. ) : (
  154. <IconNot
  155. data-test-id="quick-context-unresolved-icon"
  156. color="gray500"
  157. size="sm"
  158. />
  159. )}
  160. <StatusText>{data.status}</StatusText>
  161. </ContextBody>
  162. </IssueContextContainer>
  163. );
  164. const renderAssigneeSelector = () =>
  165. data && (
  166. <IssueContextContainer data-test-id="quick-context-assigned-to-container">
  167. <AssignedTo group={data} projectId={data.project.id} />
  168. </IssueContextContainer>
  169. );
  170. const renderSuspectCommits = () =>
  171. props.eventID &&
  172. data && (
  173. <IssueContextContainer data-test-id="quick-context-suspect-commits-container">
  174. <EventCause
  175. project={data.project}
  176. eventId={props.eventID}
  177. commitRow={QuickContextCommitRow}
  178. />
  179. </IssueContextContainer>
  180. );
  181. if (isLoading || isError) {
  182. return <NoContext isLoading={isLoading} />;
  183. }
  184. return (
  185. <Wrapper data-test-id="quick-context-hover-body">
  186. {renderStatus()}
  187. {renderAssigneeSelector()}
  188. {renderSuspectCommits()}
  189. </Wrapper>
  190. );
  191. }
  192. type NoContextProps = {
  193. isLoading: boolean;
  194. };
  195. function NoContext({isLoading}: NoContextProps) {
  196. return isLoading ? (
  197. <NoContextWrapper>
  198. <LoadingIndicator
  199. data-test-id="quick-context-loading-indicator"
  200. hideMessage
  201. size={32}
  202. />
  203. </NoContextWrapper>
  204. ) : (
  205. <NoContextWrapper>{t('Failed to load context for column.')}</NoContextWrapper>
  206. );
  207. }
  208. type BaseContextProps = {
  209. api: Client;
  210. dataRow: EventData;
  211. organization: Organization;
  212. };
  213. function ReleaseContext(props: BaseContextProps) {
  214. const {isLoading, isError, data} = useQuery<ReleaseWithHealth>(
  215. [`/organizations/${props.organization.slug}/releases/${props.dataRow.release}/`],
  216. {
  217. staleTime: fiveMinutesInMs,
  218. retry: false,
  219. }
  220. );
  221. const getCommitAuthorTitle = () => {
  222. const user = ConfigStore.get('user');
  223. const commitCount = data?.commitCount || 0;
  224. let authorsCount = data?.authors.length || 0;
  225. const userInAuthors =
  226. data &&
  227. data.authors.length >= 1 &&
  228. data.authors.find((author: User) => author.id && user.id && author.id === user.id);
  229. if (userInAuthors) {
  230. authorsCount = authorsCount - 1;
  231. return authorsCount !== 1 && commitCount !== 1
  232. ? tct('[commitCount] commits by you and [authorsCount] others', {
  233. commitCount,
  234. authorsCount,
  235. })
  236. : commitCount !== 1
  237. ? tct('[commitCount] commits by you and 1 other', {
  238. commitCount,
  239. })
  240. : authorsCount !== 1
  241. ? tct('1 commit by you and [authorsCount] others', {
  242. authorsCount,
  243. })
  244. : t('1 commit by you and 1 other');
  245. }
  246. return (
  247. data &&
  248. (authorsCount !== 1 && commitCount !== 1
  249. ? tct('[commitCount] commits by [authorsCount] authors', {
  250. commitCount,
  251. authorsCount,
  252. })
  253. : commitCount !== 1
  254. ? tct('[commitCount] commits by 1 author', {
  255. commitCount,
  256. })
  257. : authorsCount !== 1
  258. ? tct('1 commit by [authorsCount] authors', {
  259. authorsCount,
  260. })
  261. : t('1 commit by 1 author'))
  262. );
  263. };
  264. const renderReleaseDetails = () => {
  265. const statusText = data?.status === 'open' ? t('Active') : t('Archived');
  266. return (
  267. <ReleaseContextContainer data-test-id="quick-context-release-details-container">
  268. <ContextHeader>
  269. {t('Release Details')}
  270. <FeatureBadge type="alpha" />
  271. </ContextHeader>
  272. <ContextBody>
  273. <StyledKeyValueTable>
  274. <KeyValueTableRow keyName={t('Status')} value={statusText} />
  275. {data?.status === 'open' && (
  276. <Fragment>
  277. <KeyValueTableRow
  278. keyName={t('Created')}
  279. value={<TimeSince date={data.dateCreated} />}
  280. />
  281. <KeyValueTableRow
  282. keyName={t('First Event')}
  283. value={
  284. data.firstEvent ? <TimeSince date={data.firstEvent} /> : '\u2014'
  285. }
  286. />
  287. <KeyValueTableRow
  288. keyName={t('Last Event')}
  289. value={data.lastEvent ? <TimeSince date={data.lastEvent} /> : '\u2014'}
  290. />
  291. </Fragment>
  292. )}
  293. </StyledKeyValueTable>
  294. </ContextBody>
  295. </ReleaseContextContainer>
  296. );
  297. };
  298. const renderLastCommit = () =>
  299. data &&
  300. data.lastCommit && (
  301. <ReleaseContextContainer data-test-id="quick-context-release-last-commit-container">
  302. <ContextHeader>{t('Last Commit')}</ContextHeader>
  303. <DataSection>
  304. <Panel>
  305. <QuickContextCommitRow commit={data.lastCommit} />
  306. </Panel>
  307. </DataSection>
  308. </ReleaseContextContainer>
  309. );
  310. const renderIssueCountAndAuthors = () =>
  311. data && (
  312. <ReleaseContextContainer data-test-id="quick-context-release-issues-and-authors-container">
  313. <ContextRow>
  314. <div>
  315. <ContextHeader>{t('New Issues')}</ContextHeader>
  316. <ReleaseStatusBody>{data.newGroups}</ReleaseStatusBody>
  317. </div>
  318. <div>
  319. <ReleaseAuthorsTitle>{getCommitAuthorTitle()}</ReleaseAuthorsTitle>
  320. <ReleaseAuthorsBody>
  321. {data.commitCount === 0 ? (
  322. <IconNot color="gray500" size="md" />
  323. ) : (
  324. <AvatarList users={data.authors} />
  325. )}
  326. </ReleaseAuthorsBody>
  327. </div>
  328. </ContextRow>
  329. </ReleaseContextContainer>
  330. );
  331. if (isLoading || isError) {
  332. return <NoContext isLoading={isLoading} />;
  333. }
  334. return (
  335. <Wrapper data-test-id="quick-context-hover-body">
  336. {renderReleaseDetails()}
  337. {renderIssueCountAndAuthors()}
  338. {renderLastCommit()}
  339. </Wrapper>
  340. );
  341. }
  342. interface EventContextProps extends BaseContextProps {
  343. eventView?: EventView;
  344. location?: Location;
  345. projects?: Project[];
  346. }
  347. function EventContext(props: EventContextProps) {
  348. const {isLoading, isError, data} = useQuery<Event>(
  349. [
  350. `/organizations/${props.organization.slug}/events/${props.dataRow['project.name']}:${props.dataRow.id}/`,
  351. ],
  352. {
  353. staleTime: fiveMinutesInMs,
  354. }
  355. );
  356. if (isLoading || isError) {
  357. return <NoContext isLoading={isLoading} />;
  358. }
  359. if (data.type === 'transaction') {
  360. const traceId = data.contexts?.trace?.trace_id ?? '';
  361. const {start, end} = getTraceTimeRangeFromEvent(data);
  362. const project = props.projects?.find(p => p.slug === data.projectID);
  363. return (
  364. <Wrapper data-test-id="quick-context-hover-body">
  365. <EventContextContainer>
  366. <ContextHeader>
  367. <Title>
  368. {t('Transaction Duration')}
  369. {!('transaction.duration' in props.dataRow) && (
  370. <Tooltip
  371. skipWrapper
  372. title={t('Add transaction duration as a column')}
  373. position="right"
  374. >
  375. <IconAdd
  376. data-test-id="quick-context-transaction-duration-add-button"
  377. cursor="pointer"
  378. onClick={() =>
  379. addFieldAsColumn(
  380. 'transaction.duration',
  381. props.location,
  382. props.eventView
  383. )
  384. }
  385. color="gray300"
  386. size="xs"
  387. isCircled
  388. />
  389. </Tooltip>
  390. )}
  391. </Title>
  392. <FeatureBadge type="alpha" />
  393. </ContextHeader>
  394. <EventContextBody>
  395. {getDuration(data.endTimestamp - data.startTimestamp, 2, true)}
  396. </EventContextBody>
  397. </EventContextContainer>
  398. {props.location && (
  399. <EventContextContainer>
  400. <ContextHeader>
  401. <Title>
  402. {t('Status')}
  403. {!('tags[http.status_code]' in props.dataRow) && (
  404. <Tooltip
  405. skipWrapper
  406. title={t('Add HTTP status code as a column')}
  407. position="right"
  408. >
  409. <IconAdd
  410. data-test-id="quick-context-http-status-add-button"
  411. cursor="pointer"
  412. onClick={() =>
  413. addFieldAsColumn(
  414. 'tags[http.status_code]',
  415. props.location,
  416. props.eventView
  417. )
  418. }
  419. color="gray300"
  420. size="xs"
  421. isCircled
  422. />
  423. </Tooltip>
  424. )}
  425. </Title>
  426. </ContextHeader>
  427. <EventContextBody>
  428. <ContextRow>
  429. <TraceMetaQuery
  430. location={props.location}
  431. orgSlug={props.organization.slug}
  432. traceId={traceId}
  433. start={start}
  434. end={end}
  435. >
  436. {metaResults => getStatusBodyText(project, data, metaResults?.meta)}
  437. </TraceMetaQuery>
  438. <HttpStatusWrapper>
  439. (<HttpStatus event={data} />)
  440. </HttpStatusWrapper>
  441. </ContextRow>
  442. </EventContextBody>
  443. </EventContextContainer>
  444. )}
  445. </Wrapper>
  446. );
  447. }
  448. const stackTrace = getStacktrace(data);
  449. return stackTrace ? (
  450. <StackTraceWrapper>
  451. <StackTracePreviewContent event={data} stacktrace={stackTrace} />
  452. </StackTraceWrapper>
  453. ) : (
  454. <NoContextWrapper>
  455. {t('There is no stack trace available for this event.')}
  456. </NoContextWrapper>
  457. );
  458. }
  459. type ContextProps = {
  460. children: React.ReactNode;
  461. contextType: ContextType;
  462. dataRow: EventData;
  463. eventView?: EventView;
  464. organization?: Organization;
  465. projects?: Project[];
  466. };
  467. export function QuickContextHoverWrapper(props: ContextProps) {
  468. const api = useApi();
  469. const location = useLocation();
  470. const queryClient = useQueryClient();
  471. const {dataRow, contextType, organization, projects, eventView} = props;
  472. useEffect(() => {
  473. return () => {
  474. GroupStore.reset();
  475. queryClient.clear();
  476. };
  477. }, [queryClient]);
  478. return (
  479. <HoverWrapper>
  480. <StyledHovercard
  481. showUnderline
  482. delay={HOVER_DELAY}
  483. header={getHoverHeader(dataRow, contextType)}
  484. body={getHoverBody(
  485. api,
  486. dataRow,
  487. contextType,
  488. organization,
  489. location,
  490. projects,
  491. eventView
  492. )}
  493. >
  494. {props.children}
  495. </StyledHovercard>
  496. </HoverWrapper>
  497. );
  498. }
  499. const ContextContainer = styled('div')`
  500. display: flex;
  501. flex-direction: column;
  502. `;
  503. const StyledHovercard = styled(Hovercard)`
  504. ${Body} {
  505. padding: 0;
  506. }
  507. min-width: max-content;
  508. `;
  509. const HoverWrapper = styled('div')`
  510. display: flex;
  511. align-items: center;
  512. gap: ${space(0.75)};
  513. `;
  514. const IssueContextContainer = styled(ContextContainer)`
  515. ${SidebarSection.Wrap}, ${Panel}, h6 {
  516. margin: 0;
  517. }
  518. ${Panel} {
  519. border: none;
  520. box-shadow: none;
  521. }
  522. ${DataSection} {
  523. padding: 0;
  524. }
  525. ${CauseHeader}, ${SidebarSection.Title} {
  526. margin-top: ${space(2)};
  527. }
  528. ${CauseHeader} > h3,
  529. ${CauseHeader} > button {
  530. font-size: ${p => p.theme.fontSizeExtraSmall};
  531. font-weight: 600;
  532. text-transform: uppercase;
  533. }
  534. `;
  535. const ContextHeader = styled('h6')`
  536. color: ${p => p.theme.subText};
  537. display: flex;
  538. justify-content: space-between;
  539. align-items: center;
  540. margin: 0;
  541. `;
  542. const ContextBody = styled('div')`
  543. margin: ${space(1)} 0 0;
  544. width: 100%;
  545. text-align: left;
  546. font-size: ${p => p.theme.fontSizeLarge};
  547. display: flex;
  548. align-items: center;
  549. `;
  550. const EventContextBody = styled(ContextBody)`
  551. font-size: ${p => p.theme.fontSizeExtraLarge};
  552. margin: 0;
  553. align-items: flex-start;
  554. flex-direction: column;
  555. `;
  556. const StatusText = styled('span')`
  557. margin-left: ${space(1)};
  558. text-transform: capitalize;
  559. `;
  560. const Wrapper = styled('div')`
  561. background: ${p => p.theme.background};
  562. border-radius: ${p => p.theme.borderRadius};
  563. width: 300px;
  564. padding: ${space(1.5)};
  565. `;
  566. const NoContextWrapper = styled('div')`
  567. color: ${p => p.theme.subText};
  568. height: 50px;
  569. padding: ${space(1)};
  570. font-size: ${p => p.theme.fontSizeLarge};
  571. display: flex;
  572. flex-direction: column;
  573. align-items: center;
  574. justify-content: center;
  575. white-space: nowrap;
  576. `;
  577. const StyledKeyValueTable = styled(KeyValueTable)`
  578. width: 100%;
  579. margin: 0;
  580. `;
  581. const ReleaseContextContainer = styled(ContextContainer)`
  582. ${Panel} {
  583. margin: 0;
  584. border: none;
  585. box-shadow: none;
  586. }
  587. ${DataSection} {
  588. padding: 0;
  589. }
  590. & + & {
  591. margin-top: ${space(2)};
  592. }
  593. `;
  594. const EventContextContainer = styled(ContextContainer)`
  595. & + & {
  596. margin-top: ${space(2)};
  597. }
  598. `;
  599. const ReleaseAuthorsTitle = styled(ContextHeader)`
  600. max-width: 200px;
  601. text-align: right;
  602. `;
  603. const ContextRow = styled('div')`
  604. display: flex;
  605. justify-content: space-between;
  606. `;
  607. const ReleaseAuthorsBody = styled(ContextBody)`
  608. justify-content: right;
  609. `;
  610. const ReleaseStatusBody = styled('h4')`
  611. margin-bottom: 0;
  612. `;
  613. const StackTraceWrapper = styled('div')`
  614. overflow: hidden;
  615. max-height: 300px;
  616. width: 500px;
  617. overflow-y: auto;
  618. .traceback {
  619. margin-bottom: 0;
  620. border: 0;
  621. box-shadow: none;
  622. }
  623. border-radius: ${p => p.theme.borderRadius};
  624. `;
  625. const Title = styled('div')`
  626. display: flex;
  627. align-items: center;
  628. gap: ${space(0.5)};
  629. `;
  630. const HttpStatusWrapper = styled('span')`
  631. margin-left: ${space(0.5)};
  632. `;