Browse Source

feat(crons): Move crons pages into alerts / insights (#83247)

Moves crons edit / create / details pages into Alerts subroutes.
Evan Purkhiser 2 months ago
parent
commit
56b6a20d26

+ 20 - 0
static/app/routes.tsx

@@ -1257,6 +1257,15 @@ function buildRoutes() {
               component={make(() => import('sentry/views/alerts/rules/uptime/details'))}
             />
           </Route>
+          <Route
+            path="crons/"
+            component={make(() => import('sentry/views/alerts/rules/crons'))}
+          >
+            <Route
+              path=":projectId/:monitorSlug/details/"
+              component={make(() => import('sentry/views/alerts/rules/crons/details'))}
+            />
+          </Route>
         </Route>
         <Route path="metric-rules/">
           <IndexRedirect
@@ -1292,6 +1301,17 @@ function buildRoutes() {
             />
           </Route>
         </Route>
+        <Route path="crons-rules/">
+          <Route
+            path=":projectId/"
+            component={make(() => import('sentry/views/alerts/builder/projectProvider'))}
+          >
+            <Route
+              path=":monitorSlug/"
+              component={make(() => import('sentry/views/alerts/edit'))}
+            />
+          </Route>
+        </Route>
         <Route
           path="wizard/"
           component={make(() => import('sentry/views/alerts/builder/projectProvider'))}

+ 17 - 0
static/app/views/alerts/create.tsx

@@ -12,6 +12,7 @@ import {uniqueId} from 'sentry/utils/guid';
 import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
 import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
 import normalizeUrl from 'sentry/utils/url/normalizeUrl';
+import {useNavigate} from 'sentry/utils/useNavigate';
 import {useUserTeams} from 'sentry/utils/useUserTeams';
 import BuilderBreadCrumbs from 'sentry/views/alerts/builder/builderBreadCrumbs';
 import IssueRuleEditor from 'sentry/views/alerts/rules/issue';
@@ -28,6 +29,8 @@ import {
   DEFAULT_WIZARD_TEMPLATE,
 } from 'sentry/views/alerts/wizard/options';
 import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
+import MonitorForm from 'sentry/views/monitors/components/monitorForm';
+import type {Monitor} from 'sentry/views/monitors/types';
 
 type RouteParams = {
   alertType?: AlertRuleType;
@@ -57,6 +60,7 @@ function Create(props: Props) {
   const alertType = params.alertType || AlertRuleType.METRIC;
 
   const sessionId = useRef(uniqueId());
+  const navigate = useNavigate();
 
   const isDuplicateRule = createFromDuplicate === 'true' && duplicateRuleId;
 
@@ -144,6 +148,19 @@ function Create(props: Props) {
           <Fragment>
             {alertType === AlertRuleType.UPTIME ? (
               <UptimeAlertForm {...props} />
+            ) : alertType === AlertRuleType.CRONS ? (
+              <MonitorForm
+                apiMethod="POST"
+                apiEndpoint={`/organizations/${organization.slug}/monitors/`}
+                onSubmitSuccess={(data: Monitor) =>
+                  navigate(
+                    normalizeUrl(
+                      `/organizations/${organization.slug}/alerts/rules/crons/${data.project.slug}/${data.slug}/details/`
+                    )
+                  )
+                }
+                submitLabel={t('Create')}
+              />
             ) : !hasMetricAlerts || alertType === AlertRuleType.ISSUE ? (
               <IssueRuleEditor
                 {...props}

+ 5 - 0
static/app/views/alerts/edit.tsx

@@ -13,6 +13,7 @@ import {useLocation} from 'sentry/utils/useLocation';
 import {useUserTeams} from 'sentry/utils/useUserTeams';
 import BuilderBreadCrumbs from 'sentry/views/alerts/builder/builderBreadCrumbs';
 
+import {CronRulesEdit} from './rules/crons/edit';
 import IssueEditor from './rules/issue';
 import {MetricRulesEdit} from './rules/metric/edit';
 import {UptimeRulesEdit} from './rules/uptime/edit';
@@ -40,6 +41,7 @@ function ProjectAlertsEditor(props: Props) {
   const alertTypeUrls = [
     {url: '/alerts/metric-rules/', type: CombinedAlertType.METRIC},
     {url: '/alerts/uptime-rules/', type: CombinedAlertType.UPTIME},
+    {url: '/alerts/crons-rules/', type: CombinedAlertType.CRONS},
     {url: '/alerts/rules/', type: CombinedAlertType.ISSUE},
   ] as const;
 
@@ -105,6 +107,9 @@ function ProjectAlertsEditor(props: Props) {
                 userTeamIds={teams.map(({id}) => id)}
               />
             )}
+            {alertType === CombinedAlertType.CRONS && (
+              <CronRulesEdit {...props} project={project} onChangeTitle={setTitle} />
+            )}
           </Fragment>
         ) : (
           <LoadingIndicator />

+ 2 - 0
static/app/views/alerts/list/rules/row.tsx

@@ -71,6 +71,7 @@ function RuleListRow({
     [CombinedAlertType.ISSUE]: 'rules',
     [CombinedAlertType.METRIC]: 'metric-rules',
     [CombinedAlertType.UPTIME]: 'uptime-rules',
+    [CombinedAlertType.CRONS]: 'crons-rules',
   } satisfies Record<CombinedAlertType, string>;
 
   const editLink = `/organizations/${orgId}/alerts/${editKey[rule.type]}/${slug}/${rule.id}/`;
@@ -79,6 +80,7 @@ function RuleListRow({
     [CombinedAlertType.ISSUE]: 'issue',
     [CombinedAlertType.METRIC]: 'metric',
     [CombinedAlertType.UPTIME]: 'uptime',
+    [CombinedAlertType.CRONS]: 'crons',
   } satisfies Record<CombinedAlertType, string>;
 
   const duplicateLink = {

+ 72 - 0
static/app/views/alerts/rules/crons/create.tsx

@@ -0,0 +1,72 @@
+import {Breadcrumbs} from 'sentry/components/breadcrumbs';
+import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
+import * as Layout from 'sentry/components/layouts/thirds';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import HookStore from 'sentry/stores/hookStore';
+import {browserHistory} from 'sentry/utils/browserHistory';
+import normalizeUrl from 'sentry/utils/url/normalizeUrl';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import MonitorForm from 'sentry/views/monitors/components/monitorForm';
+import type {Monitor} from 'sentry/views/monitors/types';
+
+function CreateMonitor() {
+  const organization = useOrganization();
+  const orgSlug = organization.slug;
+  const {selection} = usePageFilters();
+
+  const monitorCreationCallbacks = HookStore.get('callback:on-monitor-created');
+
+  function onSubmitSuccess(data: Monitor) {
+    const endpointOptions = {
+      query: {
+        project: selection.projects,
+        environment: selection.environments,
+      },
+    };
+    browserHistory.push(
+      normalizeUrl({
+        pathname: `/organizations/${orgSlug}/crons/${data.project.slug}/${data.slug}/`,
+        query: endpointOptions.query,
+      })
+    );
+    monitorCreationCallbacks.map(cb => cb(organization));
+  }
+
+  return (
+    <SentryDocumentTitle title={t('New Monitor — Crons')}>
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Breadcrumbs
+            crumbs={[
+              {
+                label: t('Crons'),
+                to: `/organizations/${orgSlug}/crons/`,
+              },
+              {
+                label: t('Add Monitor'),
+              },
+            ]}
+          />
+          <Layout.Title>{t('Add Monitor')}</Layout.Title>
+        </Layout.HeaderContent>
+        <Layout.HeaderActions>
+          <FeedbackWidgetButton />
+        </Layout.HeaderActions>
+      </Layout.Header>
+      <Layout.Body>
+        <Layout.Main fullWidth>
+          <MonitorForm
+            apiMethod="POST"
+            apiEndpoint={`/organizations/${orgSlug}/monitors/`}
+            onSubmitSuccess={onSubmitSuccess}
+            submitLabel={t('Create')}
+          />
+        </Layout.Main>
+      </Layout.Body>
+    </SentryDocumentTitle>
+  );
+}
+
+export default CreateMonitor;

+ 80 - 0
static/app/views/alerts/rules/crons/details.spec.tsx

@@ -0,0 +1,80 @@
+import {CheckinProcessingErrorFixture} from 'sentry-fixture/checkinProcessingError';
+import {MonitorFixture} from 'sentry-fixture/monitor';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import MonitorDetails from 'sentry/views/alerts/rules/crons/details';
+
+describe('Monitor Details', () => {
+  const monitor = MonitorFixture();
+  const {organization, project, routerProps} = initializeOrg({
+    router: {params: {monitorSlug: monitor.slug, projectId: monitor.project.slug}},
+  });
+
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/monitors/${monitor.slug}/`,
+      body: {...monitor},
+    });
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/users/`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/issues/?limit=20&project=${project.id}&query=monitor.slug%3A${monitor.slug}%20environment%3A%5Bproduction%5D%20is%3Aunresolved&statsPeriod=14d`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/monitors/${monitor.slug}/stats/`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/monitors/${monitor.slug}/checkins/`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/monitors/${monitor.slug}/processing-errors/`,
+      body: [],
+    });
+  });
+
+  it('renders', async function () {
+    render(<MonitorDetails {...routerProps} />);
+    expect(await screen.findByText(monitor.slug, {exact: false})).toBeInTheDocument();
+
+    // Doesn't show processing errors
+    expect(
+      screen.queryByText(
+        'Errors were encountered while ingesting check-ins for this monitor'
+      )
+    ).not.toBeInTheDocument();
+  });
+
+  it('renders error when monitor is not found', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/monitors/${monitor.slug}/`,
+      statusCode: 404,
+    });
+
+    render(<MonitorDetails {...routerProps} />);
+    expect(
+      await screen.findByText('The monitor you were looking for was not found.')
+    ).toBeInTheDocument();
+  });
+
+  it('shows processing errors when they exist', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/monitors/${monitor.slug}/processing-errors/`,
+      body: [CheckinProcessingErrorFixture()],
+    });
+
+    render(<MonitorDetails {...routerProps} />);
+    expect(
+      await screen.findByText(
+        'Errors were encountered while ingesting check-ins for this monitor'
+      )
+    ).toBeInTheDocument();
+  });
+});

+ 225 - 0
static/app/views/alerts/rules/crons/details.tsx

@@ -0,0 +1,225 @@
+import {Fragment, useCallback, useState} from 'react';
+import styled from '@emotion/styled';
+import sortBy from 'lodash/sortBy';
+
+import {
+  deleteMonitorProcessingErrorByType,
+  updateMonitor,
+} from 'sentry/actionCreators/monitors';
+import Alert from 'sentry/components/alert';
+import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
+import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
+import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import {DetailsSidebar} from 'sentry/views/monitors/components/detailsSidebar';
+import {DetailsTimeline} from 'sentry/views/monitors/components/detailsTimeline';
+import {MonitorCheckIns} from 'sentry/views/monitors/components/monitorCheckIns';
+import {MonitorHeader} from 'sentry/views/monitors/components/monitorHeader';
+import {MonitorIssues} from 'sentry/views/monitors/components/monitorIssues';
+import {MonitorStats} from 'sentry/views/monitors/components/monitorStats';
+import {MonitorOnboarding} from 'sentry/views/monitors/components/onboarding';
+import {MonitorProcessingErrors} from 'sentry/views/monitors/components/processingErrors/monitorProcessingErrors';
+import {makeMonitorErrorsQueryKey} from 'sentry/views/monitors/components/processingErrors/utils';
+import {StatusToggleButton} from 'sentry/views/monitors/components/statusToggleButton';
+import type {
+  CheckinProcessingError,
+  Monitor,
+  MonitorBucket,
+  ProcessingErrorType,
+} from 'sentry/views/monitors/types';
+import {makeMonitorDetailsQueryKey} from 'sentry/views/monitors/utils';
+
+const DEFAULT_POLL_INTERVAL_MS = 5000;
+
+type Props = RouteComponentProps<{monitorSlug: string; projectId: string}, {}>;
+
+function hasLastCheckIn(monitor: Monitor) {
+  return monitor.environments.some(e => e.lastCheckIn);
+}
+
+function MonitorDetails({params, location}: Props) {
+  const api = useApi();
+
+  const organization = useOrganization();
+  const queryClient = useQueryClient();
+
+  const queryKey = makeMonitorDetailsQueryKey(
+    organization,
+    params.projectId,
+    params.monitorSlug,
+    {
+      environment: location.query.environment,
+    }
+  );
+
+  const {data: monitor, isError} = useApiQuery<Monitor>(queryKey, {
+    staleTime: 0,
+    refetchOnWindowFocus: true,
+    // Refetches while we are waiting for the user to send their first check-in
+    refetchInterval: query => {
+      if (!query.state.data) {
+        return false;
+      }
+      const [monitorData] = query.state.data;
+      return hasLastCheckIn(monitorData) ? false : DEFAULT_POLL_INTERVAL_MS;
+    },
+  });
+
+  const {data: checkinErrors, refetch: refetchErrors} = useApiQuery<
+    CheckinProcessingError[]
+  >(makeMonitorErrorsQueryKey(organization, params.projectId, params.monitorSlug), {
+    staleTime: 0,
+    refetchOnWindowFocus: true,
+  });
+
+  function onUpdate(data: Monitor) {
+    const updatedMonitor = {
+      ...data,
+      // TODO(davidenwang): This is a bit of a hack, due to the PUT request
+      // which pauses/unpauses a monitor not returning monitor environments
+      // we should reuse the environments retrieved from the initial request
+      environments: monitor?.environments,
+    };
+    setApiQueryData(queryClient, queryKey, updatedMonitor);
+  }
+
+  const handleUpdate = async (data: Partial<Monitor>) => {
+    if (monitor === undefined) {
+      return;
+    }
+    const resp = await updateMonitor(api, organization.slug, monitor, data);
+
+    if (resp !== null) {
+      onUpdate(resp);
+    }
+  };
+
+  function handleDismissError(errortype: ProcessingErrorType) {
+    deleteMonitorProcessingErrorByType(
+      api,
+      organization.slug,
+      params.projectId,
+      params.monitorSlug,
+      errortype
+    );
+    refetchErrors();
+  }
+
+  // Only display the unknown legend when there are visible unknown check-ins
+  // in the timeline
+  const [showUnknownLegend, setShowUnknownLegend] = useState(false);
+
+  const checkHasUnknown = useCallback((stats: MonitorBucket[]) => {
+    const hasUnknown = stats.some(bucket =>
+      Object.values(bucket[1]).some(envBucket => Boolean(envBucket.unknown))
+    );
+    setShowUnknownLegend(hasUnknown);
+  }, []);
+
+  if (isError) {
+    return (
+      <LoadingError message={t('The monitor you were looking for was not found.')} />
+    );
+  }
+
+  if (!monitor) {
+    return (
+      <Layout.Page>
+        <LoadingIndicator />
+      </Layout.Page>
+    );
+  }
+
+  const envsSortedByLastCheck = sortBy(monitor.environments, e => e.lastCheckIn);
+
+  return (
+    <Layout.Page>
+      <SentryDocumentTitle title={`${monitor.name} — Alerts`} />
+      <MonitorHeader
+        linkToAlerts
+        monitor={monitor}
+        orgSlug={organization.slug}
+        onUpdate={onUpdate}
+      />
+      <Layout.Body>
+        <Layout.Main>
+          <StyledPageFilterBar condensed>
+            <DatePageFilter />
+            <EnvironmentPageFilter />
+          </StyledPageFilterBar>
+          {monitor.status === 'disabled' && (
+            <Alert
+              type="muted"
+              showIcon
+              trailingItems={
+                <StatusToggleButton
+                  monitor={monitor}
+                  size="xs"
+                  onToggleStatus={status => handleUpdate({status})}
+                >
+                  {t('Enable')}
+                </StatusToggleButton>
+              }
+            >
+              {t('This monitor is disabled and is not accepting check-ins.')}
+            </Alert>
+          )}
+          {!!checkinErrors?.length && (
+            <MonitorProcessingErrors
+              checkinErrors={checkinErrors}
+              onDismiss={handleDismissError}
+            >
+              {t('Errors were encountered while ingesting check-ins for this monitor')}
+            </MonitorProcessingErrors>
+          )}
+          {!hasLastCheckIn(monitor) ? (
+            <MonitorOnboarding monitor={monitor} />
+          ) : (
+            <Fragment>
+              <DetailsTimeline monitor={monitor} onStatsLoaded={checkHasUnknown} />
+              <MonitorStats
+                orgSlug={organization.slug}
+                monitor={monitor}
+                monitorEnvs={monitor.environments}
+              />
+
+              <MonitorIssues
+                orgSlug={organization.slug}
+                monitor={monitor}
+                monitorEnvs={monitor.environments}
+              />
+
+              <MonitorCheckIns
+                orgSlug={organization.slug}
+                monitor={monitor}
+                monitorEnvs={monitor.environments}
+              />
+            </Fragment>
+          )}
+        </Layout.Main>
+        <Layout.Side>
+          <DetailsSidebar
+            monitorEnv={envsSortedByLastCheck[envsSortedByLastCheck.length - 1]}
+            monitor={monitor}
+            showUnknownLegend={showUnknownLegend}
+          />
+        </Layout.Side>
+      </Layout.Body>
+    </Layout.Page>
+  );
+}
+
+const StyledPageFilterBar = styled(PageFilterBar)`
+  margin-bottom: ${space(2)};
+`;
+
+export default MonitorDetails;

+ 84 - 0
static/app/views/alerts/rules/crons/edit.tsx

@@ -0,0 +1,84 @@
+import {useEffect} from 'react';
+
+import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import type {Project} from 'sentry/types/project';
+import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
+import normalizeUrl from 'sentry/utils/url/normalizeUrl';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {useParams} from 'sentry/utils/useParams';
+import MonitorForm from 'sentry/views/monitors/components/monitorForm';
+import type {Monitor} from 'sentry/views/monitors/types';
+import {makeMonitorDetailsQueryKey} from 'sentry/views/monitors/utils';
+
+type Props = {
+  onChangeTitle: (data: string) => void;
+  organization: Organization;
+  project: Project;
+};
+
+export function CronRulesEdit({onChangeTitle, project, organization}: Props) {
+  const {monitorSlug, projectId} = useParams<{
+    monitorSlug: string;
+    projectId: string;
+  }>();
+
+  const navigate = useNavigate();
+  const {selection} = usePageFilters();
+  const queryClient = useQueryClient();
+
+  const queryKey = makeMonitorDetailsQueryKey(organization, project.slug, monitorSlug, {
+    expand: ['alertRule'],
+  });
+
+  const {
+    isPending,
+    isError,
+    data: monitor,
+    refetch,
+  } = useApiQuery<Monitor>(queryKey, {
+    gcTime: 0,
+    staleTime: 0,
+  });
+
+  useEffect(
+    () => onChangeTitle(monitor?.name ?? t('Editing Monitor')),
+    [onChangeTitle, monitor?.name]
+  );
+
+  function onSubmitSuccess(data: Monitor) {
+    setApiQueryData(queryClient, queryKey, data);
+    navigate(
+      normalizeUrl({
+        pathname: `/organizations/${organization.slug}/alerts/rules/crons/${data.project.slug}/${data.slug}/details/`,
+        query: {
+          environment: selection.environments,
+          project: selection.projects,
+        },
+      })
+    );
+  }
+
+  if (isPending) {
+    return <LoadingIndicator />;
+  }
+
+  if (isError) {
+    return <LoadingError onRetry={refetch} message={t('Failed to load monitor.')} />;
+  }
+
+  return (
+    <Layout.Main fullWidth>
+      <MonitorForm
+        monitor={monitor}
+        apiMethod="PUT"
+        apiEndpoint={`/projects/${organization.slug}/${projectId}/monitors/${monitor.slug}/`}
+        onSubmitSuccess={onSubmitSuccess}
+      />
+    </Layout.Main>
+  );
+}

+ 13 - 0
static/app/views/alerts/rules/crons/index.tsx

@@ -0,0 +1,13 @@
+import NoProjectMessage from 'sentry/components/noProjectMessage';
+import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
+import useOrganization from 'sentry/utils/useOrganization';
+
+export default function CronsContainer({children}: {children?: React.ReactNode}) {
+  const organization = useOrganization();
+
+  return (
+    <NoProjectMessage organization={organization}>
+      <PageFiltersContainer>{children}</PageFiltersContainer>
+    </NoProjectMessage>
+  );
+}

+ 2 - 0
static/app/views/alerts/types.tsx

@@ -9,6 +9,7 @@ export enum AlertRuleType {
   METRIC = 'metric',
   ISSUE = 'issue',
   UPTIME = 'uptime',
+  CRONS = 'crons',
 }
 
 export type Incident = {
@@ -94,6 +95,7 @@ export enum CombinedAlertType {
   METRIC = 'alert_rule',
   ISSUE = 'rule',
   UPTIME = 'uptime',
+  CRONS = 'crons',
 }
 
 export interface IssueAlert extends IssueAlertRule {

Some files were not shown because too many files changed in this diff