Browse Source

feat(crons): Add DateNavigator components to traverse the timeline (#68306)

Looks like this

<img width="1840" alt="image"
src="https://github.com/getsentry/sentry/assets/1421724/74114d58-8212-47b6-80b6-e69f38301070">

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Evan Purkhiser 11 months ago
parent
commit
6322bee06f

+ 40 - 0
static/app/views/monitors/components/overviewTimeline/dateNavigator.tsx

@@ -0,0 +1,40 @@
+import {type BaseButtonProps, Button} from 'sentry/components/button';
+import {IconChevron} from 'sentry/icons';
+import {t} from 'sentry/locale';
+
+import type {DateNavigation} from './useDateNavigation';
+
+interface Props extends BaseButtonProps {
+  dateNavigation: DateNavigation;
+  /**
+   * Direction to navigate
+   */
+  direction: 'back' | 'forward';
+}
+
+export function DateNavigator({direction, dateNavigation, ...props}: Props) {
+  const isForward = direction === 'forward';
+
+  const title = isForward
+    ? t('Next %s', dateNavigation.label)
+    : t('Previous %s', dateNavigation.label);
+
+  const action = isForward
+    ? dateNavigation.navigateToNextPeriod
+    : dateNavigation.navigateToPreviousPeriod;
+
+  const disabled = isForward && dateNavigation.endIsNow;
+
+  const iconDirection = isForward ? 'right' : 'left';
+
+  return (
+    <Button
+      icon={<IconChevron direction={iconDirection} />}
+      title={!disabled && title}
+      aria-label={title}
+      onClick={action}
+      disabled={disabled}
+      {...props}
+    />
+  );
+}

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

@@ -149,6 +149,10 @@ export function GridLineOverlay({
   const overlayRef = mergeRefs(cursorContainerRef, selectionContainerRef);
   const markers = getTimeMarkersFromConfig(timeWindowConfig, width);
 
+  // Skip first gridline, this will be represented as a border on the
+  // LabelsContainer
+  markers.shift();
+
   return (
     <Overlay ref={overlayRef} className={className}>
       {timelineCursor}
@@ -164,7 +168,7 @@ export function GridLineOverlay({
 
 const Overlay = styled('div')`
   grid-row: 1;
-  grid-column: 3;
+  grid-column: 3/-1;
   height: 100%;
   width: 100%;
   position: absolute;
@@ -172,13 +176,15 @@ const Overlay = styled('div')`
 `;
 
 const GridLineContainer = styled('div')`
-  margin-left: -1px;
   position: relative;
   height: 100%;
   z-index: 1;
 `;
 
 const LabelsContainer = styled('div')`
+  grid-row: 1;
+  grid-column: 3/-1;
+  box-shadow: -1px 0 0 ${p => p.theme.translucentInnerBorder};
   position: relative;
   align-self: stretch;
 `;

+ 29 - 5
static/app/views/monitors/components/overviewTimeline/index.tsx

@@ -17,9 +17,11 @@ import useRouter from 'sentry/utils/useRouter';
 import type {Monitor} from 'sentry/views/monitors/types';
 import {makeMonitorListQueryKey} from 'sentry/views/monitors/utils';
 
+import {DateNavigator} from './dateNavigator';
 import {GridLineOverlay, GridLineTimeLabels} from './gridLines';
 import {SortSelector} from './sortSelector';
 import {TimelineTableRow} from './timelineTableRow';
+import {useDateNavigation} from './useDateNavigation';
 import {useMonitorStats} from './useMonitorStats';
 import {useTimeWindowConfig} from './useTimeWindowConfig';
 
@@ -38,6 +40,7 @@ export function OverviewTimeline({monitorList}: Props) {
   const {width: timelineWidth} = useDimensions<HTMLDivElement>({elementRef});
 
   const timeWindowConfig = useTimeWindowConfig({timelineWidth});
+  const dateNavigation = useDateNavigation();
 
   const {data: monitorStats, isLoading} = useMonitorStats({
     monitors: monitorList.map(m => m.id),
@@ -122,10 +125,24 @@ export function OverviewTimeline({monitorList}: Props) {
     <MonitorListPanel>
       <TimelineWidthTracker ref={elementRef} />
       <Header>
-        <HeaderControls>
+        <HeaderControlsLeft>
           <SortSelector size="xs" />
-        </HeaderControls>
+          <DateNavigator
+            dateNavigation={dateNavigation}
+            direction="back"
+            size="xs"
+            borderless
+          />
+        </HeaderControlsLeft>
         <GridLineTimeLabels timeWindowConfig={timeWindowConfig} width={timelineWidth} />
+        <HeaderControlsRight>
+          <DateNavigator
+            dateNavigation={dateNavigation}
+            direction="forward"
+            size="xs"
+            borderless
+          />
+        </HeaderControlsRight>
       </Header>
       <GridLineOverlay
         stickyCursor
@@ -155,7 +172,7 @@ export function OverviewTimeline({monitorList}: Props) {
 
 const MonitorListPanel = styled(Panel)`
   display: grid;
-  grid-template-columns: 350px 135px 1fr;
+  grid-template-columns: 350px 135px 1fr max-content;
 `;
 
 const Header = styled(Sticky)`
@@ -177,16 +194,23 @@ const Header = styled(Sticky)`
   }
 `;
 
-const HeaderControls = styled('div')`
+const HeaderControlsLeft = styled('div')`
   grid-column: 1/3;
   display: flex;
+  justify-content: space-between;
   gap: ${space(0.5)};
   padding: ${space(1.5)} ${space(2)};
 `;
 
+const HeaderControlsRight = styled('div')`
+  grid-row: 1;
+  grid-column: -1;
+  padding: ${space(1.5)} ${space(2)};
+`;
+
 const TimelineWidthTracker = styled('div')`
   position: absolute;
   width: 100%;
   grid-row: 1;
-  grid-column: 3;
+  grid-column: 3/-1;
 `;

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

@@ -9,6 +9,12 @@ import testableTransition from 'sentry/utils/testableTransition';
 
 const TOOLTIP_OFFSET = 10;
 
+/**
+ * Ensure the tooltip does not overlap with the navigation button at the right
+ * of the timeline.
+ */
+const TOOLTIP_RIGHT_CLAMP_OFFSET = 40;
+
 interface Options {
   /**
    * Function used to compute the text of the cursor tooltip. Receives the
@@ -153,7 +159,10 @@ const CursorLabel = styled(Overlay)`
   left: clamp(
     0px,
     calc(var(--cursorOffset) + ${TOOLTIP_OFFSET}px),
-    calc(var(--cursorMax) - var(--cursorLabelWidth) - ${TOOLTIP_OFFSET}px)
+    calc(
+      var(--cursorMax) - var(--cursorLabelWidth) - ${TOOLTIP_RIGHT_CLAMP_OFFSET}px -
+        ${TOOLTIP_OFFSET}px
+    )
   );
 `;
 

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

@@ -341,6 +341,7 @@ const TimelineContainer = styled('div')`
   flex-direction: column;
   gap: ${space(4)};
   contain: content;
+  grid-column: 3/-1;
 `;
 
 const TimelineEnvOuterContainer = styled('div')`

+ 61 - 0
static/app/views/monitors/components/overviewTimeline/useDateNavigation.tsx

@@ -0,0 +1,61 @@
+import {useCallback} from 'react';
+import moment from 'moment';
+
+import {updateDateTime} from 'sentry/actionCreators/pageFilters';
+import {getDuration} from 'sentry/utils/formatters';
+import useRouter from 'sentry/utils/useRouter';
+
+import {useMonitorDates} from './useMonitorDates';
+
+export interface DateNavigation {
+  /**
+   * Is the windows end aligned to the current time?
+   */
+  endIsNow: boolean;
+  /**
+   * A duration label indicating how far the navigation will navigate
+   */
+  label: React.ReactNode;
+  /**
+   * Updates the page filter date range to the next period using the current
+   * period as a reference.
+   */
+  navigateToNextPeriod: () => void;
+  /**
+   * Updates the page filter date range to the previous period using the
+   * current period as a reference.
+   */
+  navigateToPreviousPeriod: () => void;
+}
+
+export function useDateNavigation(): DateNavigation {
+  const router = useRouter();
+  const {since, until, nowRef} = useMonitorDates();
+
+  const windowMs = until.getTime() - since.getTime();
+
+  const navigateToPreviousPeriod = useCallback(() => {
+    const nextUntil = moment(until).subtract(windowMs, 'milliseconds');
+    const nextSince = moment(nextUntil).subtract(windowMs, 'milliseconds');
+
+    updateDateTime({start: nextSince.toDate(), end: nextUntil.toDate()}, router);
+  }, [windowMs, router, until]);
+
+  const navigateToNextPeriod = useCallback(() => {
+    // Do not navigate past the current time
+    const nextUntil = moment.min(
+      moment(until).add(windowMs, 'milliseconds'),
+      moment(nowRef.current)
+    );
+    const nextSince = moment(nextUntil).subtract(windowMs, 'milliseconds');
+
+    updateDateTime({start: nextSince.toDate(), end: nextUntil.toDate()}, router);
+  }, [until, windowMs, nowRef, router]);
+
+  return {
+    endIsNow: until.getTime() === nowRef.current.getTime(),
+    label: getDuration(windowMs / 1000),
+    navigateToPreviousPeriod,
+    navigateToNextPeriod,
+  };
+}