traceTimelineEvents.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import {Fragment, useMemo} 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, traceEvents} = 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 = useMemo(
  34. () => getEventsByColumn(traceEvents, durationMs, totalColumns, paddedStartTime),
  35. [durationMs, traceEvents, totalColumns, paddedStartTime]
  36. );
  37. const columnSize = width / totalColumns;
  38. // If the duration is less than 2 minutes, show seconds
  39. const showTimelineSeconds = durationMs < 120 * 1000;
  40. const middleTimestamp = paddedStartTime + Math.floor(durationMs / 2);
  41. const leftMiddleTimestamp = paddedStartTime + Math.floor(durationMs / 4);
  42. const rightMiddleTimestamp = paddedStartTime + Math.floor((durationMs / 4) * 3);
  43. return (
  44. <Fragment>
  45. {/* Add padding to the total columns, 1 column of padding on each side */}
  46. <TimelineColumns style={{gridTemplateColumns: `repeat(${totalColumns + 2}, 1fr)`}}>
  47. {eventsByColumn.map(([column, colEvents]) => {
  48. // Calculate the timestamp range that this column represents
  49. const timeRange = getChunkTimeRange(
  50. paddedStartTime,
  51. column - 1,
  52. durationMs / totalColumns
  53. );
  54. const hasCurrentEvent = colEvents.some(e => e.id === event.id);
  55. return (
  56. <EventColumn
  57. key={`${column}-${hasCurrentEvent ? 'current-event' : 'regular'}`}
  58. // Add 1 to the column to account for the padding
  59. style={{gridColumn: Math.floor(column) + 1, width: columnSize}}
  60. >
  61. <NodeGroup
  62. event={event}
  63. colEvents={colEvents}
  64. columnSize={columnSize}
  65. timeRange={timeRange}
  66. currentEventId={event.id}
  67. currentColumn={column}
  68. />
  69. </EventColumn>
  70. );
  71. })}
  72. </TimelineColumns>
  73. <TimestampColumns>
  74. <DateTime date={paddedStartTime} seconds={showTimelineSeconds} timeOnly />
  75. <DateTime date={leftMiddleTimestamp} seconds={showTimelineSeconds} timeOnly />
  76. <DateTime date={middleTimestamp} seconds={showTimelineSeconds} timeOnly />
  77. <DateTime date={rightMiddleTimestamp} seconds={showTimelineSeconds} timeOnly />
  78. <DateTime date={paddedEndTime} seconds={showTimelineSeconds} timeOnly />
  79. </TimestampColumns>
  80. </Fragment>
  81. );
  82. }
  83. /**
  84. * Use grid to create columns that we can place child nodes into.
  85. * Leveraging grid for alignment means we don't need to calculate percent offset
  86. * nor use position:absolute to lay out items.
  87. *
  88. * <Columns>
  89. * <Col>...</Col>
  90. * <Col>...</Col>
  91. * </Columns>
  92. */
  93. const TimelineColumns = styled('div')`
  94. /* Reset defaults for <ul> */
  95. list-style: none;
  96. margin: 0;
  97. padding: 0;
  98. /* Layout of the lines */
  99. display: grid;
  100. margin-top: -1px;
  101. height: 0;
  102. `;
  103. const TimestampColumns = styled('div')`
  104. display: flex;
  105. align-items: center;
  106. justify-content: space-between;
  107. margin-top: ${space(1)};
  108. text-align: center;
  109. color: ${p => p.theme.subText};
  110. font-size: ${p => p.theme.fontSizeSmall};
  111. `;
  112. function NodeGroup({
  113. event,
  114. timeRange,
  115. colEvents,
  116. columnSize,
  117. currentEventId,
  118. currentColumn,
  119. }: {
  120. colEvents: TimelineEvent[];
  121. columnSize: number;
  122. currentColumn: number;
  123. currentEventId: string;
  124. event: Event;
  125. timeRange: [number, number];
  126. }) {
  127. const totalSubColumns = Math.floor(columnSize / CHILD_WIDTH);
  128. const {eventsByColumn, columns} = useMemo(() => {
  129. const durationMs = timeRange[1] - timeRange[0];
  130. const eventColumns = getEventsByColumn(
  131. colEvents,
  132. durationMs,
  133. totalSubColumns,
  134. timeRange[0]
  135. );
  136. return {
  137. eventsByColumn: eventColumns,
  138. columns: eventColumns.map<number>(([column]) => column).sort(),
  139. };
  140. }, [colEvents, totalSubColumns, timeRange]);
  141. return (
  142. <Fragment>
  143. <TimelineColumns style={{gridTemplateColumns: `repeat(${totalSubColumns}, 1fr)`}}>
  144. {eventsByColumn.map(([column, groupEvents]) => {
  145. const isCurrentNode = groupEvents.some(e => e.id === currentEventId);
  146. return (
  147. <EventColumn key={`${column}-currrent-event`} style={{gridColumn: column}}>
  148. {isCurrentNode ? (
  149. <CurrentNodeContainer aria-label={t('Current Event')}>
  150. <CurrentNodeRing />
  151. <CurrentIconNode />
  152. </CurrentNodeContainer>
  153. ) : (
  154. groupEvents
  155. .slice(0, 5)
  156. .map(groupEvent =>
  157. 'event.type' in groupEvent ? (
  158. <IconNode key={groupEvent.id} />
  159. ) : (
  160. <PerformanceIconNode key={groupEvent.id} />
  161. )
  162. )
  163. )}
  164. </EventColumn>
  165. );
  166. })}
  167. </TimelineColumns>
  168. <TimelineColumns style={{gridTemplateColumns: `repeat(${totalSubColumns}, 1fr)`}}>
  169. <Tooltip
  170. title={<TraceTimelineTooltip event={event} timelineEvents={colEvents} />}
  171. overlayStyle={{
  172. padding: `0 !important`,
  173. }}
  174. position="bottom"
  175. isHoverable
  176. skipWrapper
  177. >
  178. <TooltipHelper
  179. style={{
  180. gridColumn:
  181. columns.length > 1
  182. ? `${columns.at(0)} / ${columns.at(-1)}`
  183. : columns.at(0)!,
  184. width: 8 * columns.length,
  185. }}
  186. data-test-id={`trace-timeline-tooltip-${currentColumn}`}
  187. />
  188. </Tooltip>
  189. </TimelineColumns>
  190. </Fragment>
  191. );
  192. }
  193. const EventColumn = styled('div')`
  194. place-items: stretch;
  195. display: grid;
  196. align-items: center;
  197. position: relative;
  198. &:hover {
  199. z-index: ${p => p.theme.zIndex.initial};
  200. }
  201. `;
  202. const IconNode = styled('div')`
  203. position: absolute;
  204. grid-column: 1;
  205. grid-row: 1;
  206. width: 8px;
  207. height: 8px;
  208. border-radius: 50%;
  209. color: ${p => p.theme.white};
  210. box-shadow: ${p => p.theme.dropShadowLight};
  211. user-select: none;
  212. background-color: ${p => color(p.theme.red200).alpha(0.3).string()};
  213. margin-left: -8px;
  214. `;
  215. const PerformanceIconNode = styled(IconNode)`
  216. background-color: unset;
  217. border: 1px solid ${p => p.theme.red300};
  218. `;
  219. const CurrentNodeContainer = styled('div')`
  220. position: absolute;
  221. grid-column: 1;
  222. grid-row: 1;
  223. width: 12px;
  224. height: 12px;
  225. `;
  226. const CurrentNodeRing = styled('div')`
  227. border: 1px solid ${p => p.theme.red300};
  228. height: 24px;
  229. width: 24px;
  230. border-radius: 100%;
  231. position: absolute;
  232. top: -6px;
  233. left: -16px;
  234. animation: pulse 2s ease-out infinite;
  235. @keyframes pulse {
  236. 0% {
  237. transform: scale(0.1, 0.1);
  238. opacity: 0;
  239. }
  240. 50% {
  241. transform: scale(0.1, 0.1);
  242. opacity: 0;
  243. }
  244. 70% {
  245. opacity: 1;
  246. }
  247. 100% {
  248. transform: scale(1.2, 1.2);
  249. opacity: 0;
  250. }
  251. }
  252. `;
  253. const CurrentIconNode = styled(IconNode)`
  254. background-color: ${p => p.theme.red300};
  255. width: 12px;
  256. height: 12px;
  257. margin-left: -10px;
  258. `;
  259. const TooltipHelper = styled('span')`
  260. height: 12px;
  261. margin-left: -8px;
  262. margin-top: -6px;
  263. z-index: 1;
  264. `;