traceTimelineEvents.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import color from 'color';
  4. import DateTime from 'sentry/components/dateTime';
  5. import {Tooltip} from 'sentry/components/tooltip';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {Event} from 'sentry/types';
  9. import {TraceTimelineTooltip} from 'sentry/views/issueDetails/traceTimeline/traceTimelineTooltip';
  10. import type {TimelineEvent} from './useTraceTimelineEvents';
  11. import {useTraceTimelineEvents} from './useTraceTimelineEvents';
  12. import {getChunkTimeRange, getEventsByColumn} from './utils';
  13. // Adjusting this will change the number of tooltip groups
  14. const PARENT_WIDTH = 12;
  15. // Adjusting subwidth changes how many dots to render
  16. const CHILD_WIDTH = 4;
  17. interface TraceTimelineEventsProps {
  18. event: Event;
  19. width: number;
  20. }
  21. export function TraceTimelineEvents({event, width}: TraceTimelineEventsProps) {
  22. const {startTimestamp, endTimestamp, data} = useTraceTimelineEvents({event});
  23. let paddedStartTime = startTimestamp;
  24. let paddedEndTime = endTimestamp;
  25. // Duration is 0, pad both sides, this is how we end up with 1 dot in the middle
  26. if (endTimestamp - startTimestamp === 0) {
  27. // If the duration is 0, we need to pad the end time
  28. paddedEndTime = startTimestamp + 1500;
  29. paddedStartTime = startTimestamp - 1500;
  30. }
  31. const durationMs = paddedEndTime - paddedStartTime;
  32. const totalColumns = Math.floor(width / PARENT_WIDTH);
  33. const eventsByColumn = getEventsByColumn(
  34. durationMs,
  35. data,
  36. totalColumns,
  37. paddedStartTime
  38. );
  39. const columnSize = width / totalColumns;
  40. // If the duration is less than 2 minutes, show seconds
  41. const showTimelineSeconds = durationMs < 120 * 1000;
  42. return (
  43. <Fragment>
  44. {/* Add padding to the total columns, 1 column of padding on each side */}
  45. <TimelineColumns totalColumns={totalColumns + 2}>
  46. {Array.from(eventsByColumn.entries()).map(([column, colEvents]) => {
  47. // Calculate the timestamp range that this column represents
  48. const timeRange = getChunkTimeRange(
  49. paddedStartTime,
  50. column - 1,
  51. durationMs / totalColumns
  52. );
  53. return (
  54. <EventColumn
  55. key={column}
  56. // Add 1 to the column to account for the padding
  57. style={{gridColumn: Math.floor(column) + 1, width: columnSize}}
  58. >
  59. <NodeGroup
  60. event={event}
  61. colEvents={colEvents}
  62. columnSize={columnSize}
  63. timeRange={timeRange}
  64. currentEventId={event.id}
  65. />
  66. </EventColumn>
  67. );
  68. })}
  69. </TimelineColumns>
  70. <TimestampColumns>
  71. <TimestampItem style={{textAlign: 'left'}}>
  72. <DateTime date={paddedStartTime} seconds={showTimelineSeconds} timeOnly />
  73. </TimestampItem>
  74. <TimestampItem style={{textAlign: 'center'}}>
  75. <DateTime
  76. date={paddedStartTime + Math.floor(durationMs / 2)}
  77. seconds={showTimelineSeconds}
  78. timeOnly
  79. />
  80. </TimestampItem>
  81. <TimestampItem style={{textAlign: 'right'}}>
  82. <DateTime date={paddedEndTime} seconds={showTimelineSeconds} timeOnly />
  83. </TimestampItem>
  84. </TimestampColumns>
  85. </Fragment>
  86. );
  87. }
  88. /**
  89. * Use grid to create columns that we can place child nodes into.
  90. * Leveraging grid for alignment means we don't need to calculate percent offset
  91. * nor use position:absolute to lay out items.
  92. *
  93. * <Columns>
  94. * <Col>...</Col>
  95. * <Col>...</Col>
  96. * </Columns>
  97. */
  98. const TimelineColumns = styled('ul')<{totalColumns: number}>`
  99. /* Reset defaults for <ul> */
  100. list-style: none;
  101. margin: 0;
  102. padding: 0;
  103. /* Layout of the lines */
  104. display: grid;
  105. grid-template-columns: repeat(${p => p.totalColumns}, 1fr);
  106. margin-top: -1px;
  107. height: 0;
  108. `;
  109. const TimestampColumns = styled('div')`
  110. display: grid;
  111. grid-template-columns: repeat(3, 1fr);
  112. margin-top: ${space(1)};
  113. `;
  114. const TimestampItem = styled('div')`
  115. place-items: stretch;
  116. display: grid;
  117. align-items: center;
  118. position: relative;
  119. color: ${p => p.theme.subText};
  120. font-size: ${p => p.theme.fontSizeSmall};
  121. `;
  122. function NodeGroup({
  123. event,
  124. timeRange,
  125. colEvents,
  126. columnSize,
  127. currentEventId,
  128. }: {
  129. colEvents: TimelineEvent[];
  130. columnSize: number;
  131. currentEventId: string;
  132. event: Event;
  133. timeRange: [number, number];
  134. }) {
  135. const totalSubColumns = Math.floor(columnSize / CHILD_WIDTH);
  136. const durationMs = timeRange[1] - timeRange[0];
  137. const eventsByColumn = getEventsByColumn(
  138. durationMs,
  139. colEvents,
  140. totalSubColumns,
  141. timeRange[0]
  142. );
  143. const columns = Array.from(eventsByColumn.keys());
  144. const minColumn = Math.min(...columns);
  145. const maxColumn = Math.max(...columns);
  146. return (
  147. <Fragment>
  148. <TimelineColumns totalColumns={totalSubColumns}>
  149. {Array.from(eventsByColumn.entries()).map(([column, groupEvents]) => {
  150. const isCurrentNode = groupEvents.some(e => e.id === currentEventId);
  151. return (
  152. <EventColumn key={column} style={{gridColumn: Math.floor(column)}}>
  153. {groupEvents.map(groupEvent => (
  154. <Fragment key={groupEvent.id}>
  155. {isCurrentNode ? (
  156. <CurrentNodeContainer aria-label={t('Current Event')}>
  157. <CurrentNodeRing />
  158. <CurrentIconNode />
  159. </CurrentNodeContainer>
  160. ) : !('event.type' in groupEvent) ? (
  161. <PerformanceIconNode />
  162. ) : (
  163. <IconNode />
  164. )}
  165. </Fragment>
  166. ))}
  167. </EventColumn>
  168. );
  169. })}
  170. </TimelineColumns>
  171. <TimelineColumns totalColumns={totalSubColumns}>
  172. <Tooltip
  173. title={<TraceTimelineTooltip event={event} timelineEvents={colEvents} />}
  174. overlayStyle={{
  175. padding: `0 !important`,
  176. }}
  177. offset={10}
  178. position="bottom"
  179. isHoverable
  180. skipWrapper
  181. >
  182. <TooltipHelper
  183. style={{
  184. gridColumn: columns.length > 1 ? `${minColumn} / ${maxColumn}` : columns[0],
  185. }}
  186. />
  187. </Tooltip>
  188. </TimelineColumns>
  189. </Fragment>
  190. );
  191. }
  192. const EventColumn = styled('li')`
  193. place-items: stretch;
  194. display: grid;
  195. align-items: center;
  196. position: relative;
  197. &:hover {
  198. z-index: ${p => p.theme.zIndex.initial};
  199. }
  200. `;
  201. const IconNode = styled('div')`
  202. position: absolute;
  203. grid-column: 1;
  204. grid-row: 1;
  205. width: 8px;
  206. height: 8px;
  207. border-radius: 50%;
  208. color: ${p => p.theme.white};
  209. box-shadow: ${p => p.theme.dropShadowLight};
  210. user-select: none;
  211. background-color: ${p => color(p.theme.red200).alpha(0.3).string()};
  212. `;
  213. const PerformanceIconNode = styled(IconNode)`
  214. background-color: unset;
  215. border: 1px solid ${p => color(p.theme.red300).alpha(0.3).string()};
  216. `;
  217. const CurrentNodeContainer = styled('div')`
  218. position: absolute;
  219. grid-column: 1;
  220. grid-row: 1;
  221. width: 8px;
  222. height: 8px;
  223. `;
  224. const CurrentNodeRing = styled('div')`
  225. border: 1px solid ${p => p.theme.red300};
  226. height: 16px;
  227. width: 16px;
  228. border-radius: 100%;
  229. position: absolute;
  230. top: -4px;
  231. left: -4px;
  232. animation: pulse 1s ease-out infinite;
  233. @keyframes pulse {
  234. 0% {
  235. transform: scale(0.1, 0.1);
  236. opacity: 0.0;
  237. }
  238. 50% {
  239. opacity: 1.0;
  240. }
  241. 100% {
  242. transform: scale(1.2, 1.2);
  243. opacity: 0.0;
  244. }
  245. }
  246. `;
  247. const CurrentIconNode = styled(IconNode)`
  248. background-color: ${p => p.theme.red300};
  249. border-radius: 50%;
  250. position: absolute;
  251. `;
  252. const TooltipHelper = styled('span')`
  253. height: 8px;
  254. margin-top: -4px;
  255. margin-right: -2px;
  256. min-width: 8px;
  257. z-index: ${p => p.theme.zIndex.issuesList.stickyHeader};
  258. `;