Browse Source

feat(issue-alert): issue alert preview graph (#40043)

Adds a step in the 'New Alert Rule' page to fetch a preview graph. [pics
in the jira](https://getsentry.atlassian.net/browse/ISP-47)
Andrew Xue 2 years ago
parent
commit
2a852be875

+ 55 - 0
static/app/views/alerts/create.spec.jsx

@@ -1,4 +1,5 @@
 import selectEvent from 'react-select-event';
+import moment from 'moment';
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
@@ -439,4 +440,58 @@ describe('ProjectAlertsCreate', function () {
       });
     });
   });
+
+  describe('test preview chart', () => {
+    const organization = TestStubs.Organization({features: ['issue-alert-preview']});
+    afterEach(() => {
+      jest.clearAllMocks();
+    });
+    it('generate valid preview chart', async () => {
+      const mock = MockApiClient.addMockResponse({
+        url: '/projects/org-slug/project-slug/rules/preview',
+        method: 'POST',
+        body: [
+          {datetime: moment().subtract(2, 'days').format(), count: 1},
+          {datetime: moment().subtract(1, 'days').format(), count: 2},
+          {datetime: moment().format(), count: 3},
+        ],
+      });
+      createWrapper({organization});
+      userEvent.click(screen.getByText('Generate Preview'));
+      await waitFor(() => {
+        expect(mock).toHaveBeenCalledWith(
+          expect.any(String),
+          expect.objectContaining({
+            data: {
+              actionMatch: 'all',
+              conditions: [],
+              filterMatch: 'all',
+              filters: [],
+              frequency: 30,
+            },
+          })
+        );
+      });
+      expect(screen.getByText('Alerts Triggered')).toBeInTheDocument();
+      expect(screen.getByText('Total Alerts')).toBeInTheDocument();
+    });
+
+    it('invalid preview chart', async () => {
+      const mock = MockApiClient.addMockResponse({
+        url: '/projects/org-slug/project-slug/rules/preview',
+        method: 'POST',
+        statusCode: 400,
+      });
+      createWrapper({organization});
+      userEvent.click(screen.getByText('Generate Preview'));
+      await waitFor(() => {
+        expect(mock).toHaveBeenCalled();
+      });
+      expect(
+        screen.getByText(
+          'Previews are unavailable for this combination of conditions and filters'
+        )
+      ).toBeInTheDocument();
+    });
+  });
 });

+ 85 - 1
static/app/views/alerts/rules/issue/index.tsx

@@ -15,6 +15,7 @@ import {
 } from 'sentry/actionCreators/indicator';
 import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
 import Access from 'sentry/components/acl/access';
+import Feature from 'sentry/components/acl/feature';
 import Alert from 'sentry/components/alert';
 import Button from 'sentry/components/button';
 import Confirm from 'sentry/components/confirm';
@@ -49,6 +50,7 @@ import {
   IssueAlertRuleAction,
   IssueAlertRuleActionTemplate,
   IssueAlertRuleConditionTemplate,
+  ProjectAlertRuleStats,
   UnsavedIssueAlertRule,
 } from 'sentry/types/alerts';
 import {metric} from 'sentry/utils/analytics';
@@ -59,6 +61,7 @@ import recreateRoute from 'sentry/utils/recreateRoute';
 import routeTitleGen from 'sentry/utils/routeTitle';
 import withOrganization from 'sentry/utils/withOrganization';
 import withProjects from 'sentry/utils/withProjects';
+import PreviewChart from 'sentry/views/alerts/rules/issue/previewChart';
 import {
   CHANGE_ALERT_CONDITION_IDS,
   CHANGE_ALERT_PLACEHOLDERS_LABELS,
@@ -134,7 +137,9 @@ type State = AsyncView['state'] & {
     [key: string]: string[];
   };
   environments: Environment[] | null;
+  previewError: null | string;
   project: Project;
+  ruleFireHistory: ProjectAlertRuleStats[] | null;
   uuid: null | string;
   duplicateTargetRule?: UnsavedIssueAlertRule | IssueAlertRule | null;
   ownership?: null | IssueOwnership;
@@ -189,6 +194,7 @@ class IssueRuleEditor extends AsyncView<Props, State> {
       environments: [],
       uuid: null,
       project,
+      ruleFireHistory: null,
     };
 
     const projectTeamIds = new Set(project.teams.map(({id}) => id));
@@ -301,6 +307,42 @@ class IssueRuleEditor extends AsyncView<Props, State> {
     }
   };
 
