issueCronCheckTimeline.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import {Fragment, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import {CheckInPlaceholder} from 'sentry/components/checkInTimeline/checkInPlaceholder';
  4. import {CheckInTimeline} from 'sentry/components/checkInTimeline/checkInTimeline';
  5. import {
  6. Gridline,
  7. GridLineLabels,
  8. GridLineOverlay,
  9. } from 'sentry/components/checkInTimeline/gridLines';
  10. import {useTimeWindowConfig} from 'sentry/components/checkInTimeline/hooks/useTimeWindowConfig';
  11. import type {StatsBucket} from 'sentry/components/checkInTimeline/types';
  12. import {Flex} from 'sentry/components/container/flex';
  13. import {Tooltip} from 'sentry/components/tooltip';
  14. import {tct} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Event} from 'sentry/types/event';
  17. import type {Group} from 'sentry/types/group';
  18. import {useApiQuery} from 'sentry/utils/queryClient';
  19. import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
  20. import {useDimensions} from 'sentry/utils/useDimensions';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import {useUser} from 'sentry/utils/useUser';
  23. import {useIssueDetails} from 'sentry/views/issueDetails/streamline/context';
  24. import {getGroupEventQueryKey} from 'sentry/views/issueDetails/utils';
  25. import {MonitorIndicator} from 'sentry/views/monitors/components/monitorIndicator';
  26. import {CheckInStatus, type MonitorBucket} from 'sentry/views/monitors/types';
  27. import {
  28. checkInStatusPrecedent,
  29. statusToText,
  30. tickStyle,
  31. } from 'sentry/views/monitors/utils';
  32. import {selectCheckInData} from 'sentry/views/monitors/utils/selectCheckInData';
  33. import {useMonitorStats} from 'sentry/views/monitors/utils/useMonitorStats';
  34. export function useCronIssueAlertId({groupId}: {groupId: string}): string | undefined {
  35. /**
  36. * This should be removed once the cron rule value is set on the issue.
  37. * This will fetch an event from the max range if the detector details
  38. * are not available (e.g. time range has changed and page refreshed)
  39. */
  40. const user = useUser();
  41. const organization = useOrganization();
  42. const {detectorDetails} = useIssueDetails();
  43. const {detectorId, detectorType} = detectorDetails;
  44. const hasCronDetector = detectorId && detectorType === 'cron_monitor';
  45. const {data: event} = useApiQuery<Event>(
  46. getGroupEventQueryKey({
  47. orgSlug: organization.slug,
  48. groupId,
  49. eventId: user.options.defaultIssueEvent,
  50. environments: [],
  51. }),
  52. {
  53. staleTime: Infinity,
  54. enabled: !hasCronDetector,
  55. retry: false,
  56. }
  57. );
  58. // Fall back to the fetched event since the legacy UI isn't nested within the provider the provider
  59. return hasCronDetector
  60. ? detectorId
  61. : event?.tags?.find(({key}) => key === 'monitor.id')?.value;
  62. }
  63. function useCronLegendStatuses({
  64. bucketStats,
  65. }: {
  66. bucketStats: MonitorBucket[];
  67. }): CheckInStatus[] {
  68. /**
  69. * Extract a list of statuses that have occurred at least once in the bucket stats.
  70. */
  71. return useMemo(() => {
  72. const statusMap: Record<CheckInStatus, boolean> = {
  73. [CheckInStatus.OK]: true,
  74. [CheckInStatus.ERROR]: false,
  75. [CheckInStatus.IN_PROGRESS]: false,
  76. [CheckInStatus.MISSED]: false,
  77. [CheckInStatus.TIMEOUT]: false,
  78. [CheckInStatus.UNKNOWN]: false,
  79. };
  80. bucketStats?.forEach(([_timestamp, bucketEnvMapping]) => {
  81. const bucketEnvMappingEntries: Array<StatsBucket<CheckInStatus>> =
  82. Object.values(bucketEnvMapping);
  83. for (const statBucket of bucketEnvMappingEntries) {
  84. const statBucketEntries = Object.entries(statBucket) as Array<
  85. [CheckInStatus, number]
  86. >;
  87. for (const [status, count] of statBucketEntries) {
  88. if (count > 0 && !statusMap[status]) {
  89. statusMap[status] = true;
  90. }
  91. }
  92. }
  93. });
  94. return (Object.keys(statusMap) as CheckInStatus[]).filter(
  95. status => statusMap[status]
  96. );
  97. }, [bucketStats]);
  98. }
  99. export function IssueCronCheckTimeline({group}: {group: Group}) {
  100. const elementRef = useRef<HTMLDivElement>(null);
  101. const {width: containerWidth} = useDimensions<HTMLDivElement>({elementRef});
  102. const timelineWidth = useDebouncedValue(containerWidth, 500);
  103. const timeWindowConfig = useTimeWindowConfig({timelineWidth});
  104. const cronAlertId = useCronIssueAlertId({groupId: group.id});
  105. const {data: stats, isPending} = useMonitorStats({
  106. monitors: cronAlertId ? [cronAlertId] : [],
  107. timeWindowConfig,
  108. });
  109. const cronStats = useMemo(() => {
  110. if (!cronAlertId) {
  111. return [];
  112. }
  113. return stats?.[cronAlertId] ?? [];
  114. }, [cronAlertId, stats]);
  115. const statEnvironments = useMemo(() => {
  116. const envSet = cronStats.reduce((acc, [_, envs]) => {
  117. Object.keys(envs).forEach(env => acc.add(env));
  118. return acc;
  119. }, new Set<string>());
  120. return [...envSet];
  121. }, [cronStats]);
  122. const legendStatuses = useCronLegendStatuses({
  123. bucketStats: cronStats,
  124. });
  125. return (
  126. <ChartContainer envCount={statEnvironments.length}>
  127. <TimelineLegend ref={elementRef} role="caption">
  128. {!isPending &&
  129. legendStatuses.map(status => (
  130. <Flex align="center" gap={space(0.5)} key={status}>
  131. <MonitorIndicator status={status} size={8} />
  132. <TimelineLegendText>{statusToText[status]}</TimelineLegendText>
  133. </Flex>
  134. ))}
  135. </TimelineLegend>
  136. <IssueGridLineOverlay
  137. allowZoom
  138. showCursor
  139. timeWindowConfig={timeWindowConfig}
  140. labelPosition="center-bottom"
  141. envCount={statEnvironments.length}
  142. />
  143. <IssueGridLineLabels
  144. timeWindowConfig={timeWindowConfig}
  145. labelPosition="center-bottom"
  146. envCount={statEnvironments.length}
  147. />
  148. <TimelineContainer>
  149. {isPending ? (
  150. <CheckInPlaceholder />
  151. ) : (
  152. <Fragment>
  153. {statEnvironments.map((env, envIndex) => (
  154. <Fragment key={env}>
  155. {statEnvironments.length > 1 && (
  156. <EnvironmentLabel
  157. title={tct('Environment: [env]', {env})}
  158. style={{
  159. top: envIndex * totalHeight + timelineHeight,
  160. }}
  161. >
  162. {env}
  163. </EnvironmentLabel>
  164. )}
  165. <CheckInTimeline
  166. style={{
  167. top: envIndex * (environmentHeight + paddingHeight),
  168. }}
  169. bucketedData={stats && env ? selectCheckInData(cronStats, env) : []}
  170. statusLabel={statusToText}
  171. statusStyle={tickStyle}
  172. statusPrecedent={checkInStatusPrecedent}
  173. timeWindowConfig={timeWindowConfig}
  174. />
  175. </Fragment>
  176. ))}
  177. </Fragment>
  178. )}
  179. </TimelineContainer>
  180. </ChartContainer>
  181. );
  182. }
  183. const timelineHeight = 14;
  184. const environmentHeight = 16;
  185. const paddingHeight = 8;
  186. const totalHeight = timelineHeight + environmentHeight + paddingHeight;
  187. const ChartContainer = styled('div')<{envCount: number}>`
  188. position: relative;
  189. width: 100%;
  190. min-height: ${p => Math.max(p.envCount - 1, 0) * totalHeight + 104}px;
  191. `;
  192. const TimelineLegend = styled('div')`
  193. position: absolute;
  194. width: 100%;
  195. user-select: none;
  196. display: flex;
  197. gap: ${space(1)};
  198. margin-top: ${space(1.5)};
  199. `;
  200. const TimelineLegendText = styled('div')`
  201. color: ${p => p.theme.subText};
  202. font-size: ${p => p.theme.fontSizeSmall};
  203. `;
  204. const TimelineContainer = styled('div')`
  205. position: absolute;
  206. top: 36px;
  207. width: 100%;
  208. `;
  209. const EnvironmentLabel = styled(Tooltip)`
  210. position: absolute;
  211. user-select: none;
  212. left: 0;
  213. font-weight: ${p => p.theme.fontWeightBold};
  214. font-size: ${p => p.theme.fontSizeExtraSmall};
  215. color: ${p => p.theme.subText};
  216. white-space: nowrap;
  217. `;
  218. const IssueGridLineLabels = styled(GridLineLabels)<{envCount: number}>`
  219. top: ${p => Math.max(p.envCount - 1, 0) * totalHeight + 68}px;
  220. `;
  221. const IssueGridLineOverlay = styled(GridLineOverlay)<{envCount: number}>`
  222. ${Gridline} {
  223. top: ${p => Math.max(p.envCount - 1, 0) * totalHeight + 68}px;
  224. }
  225. `;