Browse Source

feat(crons): Add tooltip to timeline component (#51179)

Adds a tooltip like this

<img width="650" alt="image"
src="https://github.com/getsentry/sentry/assets/9372512/a8b32ae5-68dc-4478-8f88-93abcac466e0">
David Wang 1 year ago
parent
commit
78c70d610f

+ 38 - 41
static/app/views/monitors/components/checkInTimeline.tsx

@@ -1,12 +1,14 @@
-import {Theme} from '@emotion/react';
 import styled from '@emotion/styled';
 
-import DateTime from 'sentry/components/dateTime';
 import {Resizeable} from 'sentry/components/replays/resizeable';
-import {Tooltip} from 'sentry/components/tooltip';
 import {space} from 'sentry/styles/space';
-import {MonitorBucketData} from 'sentry/views/monitors/components/overviewTimeline/types';
+import {JobTickTooltip} from 'sentry/views/monitors/components/overviewTimeline/jobTickTooltip';
+import {
+  MonitorBucketData,
+  TimeWindow,
+} from 'sentry/views/monitors/components/overviewTimeline/types';
 import {CheckInStatus} from 'sentry/views/monitors/types';
+import {getColorsFromStatus} from 'sentry/views/monitors/utils';
 import {getAggregateStatus} from 'sentry/views/monitors/utils/getAggregateStatus';
 import {mergeBuckets} from 'sentry/views/monitors/utils/mergeBuckets';
 
@@ -14,20 +16,10 @@ interface Props {
   bucketedData: MonitorBucketData;
   end: Date;
   start: Date;
+  timeWindow: TimeWindow;
   width?: number;
 }
 
-function getColorFromStatus(status: CheckInStatus, theme: Theme) {
-  const statusToColor: Record<CheckInStatus, string> = {
-    [CheckInStatus.ERROR]: theme.red200,
-    [CheckInStatus.TIMEOUT]: theme.red200,
-    [CheckInStatus.OK]: theme.green200,
-    [CheckInStatus.MISSED]: theme.yellow200,
-    [CheckInStatus.IN_PROGRESS]: theme.disabled,
-  };
-  return statusToColor[status];
-}
-
 function getBucketedCheckInsPosition(
   timestamp: number,
   timelineStart: Date,
@@ -38,35 +30,43 @@ function getBucketedCheckInsPosition(
 }
 
 export function CheckInTimeline(props: Props) {
-  const {bucketedData, start, end} = props;
+  const {bucketedData, start, end, timeWindow} = props;
 
   function renderTimelineWithWidth(width: number) {
-    const timeWindow = end.getTime() - start.getTime();
-    const msPerPixel = timeWindow / width;
+    const elapsedMs = end.getTime() - start.getTime();
+    const msPerPixel = elapsedMs / width;
 
     const jobTicks = mergeBuckets(bucketedData);
 
     return (
       <TimelineContainer>
-        {jobTicks.map(
-          ({startTs, width: tickWidth, envMapping, roundedLeft, roundedRight}) => {
-            const timestampMs = startTs * 1000;
-            const left = getBucketedCheckInsPosition(timestampMs, start, msPerPixel);
+        {jobTicks.map(jobTick => {
+          const {
+            startTs,
+            width: tickWidth,
+            envMapping,
+            roundedLeft,
+            roundedRight,
+          } = jobTick;
+          const timestampMs = startTs * 1000;
+          const left = getBucketedCheckInsPosition(timestampMs, start, msPerPixel);
 
-            return (
-              <JobTickContainer style={{left}} key={startTs}>
-                <Tooltip title={<DateTime date={timestampMs} seconds />}>
-                  <JobTick
-                    style={{width: tickWidth}}
-                    status={getAggregateStatus(envMapping)}
-                    roundedLeft={roundedLeft}
-                    roundedRight={roundedRight}
-                  />
-                </Tooltip>
-              </JobTickContainer>
-            );
-          }
-        )}
+          return (
+            <JobTickTooltip
+              jobTick={jobTick}
+              timeWindow={timeWindow}
+              skipWrapper
+              key={startTs}
+            >
+              <JobTick
+                style={{left, width: tickWidth}}
+                status={getAggregateStatus(envMapping)}
+                roundedLeft={roundedLeft}
+                roundedRight={roundedRight}
+              />
+            </JobTickTooltip>
+          );
+        })}
       </TimelineContainer>
     );
   }
@@ -84,16 +84,13 @@ const TimelineContainer = styled('div')`
   margin: ${space(2)} 0;
 `;
 
-const JobTickContainer = styled('div')`
-  position: absolute;
-`;
-
 const JobTick = styled('div')<{
   roundedLeft: boolean;
   roundedRight: boolean;
   status: CheckInStatus;
 }>`
-  background: ${p => getColorFromStatus(p.status, p.theme)};
+  position: absolute;
+  background: ${p => getColorsFromStatus(p.status, p.theme).tickColor};
   width: 4px;
   height: 14px;
   ${p =>

+ 1 - 8
static/app/views/monitors/components/monitorCheckIns.tsx

@@ -22,6 +22,7 @@ import {
   Monitor,
   MonitorEnvironment,
 } from 'sentry/views/monitors/types';
+import {statusToText} from 'sentry/views/monitors/utils';
 
 type Props = {
   monitor: Monitor;
@@ -40,14 +41,6 @@ const checkStatusToIndicatorStatus: Record<
   [CheckInStatus.TIMEOUT]: 'error',
 };
 
-const statusToText: Record<CheckInStatus, string> = {
-  [CheckInStatus.OK]: t('Okay'),
-  [CheckInStatus.ERROR]: t('Failed'),
-  [CheckInStatus.IN_PROGRESS]: t('In Progress'),
-  [CheckInStatus.MISSED]: t('Missed'),
-  [CheckInStatus.TIMEOUT]: t('Timed Out'),
-};
-
 function MonitorCheckIns({monitor, monitorEnvs, orgId}: Props) {
   const location = useLocation();
   const queryKey = [

+ 1 - 0
static/app/views/monitors/components/overviewTimeline/index.tsx

@@ -104,6 +104,7 @@ export function OverviewTimeline({monitorList}: Props) {
           ) : (
             <div>
               <CheckInTimeline
+                timeWindow={timeWindow}
                 bucketedData={monitorStats[monitor.slug]}
                 end={nowRef.current}
                 start={start}

+ 111 - 0
static/app/views/monitors/components/overviewTimeline/jobTickTooltip.spec.tsx

@@ -0,0 +1,111 @@
+import {render, screen, within} from 'sentry-test/reactTestingLibrary';
+
+import {JobTickTooltip} from 'sentry/views/monitors/components/overviewTimeline/jobTickTooltip';
+
+type StatusCounts = [ok: number, missed: number, timeout: number, error: number];
+
+export function generateEnvMapping(name: string, counts: StatusCounts) {
+  const [ok, missed, timeout, error] = counts;
+  return {
+    [name]: {ok, missed, timeout, error},
+  };
+}
+
+describe('JobTickTooltip', function () {
+  it('renders tooltip representing single job run', function () {
+    const startTs = new Date('2023-06-15T11:00:00Z').valueOf();
+    const endTs = startTs;
+    const envMapping = generateEnvMapping('prod', [0, 1, 0, 0]);
+    const jobTick = {
+      startTs,
+      envMapping,
+      roundedLeft: false,
+      roundedRight: false,
+      endTs,
+      width: 4,
+    };
+
+    render(<JobTickTooltip jobTick={jobTick} timeWindow="1h" forceVisible />);
+
+    // Skip the header row
+    const statusRow = screen.getAllByRole('row')[1];
+
+    expect(within(statusRow).getByText('Missed')).toBeInTheDocument();
+    expect(within(statusRow).getByText('prod')).toBeInTheDocument();
+    expect(within(statusRow).getByText('1')).toBeInTheDocument();
+  });
+
+  it('renders tooltip representing multiple job runs 1 env', function () {
+    const startTs = new Date('2023-06-15T11:00:00Z').valueOf();
+    const endTs = startTs;
+    const envMapping = generateEnvMapping('prod', [1, 1, 1, 1]);
+    const jobTick = {
+      startTs,
+      envMapping,
+      roundedLeft: false,
+      roundedRight: false,
+      endTs,
+      width: 4,
+    };
+
+    render(<JobTickTooltip jobTick={jobTick} timeWindow="1h" forceVisible />);
+
+    const okayRow = screen.getAllByRole('row')[1];
+    expect(within(okayRow).getByText('Okay')).toBeInTheDocument();
+    expect(within(okayRow).getByText('prod')).toBeInTheDocument();
+    expect(within(okayRow).getByText('1')).toBeInTheDocument();
+
+    const missedRow = screen.getAllByRole('row')[2];
+    expect(within(missedRow).getByText('Missed')).toBeInTheDocument();
+    expect(within(missedRow).getByText('prod')).toBeInTheDocument();
+    expect(within(missedRow).getByText('1')).toBeInTheDocument();
+
+    const timeoutRow = screen.getAllByRole('row')[3];
+    expect(within(timeoutRow).getByText('Timed Out')).toBeInTheDocument();
+    expect(within(timeoutRow).getByText('prod')).toBeInTheDocument();
+    expect(within(timeoutRow).getByText('1')).toBeInTheDocument();
+
+    const errorRow = screen.getAllByRole('row')[4];
+    expect(within(errorRow).getByText('Failed')).toBeInTheDocument();
+    expect(within(errorRow).getByText('prod')).toBeInTheDocument();
+    expect(within(errorRow).getByText('1')).toBeInTheDocument();
+  });
+
+  it('renders tooltip representing multiple job runs multiple envs', function () {
+    const startTs = new Date('2023-06-15T11:00:00Z').valueOf();
+    const endTs = startTs;
+    const prodEnvMapping = generateEnvMapping('prod', [0, 1, 0, 0]);
+    const devEnvMapping = generateEnvMapping('dev', [1, 2, 1, 0]);
+    const envMapping = {...prodEnvMapping, ...devEnvMapping};
+    const jobTick = {
+      startTs,
+      envMapping,
+      roundedLeft: false,
+      roundedRight: false,
+      endTs,
+      width: 4,
+    };
+
+    render(<JobTickTooltip jobTick={jobTick} timeWindow="1h" forceVisible />);
+
+    const missedProdRow = screen.getAllByRole('row')[1];
+    expect(within(missedProdRow).getByText('Missed')).toBeInTheDocument();
+    expect(within(missedProdRow).getByText('prod')).toBeInTheDocument();
+    expect(within(missedProdRow).getByText('1')).toBeInTheDocument();
+
+    const okDevRow = screen.getAllByRole('row')[2];
+    expect(within(okDevRow).getByText('Okay')).toBeInTheDocument();
+    expect(within(okDevRow).getByText('dev')).toBeInTheDocument();
+    expect(within(okDevRow).getByText('1')).toBeInTheDocument();
+
+    const missedDevRow = screen.getAllByRole('row')[3];
+    expect(within(missedDevRow).getByText('Missed')).toBeInTheDocument();
+    expect(within(missedDevRow).getByText('dev')).toBeInTheDocument();
+    expect(within(missedDevRow).getByText('2')).toBeInTheDocument();
+
+    const timeoutDevRow = screen.getAllByRole('row')[4];
+    expect(within(timeoutDevRow).getByText('Timed Out')).toBeInTheDocument();
+    expect(within(timeoutDevRow).getByText('dev')).toBeInTheDocument();
+    expect(within(timeoutDevRow).getByText('1')).toBeInTheDocument();
+  });
+});

+ 110 - 0
static/app/views/monitors/components/overviewTimeline/jobTickTooltip.tsx

@@ -0,0 +1,110 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import DateTime from 'sentry/components/dateTime';
+import Text from 'sentry/components/text';
+import {Tooltip, TooltipProps} from 'sentry/components/tooltip';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {
+  JobTickData,
+  TimeWindow,
+} from 'sentry/views/monitors/components/overviewTimeline/types';
+import {CheckInStatus} from 'sentry/views/monitors/types';
+import {getColorsFromStatus, statusToText} from 'sentry/views/monitors/utils';
+
+import {timeWindowData} from './utils';
+
+interface Props extends Omit<TooltipProps, 'title'> {
+  jobTick: JobTickData;
+  timeWindow: TimeWindow;
+}
+
+export function JobTickTooltip({jobTick, timeWindow, children, ...props}: Props) {
+  const {startTs, endTs, envMapping} = jobTick;
+  const {dateTimeProps} = timeWindowData[timeWindow];
+  const capturedEnvs = Object.keys(envMapping);
+  const representsSingleJob =
+    capturedEnvs.length === 1 &&
+    Object.values(envMapping[capturedEnvs[0]]).reduce((sum, count) => sum + count, 0) ===
+      1;
+
+  const tooltipTitle = (
+    <Fragment>
+      <TooltipTimeLabel>
+        <DateTime date={startTs * 1000} {...dateTimeProps} />
+        {!representsSingleJob && (
+          <Fragment>
+            <Text>{'\u2014'}</Text>
+            <DateTime date={endTs * 1000} {...dateTimeProps} />
+          </Fragment>
+        )}
+      </TooltipTimeLabel>
+      <StatusCountContainer>
+        {/* Visually hidden but kept here for accessibility */}
+        <HiddenHeader>
+          <tr>
+            <td>{t('Status')}</td>
+            <td>{t('Environment')}</td>
+            <td>{t('Count')}</td>
+          </tr>
+        </HiddenHeader>
+        <tbody>
+          {Object.entries(envMapping).map(([envName, statusCounts]) =>
+            Object.entries(statusCounts).map(
+              ([status, count]) =>
+                count > 0 && (
+                  <tr key={`${envName}:${status}`}>
+                    {/* TODO(davidenwang): fix types to remove "as" here */}
+                    <StatusLabel status={status as CheckInStatus}>
+                      {statusToText[status]}
+                    </StatusLabel>
+                    {/* TODO(davidenwang): handle long env names */}
+                    <EnvLabel>{envName}</EnvLabel>
+                    <StatusCount>{count}</StatusCount>
+                  </tr>
+                )
+            )
+          )}
+        </tbody>
+      </StatusCountContainer>
+    </Fragment>
+  );
+
+  return (
+    <Tooltip title={tooltipTitle} skipWrapper {...props}>
+      {children}
+    </Tooltip>
+  );
+}
+
+const StatusCountContainer = styled('table')`
+  width: 100%;
+  margin: 0;
+`;
+
+const TooltipTimeLabel = styled('div')`
+  display: flex;
+  margin-bottom: ${space(0.5)};
+  justify-content: center;
+`;
+
+const HiddenHeader = styled('thead')`
+  display: block;
+  overflow: hidden;
+  height: 0;
+  width: 0;
+`;
+
+const StatusLabel = styled('td')<{status: CheckInStatus}>`
+  color: ${p => getColorsFromStatus(p.status, p.theme).labelColor};
+  text-align: left;
+`;
+
+const StatusCount = styled('td')`
+  font-variant-numeric: tabular-nums;
+`;
+
+const EnvLabel = styled('td')`
+  padding: ${space(0.25)} ${space(0.5)};
+`;

+ 21 - 1
static/app/views/monitors/utils.tsx

@@ -1,10 +1,11 @@
+import {Theme} from '@emotion/react';
 import cronstrue from 'cronstrue';
 import {Location} from 'history';
 
 import {t, tn} from 'sentry/locale';
 import {Organization} from 'sentry/types';
 import {shouldUse24Hours} from 'sentry/utils/dates';
-import {MonitorConfig, ScheduleType} from 'sentry/views/monitors/types';
+import {CheckInStatus, MonitorConfig, ScheduleType} from 'sentry/views/monitors/types';
 
 export function makeMonitorListQueryKey(organization: Organization, location: Location) {
   return [
@@ -62,3 +63,22 @@ export function scheduleAsText(config: MonitorConfig) {
 
   return t('Unknown schedule');
 }
+
+export const statusToText: Record<CheckInStatus, string> = {
+  [CheckInStatus.OK]: t('Okay'),
+  [CheckInStatus.ERROR]: t('Failed'),
+  [CheckInStatus.IN_PROGRESS]: t('In Progress'),
+  [CheckInStatus.MISSED]: t('Missed'),
+  [CheckInStatus.TIMEOUT]: t('Timed Out'),
+};
+
+export function getColorsFromStatus(status: CheckInStatus, theme: Theme) {
+  const statusToColor: Record<CheckInStatus, {labelColor: string; tickColor: string}> = {
+    [CheckInStatus.ERROR]: {tickColor: theme.red200, labelColor: theme.red300},
+    [CheckInStatus.TIMEOUT]: {tickColor: theme.red200, labelColor: theme.red300},
+    [CheckInStatus.OK]: {tickColor: theme.green200, labelColor: theme.green300},
+    [CheckInStatus.MISSED]: {tickColor: theme.yellow200, labelColor: theme.yellow300},
+    [CheckInStatus.IN_PROGRESS]: {tickColor: theme.disabled, labelColor: theme.disabled},
+  };
+  return statusToColor[status];
+}