+  fetchPreview = async () => {
+    const {organization} = this.props;
+    const {project, rule} = this.state;
+
+    if (!rule) {
+      return;
+    }
+    this.setState({loadingPreview: true});
+    try {
+      const response = await this.api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/rules/preview`,
+        {
+          method: 'POST',
+          data: {
+            conditions: rule?.conditions || [],
+            filters: rule?.filters || [],
+            actionMatch: rule?.actionMatch || 'all',
+            filterMatch: rule?.filterMatch || 'all',
+            frequency: rule?.frequency || 60,
+          },
+        }
+      );
+      this.setState({
+        ruleFireHistory: response,
+        previewError: null,
+        loadingPreview: false,
+      });
+    } catch (err) {
+      this.setState({
+        previewError:
+          'Previews are unavailable for this combination of conditions and filters',
+        loadingPreview: false,
+      });
+    }
+  };
+
   fetchEnvironments() {
     const {
       params: {orgId},
@@ -759,6 +801,21 @@ class IssueRuleEditor extends AsyncView<Props, State> {
     );
   }
 
+  renderPreviewGraph() {
+    const {ruleFireHistory, previewError} = this.state;
+    if (ruleFireHistory && !previewError) {
+      return <PreviewChart ruleFireHistory={ruleFireHistory} />;
+    }
+    if (previewError) {
+      return (
+        <Alert type="error" showIcon>
+          {previewError}
+        </Alert>
+      );
+    }
+    return null;
+  }
+
   renderProjectSelect(disabled: boolean) {
     const {project: _selectedProject, projects, organization} = this.props;
     const {rule} = this.state;
@@ -892,7 +949,7 @@ class IssueRuleEditor extends AsyncView<Props, State> {
 
   renderBody() {
     const {organization} = this.props;
-    const {project, rule, detailedError, loading, ownership} = this.state;
+    const {project, rule, detailedError, loading, ownership, loadingPreview} = this.state;
     const {actions, filters, conditions, frequency} = rule || {};
 
     const environment =
@@ -1148,6 +1205,28 @@ class IssueRuleEditor extends AsyncView<Props, State> {
                     </StyledFieldHelp>
                   </StyledListItem>
                   {this.renderActionInterval(disabled)}
+                  <Feature organization={organization} features={['issue-alert-preview']}>
+                    <StyledListItem>
+                      <StyledListItemSpaced>
+                        <div>
+                          {t('Preview history graph')}
+                          <StyledFieldHelp>
+                            {t(
+                              'Shows when this rule would have fired in the past 2 weeks'
+                            )}
+                          </StyledFieldHelp>
+                        </div>
+                        <Button
+                          onClick={this.fetchPreview}
+                          type="button"
+                          disabled={loadingPreview}
+                        >
+                          Generate Preview
+                        </Button>
+                      </StyledListItemSpaced>
+                    </StyledListItem>
+                    {this.renderPreviewGraph()}
+                  </Feature>
                   <StyledListItem>{t('Establish ownership')}</StyledListItem>
                   {this.renderRuleName(disabled)}
                   {this.renderTeamSelect(disabled)}
@@ -1182,6 +1261,11 @@ const StyledListItem = styled(ListItem)`
   font-size: ${p => p.theme.fontSizeExtraLarge};
 `;
 
+const StyledListItemSpaced = styled('div')`
+  display: flex;
+  justify-content: space-between;
+`;
+
 const StyledFieldHelp = styled(FieldHelp)`
   margin-top: 0;
 `;

+ 98 - 0
static/app/views/alerts/rules/issue/previewChart.tsx

@@ -0,0 +1,98 @@
+import styled from '@emotion/styled';
+
+import {AreaChart, AreaChartSeries} from 'sentry/components/charts/areaChart';
+import {HeaderTitleLegend, SectionHeading} from 'sentry/components/charts/styles';
+import {Panel, PanelBody, PanelFooter} from 'sentry/components/panels';
+import Placeholder from 'sentry/components/placeholder';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {ProjectAlertRuleStats} from 'sentry/types/alerts';
+import getDynamicText from 'sentry/utils/getDynamicText';
+
+type Props = {
+  ruleFireHistory: ProjectAlertRuleStats[];
+};
+
+const PreviewChart = ({ruleFireHistory}: Props) => {
+  const renderChart = fireHistory => {
+    const series: AreaChartSeries = {
+      seriesName: 'Alerts Triggered',
+      data: fireHistory.map(alert => ({
+        name: alert.date,
+        value: alert.count,
+      })),
+      emphasis: {
+        disabled: true,
+      },
+    };
+
+    return (
+      <AreaChart
+        isGroupedByDate
+        showTimeInTooltip
+        grid={{
+          left: space(0.25),
+          right: space(2),
+          top: space(3),
+          bottom: 0,
+        }}
+        yAxis={{
+          minInterval: 1,
+        }}
+        series={[series]}
+      />
+    );
+  };
+
+  const totalAlertsTriggered = ruleFireHistory.reduce((acc, curr) => acc + curr.count, 0);
+
+  return (
+    <Panel>
+      <StyledPanelBody withPadding>
+        <ChartHeader>
+          <HeaderTitleLegend>{t('Alerts Triggered')}</HeaderTitleLegend>
+        </ChartHeader>
+        {getDynamicText({
+          value: renderChart(ruleFireHistory),
+          fixed: <Placeholder height="200px" testId="skeleton-ui" />,
+        })}
+      </StyledPanelBody>
+      <ChartFooter>
+        <FooterHeader>{t('Total Alerts')}</FooterHeader>
+        <FooterValue>{totalAlertsTriggered.toLocaleString()}</FooterValue>
+      </ChartFooter>
+    </Panel>
+  );
+};
+
+export default PreviewChart;
+
+const ChartHeader = styled('div')`
+  margin-bottom: ${space(3)};
+`;
+
+const ChartFooter = styled(PanelFooter)`
+  display: flex;
+  align-items: center;
+  padding: ${space(1)} 20px;
+`;
+
+const FooterHeader = styled(SectionHeading)`
+  display: flex;
+  align-items: center;
+  margin: 0;
+  font-weight: bold;
+  font-size: ${p => p.theme.fontSizeMedium};
+  line-height: 1;
+`;
+
+const FooterValue = styled('div')`
+  display: flex;
+  align-items: center;
+  margin: 0 ${space(1)};
+`;
+
+/* Override padding to make chart appear centered */
+const StyledPanelBody = styled(PanelBody)`
+  padding-right: ${space(0.75)};
+`;