Browse Source

feat(crons): Show multiple environments on the timeline (#52586)

<img width="1272" alt="image"
src="https://github.com/getsentry/sentry/assets/9372512/8330d268-c4cd-40db-90b3-99d75e602901">

<img width="1259" alt="image"
src="https://github.com/getsentry/sentry/assets/9372512/ac41b925-ecba-4a30-a553-151ebb70f7f9">


Splits the timeline per environment, showing up to 4 envs, and showing a
`Show X More` button to display more

~99% of monitors do not have more than 4 environments
https://redash.getsentry.net/queries/4504#5667, but users can still
choose to show more or filter environments by the environment selector

- Added filterMonitorStatsBucketByEnv, which takes in a bucket of data
and an environment with which to filter that bucket by.
- When the timeline is processing/merging the buckets it will first run
them through the filter function
- Then environment and timeline are now displayed in a 3 column
(previously 2) grid layout

Depends/Borrows from https://github.com/getsentry/sentry/pull/53117
David Wang 1 year ago
parent
commit
704e3d6da4

+ 5 - 6
static/app/views/monitors/components/overviewTimeline/checkInTimeline.tsx

@@ -1,6 +1,5 @@
 import styled from '@emotion/styled';
 
-import {space} from 'sentry/styles/space';
 import {CheckInStatus} from 'sentry/views/monitors/types';
 import {getColorsFromStatus} from 'sentry/views/monitors/utils';
 import {getAggregateStatus} from 'sentry/views/monitors/utils/getAggregateStatus';
@@ -9,9 +8,10 @@ import {mergeBuckets} from 'sentry/views/monitors/utils/mergeBuckets';
 import {JobTickTooltip} from './jobTickTooltip';
 import {MonitorBucketData, TimeWindow} from './types';
 
-interface Props {
+export interface CheckInTimelineProps {
   bucketedData: MonitorBucketData;
   end: Date;
+  environment: string;
   start: Date;
   timeWindow: TimeWindow;
   width: number;
@@ -26,13 +26,13 @@ function getBucketedCheckInsPosition(
   return elapsedSinceStart / msPerPixel;
 }
 
-export function CheckInTimeline(props: Props) {
-  const {bucketedData, start, end, timeWindow, width} = props;
+export function CheckInTimeline(props: CheckInTimelineProps) {
+  const {bucketedData, start, end, timeWindow, width, environment} = props;
 
   const elapsedMs = end.getTime() - start.getTime();
   const msPerPixel = elapsedMs / width;
 
-  const jobTicks = mergeBuckets(bucketedData);
+  const jobTicks = mergeBuckets(bucketedData, environment);
 
   return (
     <TimelineContainer>
@@ -70,7 +70,6 @@ export function CheckInTimeline(props: Props) {
 const TimelineContainer = styled('div')`
   position: relative;
   height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
-  margin: ${space(2)} 0;
 `;
 
 const JobTick = styled('div')<{

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

@@ -98,7 +98,7 @@ export function GridLineOverlay({end, timeWindow, width, showCursor}: Props) {
 
 const Overlay = styled('div')`
   grid-row: 1;
-  grid-column: 2;
+  grid-column: 3;
   height: 100%;
   width: 100%;
   position: absolute;

+ 13 - 73
static/app/views/monitors/components/overviewTimeline/index.tsx

@@ -1,9 +1,7 @@
 import {useCallback, useRef} from 'react';
 import styled from '@emotion/styled';
 
-import Link from 'sentry/components/links/link';
 import Panel from 'sentry/components/panels/panel';
-import Placeholder from 'sentry/components/placeholder';
 import {SegmentedControl} from 'sentry/components/segmentedControl';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -11,14 +9,13 @@ import {useApiQuery} from 'sentry/utils/queryClient';
 import {useDimensions} from 'sentry/utils/useDimensions';
 import useOrganization from 'sentry/utils/useOrganization';
 import useRouter from 'sentry/utils/useRouter';
-import {CheckInTimeline} from 'sentry/views/monitors/components/overviewTimeline/checkInTimeline';
 import {
   GridLineOverlay,
   GridLineTimeLabels,
 } from 'sentry/views/monitors/components/overviewTimeline/gridLines';
+import {TimelineTableRow} from 'sentry/views/monitors/components/overviewTimeline/timelineTableRow';
 
 import {Monitor} from '../../types';
-import {scheduleAsText} from '../../utils';
 
 import {MonitorBucketData, TimeWindow} from './types';
 import {getStartFromTimeWindow, timeWindowConfig} from './utils';
@@ -95,77 +92,23 @@ export function OverviewTimeline({monitorList}: Props) {
       />
 
       {monitorList.map(monitor => (
-        <TimelineRow key={monitor.id}>
-          <MonitorDetails monitor={monitor} />
-          {isLoading || !monitorStats ? (
-            <TimelinePlaceholder />
-          ) : (
-            <div>
-              <CheckInTimeline
-                timeWindow={timeWindow}
-                bucketedData={monitorStats[monitor.slug]}
-                end={nowRef.current}
-                start={start}
-                width={timelineWidth}
-              />
-            </div>
-          )}
-        </TimelineRow>
+        <TimelineTableRow
+          key={monitor.id}
+          monitor={monitor}
+          timeWindow={timeWindow}
+          bucketedData={monitorStats?.[monitor.slug]}
+          end={nowRef.current}
+          start={start}
+          width={timelineWidth}
+        />
       ))}
     </MonitorListPanel>
   );
 }
 
-function MonitorDetails({monitor}: {monitor: Monitor}) {
-  const organization = useOrganization();
-  const schedule = scheduleAsText(monitor.config);
-
-  const monitorDetailUrl = `/organizations/${organization.slug}/crons/${monitor.slug}/`;
-
-  return (
-    <DetailsContainer to={monitorDetailUrl}>
-      <Name>{monitor.name}</Name>
-      <Schedule>{schedule}</Schedule>
-    </DetailsContainer>
-  );
-}
-
 const MonitorListPanel = styled(Panel)`
   display: grid;
-  grid-template-columns: 350px 1fr;
-`;
-
-const TimelineRow = styled('div')`
-  display: contents;
-
-  &:nth-child(odd) > * {
-    background: ${p => p.theme.backgroundSecondary};
-  }
-
-  &:hover > * {
-    background: ${p => p.theme.backgroundTertiary};
-  }
-
-  > * {
-    transition: background 50ms ease-in-out;
-  }
-`;
-
-const DetailsContainer = styled(Link)`
-  color: ${p => p.theme.textColor};
-  padding: ${space(2)};
-  border-right: 1px solid ${p => p.theme.border};
-  border-radius: 0;
-`;
-
-const Name = styled('h3')`
-  font-size: ${p => p.theme.fontSizeLarge};
-  margin-bottom: ${space(0.25)};
-`;
-
-const Schedule = styled('small')`
-  color: ${p => p.theme.subText};
-  font-size: ${p => p.theme.fontSizeSmall};
+  grid-template-columns: 350px 135px 1fr;
 `;
 
 const ListFilters = styled('div')`
@@ -173,15 +116,12 @@ const ListFilters = styled('div')`
   gap: ${space(1)};
   padding: ${space(1.5)} ${space(2)};
   border-bottom: 1px solid ${p => p.theme.border};
+  grid-column: 1/3;
 `;
 
 const TimelineWidthTracker = styled('div')`
   position: absolute;
   width: 100%;
   grid-row: 1;
-  grid-column: 2;
-`;
-
-const TimelinePlaceholder = styled(Placeholder)`
-  align-self: center;
+  grid-column: 3;
 `;

+ 138 - 0
static/app/views/monitors/components/overviewTimeline/timelineTableRow.tsx

@@ -0,0 +1,138 @@
+import {useState} from 'react';
+import {Link} from 'react-router';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import Placeholder from 'sentry/components/placeholder';
+import {tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import useOrganization from 'sentry/utils/useOrganization';
+import {Monitor} from 'sentry/views/monitors/types';
+import {scheduleAsText} from 'sentry/views/monitors/utils';
+
+import {CheckInTimeline, CheckInTimelineProps} from './checkInTimeline';
+import {MonitorBucket} from './types';
+
+interface Props extends Omit<CheckInTimelineProps, 'bucketedData' | 'environment'> {
+  monitor: Monitor;
+  bucketedData?: MonitorBucket[];
+}
+
+const MAX_SHOWN_ENVIRONMENTS = 4;
+
+export function TimelineTableRow({monitor, bucketedData, ...timelineProps}: Props) {
+  const [isExpanded, setExpanded] = useState(
+    monitor.environments.length <= MAX_SHOWN_ENVIRONMENTS
+  );
+
+  const environments = isExpanded
+    ? monitor.environments
+    : monitor.environments.slice(0, MAX_SHOWN_ENVIRONMENTS);
+
+  return (
+    <TimelineRow key={monitor.id}>
+      <MonitorDetails monitor={monitor} />
+      <MonitorEnvContainer>
+        {environments.map(({name}) => (
+          <MonitorEnvLabel key={name}>{name}</MonitorEnvLabel>
+        ))}
+        {!isExpanded && (
+          <Button size="xs" onClick={() => setExpanded(true)}>
+            {tct('Show [num] More', {
+              num: monitor.environments.length - MAX_SHOWN_ENVIRONMENTS,
+            })}
+          </Button>
+        )}
+      </MonitorEnvContainer>
+      {!bucketedData ? (
+        <TimelinePlaceholder />
+      ) : (
+        <TimelineContainer>
+          {environments.map(({name}) => {
+            return (
+              <CheckInTimeline
+                key={name}
+                {...timelineProps}
+                bucketedData={bucketedData}
+                environment={name}
+              />
+            );
+          })}
+        </TimelineContainer>
+      )}
+    </TimelineRow>
+  );
+}
+
+function MonitorDetails({monitor}: {monitor: Monitor}) {
+  const organization = useOrganization();
+  const schedule = scheduleAsText(monitor.config);
+
+  const monitorDetailUrl = `/organizations/${organization.slug}/crons/${monitor.slug}/`;
+
+  return (
+    <DetailsContainer to={monitorDetailUrl}>
+      <Name>{monitor.name}</Name>
+      <Schedule>{schedule}</Schedule>
+    </DetailsContainer>
+  );
+}
+
+const TimelineRow = styled('div')`
+  display: contents;
+
+  &:nth-child(odd) > * {
+    background: ${p => p.theme.backgroundSecondary};
+  }
+
+  &:hover > * {
+    background: ${p => p.theme.backgroundTertiary};
+  }
+
+  > * {
+    transition: background 50ms ease-in-out;
+  }
+`;
+
+const DetailsContainer = styled(Link)`
+  color: ${p => p.theme.textColor};
+  padding: ${space(3)};
+  border-right: 1px solid ${p => p.theme.border};
+  border-radius: 0;
+`;
+
+const Name = styled('h3')`
+  font-size: ${p => p.theme.fontSizeLarge};
+  margin-bottom: ${space(0.25)};
+`;
+
+const Schedule = styled('small')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const MonitorEnvContainer = styled('div')`
+  display: flex;
+  padding: ${space(3)} ${space(2)};
+  flex-direction: column;
+  gap: ${space(4)};
+  border-right: 1px solid ${p => p.theme.innerBorder};
+  text-align: right;
+`;
+
+const MonitorEnvLabel = styled('div')`
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+`;
+
+const TimelineContainer = styled('div')`
+  display: flex;
+  padding: ${space(3)} 0;
+  flex-direction: column;
+  gap: ${space(4)};
+`;
+
+const TimelinePlaceholder = styled(Placeholder)`
+  align-self: center;
+`;

+ 4 - 1
static/app/views/monitors/components/overviewTimeline/types.tsx

@@ -23,7 +23,10 @@ export interface TimeWindowOptions {
 
 export type TimeWindowData = Record<TimeWindow, TimeWindowOptions>;
 
-export type MonitorBucketData = [timestamp: number, envData: MonitorBucketEnvMapping][];
+// TODO(davidenwang): Remove this type as its a little too specific
+export type MonitorBucketData = MonitorBucket[];
+
+export type MonitorBucket = [timestamp: number, envData: MonitorBucketEnvMapping];
 
 export interface JobTickData {
   endTs: number;

+ 27 - 0
static/app/views/monitors/utils/filterMonitorStatsBucketByEnv.spec.tsx

@@ -0,0 +1,27 @@
+import {MonitorBucket} from 'sentry/views/monitors/components/overviewTimeline/types';
+import {filterMonitorStatsBucketByEnv} from 'sentry/views/monitors/utils/filterMonitorStatsBucketByEnv';
+
+describe('filterMonitorStatsBucketByEnvs', function () {
+  it('filters away environments', function () {
+    const bucket = [
+      1,
+      {
+        prod: {ok: 0, missed: 0, timeout: 1, error: 0},
+        dev: {ok: 1, missed: 0, timeout: 0, error: 0},
+      },
+    ] as MonitorBucket;
+    const filteredBucket = filterMonitorStatsBucketByEnv(bucket, 'prod');
+    expect(filteredBucket).toEqual([
+      1,
+      {
+        prod: {ok: 0, missed: 0, timeout: 1, error: 0},
+      },
+    ]);
+  });
+
+  it('filters on an empty bucket', function () {
+    const bucket = [1, {}] as MonitorBucket;
+    const filteredBucket = filterMonitorStatsBucketByEnv(bucket, 'prod');
+    expect(filteredBucket).toEqual([1, {}]);
+  });
+});

+ 12 - 0
static/app/views/monitors/utils/filterMonitorStatsBucketByEnv.tsx

@@ -0,0 +1,12 @@
+import {MonitorBucket} from 'sentry/views/monitors/components/overviewTimeline/types';
+
+export function filterMonitorStatsBucketByEnv(
+  bucket: MonitorBucket,
+  environment: string
+): MonitorBucket {
+  const [timestamp, envMapping] = bucket;
+  const envStatusCounts = envMapping[environment]
+    ? {[environment]: envMapping[environment]}
+    : {};
+  return [timestamp, envStatusCounts];
+}

+ 48 - 3
static/app/views/monitors/utils/mergeBuckets.spec.tsx

@@ -37,7 +37,7 @@ describe('mergeBuckets', function () {
       [7, generateJobRun('prod', CheckInStatus.OK)],
       [8, generateJobRun('prod', CheckInStatus.OK)],
     ];
-    const mergedData = mergeBuckets(bucketData);
+    const mergedData = mergeBuckets(bucketData, 'prod');
     const expectedMerged = [
       {
         startTs: 1,
@@ -63,7 +63,7 @@ describe('mergeBuckets', function () {
       [7, generateJobRun('prod', CheckInStatus.MISSED)],
       [8, generateJobRun('prod', CheckInStatus.MISSED)],
     ];
-    const mergedData = mergeBuckets(bucketData);
+    const mergedData = mergeBuckets(bucketData, 'prod');
     const expectedMerged = [
       {
         startTs: 1,
@@ -97,7 +97,7 @@ describe('mergeBuckets', function () {
       [7, generateJobRun('prod', CheckInStatus.MISSED)],
       [8, generateJobRun('prod', CheckInStatus.TIMEOUT)],
     ];
-    const mergedData = mergeBuckets(bucketData);
+    const mergedData = mergeBuckets(bucketData, 'prod');
     const expectedMerged = [
       {
         startTs: 1,
@@ -111,4 +111,49 @@ describe('mergeBuckets', function () {
 
     expect(mergedData).toEqual(expectedMerged);
   });
+
+  it('filters off environment', function () {
+    const bucketData: MonitorBucketData = [
+      [
+        1,
+        {
+          ...generateJobRun('prod', CheckInStatus.TIMEOUT),
+          ...generateJobRun('dev', CheckInStatus.OK),
+        },
+      ],
+      [
+        2,
+        {
+          ...generateJobRun('prod', CheckInStatus.TIMEOUT),
+          ...generateJobRun('dev', CheckInStatus.MISSED),
+        },
+      ],
+      [
+        3,
+        {
+          ...generateJobRun('prod', CheckInStatus.TIMEOUT),
+          ...generateJobRun('dev', CheckInStatus.TIMEOUT),
+        },
+      ],
+      [4, generateJobRun('prod', CheckInStatus.TIMEOUT)],
+      [5, generateJobRun('prod', CheckInStatus.MISSED)],
+      [6, generateJobRun('prod', CheckInStatus.OK)],
+      [7, generateJobRun('prod', CheckInStatus.MISSED)],
+      [8, generateJobRun('prod', CheckInStatus.TIMEOUT)],
+    ];
+
+    const mergedData = mergeBuckets(bucketData, 'dev');
+    const expectedMerged = [
+      {
+        startTs: 1,
+        endTs: 4,
+        width: 4,
+        roundedLeft: true,
+        roundedRight: true,
+        envMapping: generateEnvMapping('dev', [1, 1, 1, 0]),
+      },
+    ];
+
+    expect(mergedData).toEqual(expectedMerged);
+  });
 });

+ 7 - 4
static/app/views/monitors/utils/mergeBuckets.tsx

@@ -3,6 +3,7 @@ import {
   MonitorBucketData,
 } from 'sentry/views/monitors/components/overviewTimeline/types';
 
+import {filterMonitorStatsBucketByEnv} from './filterMonitorStatsBucketByEnv';
 import {getAggregateStatus} from './getAggregateStatus';
 import {getAggregateStatusFromMultipleBuckets} from './getAggregateStatusFromMultipleBuckets';
 import {isEnvMappingEmpty} from './isEnvMappingEmpty';
@@ -24,17 +25,19 @@ function generateJobTickFromBucket(
   };
 }
 
-export function mergeBuckets(data: MonitorBucketData) {
+export function mergeBuckets(data: MonitorBucketData, environment: string) {
   const minTickWidth = 4;
 
   const jobTicks: JobTickData[] = [];
   data.reduce((currentJobTick, bucket, i) => {
-    const [timestamp, envMapping] = bucket;
+    const filteredBucket = filterMonitorStatsBucketByEnv(bucket, environment);
+
+    const [timestamp, envMapping] = filteredBucket;
     const envMappingEmpty = isEnvMappingEmpty(envMapping);
     if (!currentJobTick) {
       return envMappingEmpty
         ? currentJobTick
-        : generateJobTickFromBucket(bucket, {roundedLeft: true});
+        : generateJobTickFromBucket(filteredBucket, {roundedLeft: true});
     }
     const bucketStatus = getAggregateStatus(envMapping);
     const currJobTickStatus = getAggregateStatus(currentJobTick.envMapping);
@@ -58,7 +61,7 @@ export function mergeBuckets(data: MonitorBucketData) {
     ) {
       // Then add our current tick to the running list of job ticks to render
       jobTicks.push(currentJobTick);
-      return generateJobTickFromBucket(bucket);
+      return generateJobTickFromBucket(filteredBucket);
     }
 
     // Merge our current tick with the current bucket data