Просмотр исходного кода

feat(alerts): Add incident activity to alert rule details (#24085)

* feat(alerts): Add incident activity to alert rule details

* remove activities fetch from FE

* lint changes

* correct thresholds etc

* style(lint): Auto commit lint changes

* fix color

* rename rich serializer

* move cheat data into metric chart

* render metric chart in events request

* rebase

* new rule serializer

* one serializer

* style(lint): Auto commit lint changes

* style(lint): Auto commit lint changes

* lint

* expand serializer

* style(lint): Auto commit lint changes

* style(lint): Auto commit lint changes

* expand all

* style(lint): Auto commit lint changes

* better serialization

* serialize list

* serialize seen by

* style(lint): Auto commit lint changes

* super expand for detailed serializer

* addressing comments

* style(lint): Auto commit lint changes

* style(lint): Auto commit lint changes

* sort seen by

* style(lint): Auto commit lint changes

* fix test

* style(lint): Auto commit lint changes

Co-authored-by: sentry-internal-tools[bot] <66042841+sentry-internal-tools[bot]@users.noreply.github.com>
Taylan Gocmen 4 лет назад
Родитель
Сommit
15d201aa76

+ 45 - 17
src/sentry/api/serializers/models/incident.py

@@ -2,8 +2,13 @@ from collections import defaultdict
 
 
 
 
 from sentry.api.serializers import Serializer, register, serialize
 from sentry.api.serializers import Serializer, register, serialize
-from sentry.incidents.models import Incident, IncidentProject, IncidentSubscription
-from sentry.models import User
+from sentry.incidents.models import (
+    Incident,
+    IncidentActivity,
+    IncidentProject,
+    IncidentSeen,
+    IncidentSubscription,
+)
 from sentry.snuba.models import QueryDatasets
 from sentry.snuba.models import QueryDatasets
 from sentry.snuba.tasks import apply_dataset_query_conditions
 from sentry.snuba.tasks import apply_dataset_query_conditions
 from sentry.utils.db import attach_foreignkey
 from sentry.utils.db import attach_foreignkey
@@ -11,6 +16,9 @@ from sentry.utils.db import attach_foreignkey
 
 
 @register(Incident)
 @register(Incident)
 class IncidentSerializer(Serializer):
 class IncidentSerializer(Serializer):
+    def __init__(self, expand=None):
+        self.expand = expand or []
+
     def get_attrs(self, item_list, user, **kwargs):
     def get_attrs(self, item_list, user, **kwargs):
         attach_foreignkey(item_list, Incident.alert_rule, related=("snuba_query",))
         attach_foreignkey(item_list, Incident.alert_rule, related=("snuba_query",))
         incident_projects = defaultdict(list)
         incident_projects = defaultdict(list)
@@ -29,6 +37,31 @@ class IncidentSerializer(Serializer):
             results[incident] = {"projects": incident_projects.get(incident.id, [])}
             results[incident] = {"projects": incident_projects.get(incident.id, [])}
             results[incident]["alert_rule"] = alert_rules.get(str(incident.alert_rule.id))
             results[incident]["alert_rule"] = alert_rules.get(str(incident.alert_rule.id))
 
 
+        if "seen_by" in self.expand:
+            incident_seen_list = list(
+                IncidentSeen.objects.filter(incident__in=item_list)
+                .select_related("user")
+                .order_by("-last_seen")
+            )
+            incident_seen_dict = defaultdict(list)
+            for incident_seen, serialized_seen_by in zip(
+                incident_seen_list, serialize(incident_seen_list)
+            ):
+                incident_seen_dict[incident_seen.incident_id].append(serialized_seen_by)
+            for incident in item_list:
+                seen_by = incident_seen_dict[incident.id]
+                has_seen = any(seen for seen in seen_by if seen["id"] == str(user.id))
+                results[incident]["seen_by"] = seen_by
+                results[incident]["has_seen"] = has_seen
+
+        if "activities" in self.expand:
+            activities = list(IncidentActivity.objects.filter(incident__in=item_list))
+            incident_activities = defaultdict(list)
+            for activity, serialized_activity in zip(activities, serialize(activities, user=user)):
+                incident_activities[activity.incident_id].append(serialized_activity)
+            for incident in item_list:
+                results[incident]["activities"] = incident_activities[incident.id]
+
         return results
         return results
 
 
     def serialize(self, obj, attrs, user):
     def serialize(self, obj, attrs, user):
@@ -39,6 +72,9 @@ class IncidentSerializer(Serializer):
             "organizationId": str(obj.organization_id),
             "organizationId": str(obj.organization_id),
             "projects": attrs["projects"],
             "projects": attrs["projects"],
             "alertRule": attrs["alert_rule"],
             "alertRule": attrs["alert_rule"],
+            "activities": attrs["activities"] if "activities" in self.expand else None,
+            "seenBy": attrs["seen_by"] if "seen_by" in self.expand else None,
+            "hasSeen": attrs["has_seen"] if "seen_by" in self.expand else None,
             "status": obj.status,
             "status": obj.status,
             "statusMethod": obj.status_method,
             "statusMethod": obj.status_method,
             "type": obj.type,
             "type": obj.type,
@@ -51,6 +87,13 @@ class IncidentSerializer(Serializer):
 
 
 
 
 class DetailedIncidentSerializer(IncidentSerializer):
 class DetailedIncidentSerializer(IncidentSerializer):
+    def __init__(self, expand=None):
+        if expand is None:
+            expand = ["seen_by"]
+        elif "seen_by" not in expand:
+            expand.append("seen_by")
+        super().__init__(expand=expand)
+
     def get_attrs(self, item_list, user, **kwargs):
     def get_attrs(self, item_list, user, **kwargs):
         results = super().get_attrs(item_list, user=user, **kwargs)
         results = super().get_attrs(item_list, user=user, **kwargs)
         subscribed_incidents = set()
         subscribed_incidents = set()
@@ -65,24 +108,9 @@ class DetailedIncidentSerializer(IncidentSerializer):
             results[item]["is_subscribed"] = item.id in subscribed_incidents
             results[item]["is_subscribed"] = item.id in subscribed_incidents
         return results
         return results
 
 
-    def _get_incident_seen_list(self, incident, user):
-        seen_by_list = list(
-            User.objects.filter(incidentseen__incident=incident).order_by(
-                "-incidentseen__last_seen"
-            )
-        )
-
-        has_seen = any(seen_by for seen_by in seen_by_list if seen_by.id == user.id)
-
-        return {"seen_by": serialize(seen_by_list), "has_seen": has_seen}
-
     def serialize(self, obj, attrs, user):
     def serialize(self, obj, attrs, user):
         context = super().serialize(obj, attrs, user)
         context = super().serialize(obj, attrs, user)
-        seen_list = self._get_incident_seen_list(obj, user)
-
         context["isSubscribed"] = attrs["is_subscribed"]
         context["isSubscribed"] = attrs["is_subscribed"]
-        context["seenBy"] = seen_list["seen_by"]
-        context["hasSeen"] = seen_list["has_seen"]
         # The query we should use to get accurate results in Discover.
         # The query we should use to get accurate results in Discover.
         context["discoverQuery"] = self._build_discover_query(obj)
         context["discoverQuery"] = self._build_discover_query(obj)
 
 

+ 3 - 9
src/sentry/incidents/endpoints/organization_incident_index.py

@@ -4,7 +4,7 @@ from sentry.api.bases.organization import OrganizationEndpoint
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.paginator import OffsetPaginator
 from sentry.api.paginator import OffsetPaginator
 from sentry.api.serializers import serialize
 from sentry.api.serializers import serialize
-from sentry.api.serializers.models.incident import DetailedIncidentSerializer
+from sentry.api.serializers.models.incident import IncidentSerializer
 from sentry.incidents.models import Incident, IncidentStatus
 from sentry.incidents.models import Incident, IncidentStatus
 from sentry.snuba.dataset import Dataset
 from sentry.snuba.dataset import Dataset
 
 
@@ -60,19 +60,13 @@ class OrganizationIncidentIndexEndpoint(OrganizationEndpoint):
             # Filter to only error alerts
             # Filter to only error alerts
             incidents = incidents.filter(alert_rule__snuba_query__dataset=Dataset.Events.value)
             incidents = incidents.filter(alert_rule__snuba_query__dataset=Dataset.Events.value)
 
 
-        query_detailed = request.GET.get("detailed")
-
-        def serialize_results(results):
-            if query_detailed:
-                return serialize(results, request.user, DetailedIncidentSerializer())
-            else:
-                return serialize(results, request.user)
+        expand = request.GET.getlist("expand", [])
 
 
         return self.paginate(
         return self.paginate(
             request,
             request,
             queryset=incidents,
             queryset=incidents,
             order_by="-date_started",
             order_by="-date_started",
             paginator_cls=OffsetPaginator,
             paginator_cls=OffsetPaginator,
-            on_results=serialize_results,
+            on_results=lambda x: serialize(x, request.user, IncidentSerializer(expand=expand)),
             default_per_page=25,
             default_per_page=25,
         )
         )

+ 17 - 222
src/sentry/static/sentry/app/views/alerts/rules/details/body.tsx

@@ -1,15 +1,12 @@
 import React from 'react';
 import React from 'react';
 import {RouteComponentProps} from 'react-router';
 import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
-import {withTheme} from 'emotion-theming';
 import {Location} from 'history';
 import {Location} from 'history';
 import moment from 'moment';
 import moment from 'moment';
 
 
 import {Client} from 'app/api';
 import {Client} from 'app/api';
 import Feature from 'app/components/acl/feature';
 import Feature from 'app/components/acl/feature';
 import ActorAvatar from 'app/components/avatar/actorAvatar';
 import ActorAvatar from 'app/components/avatar/actorAvatar';
-import Button from 'app/components/button';
-import EventsRequest from 'app/components/charts/eventsRequest';
 import {SectionHeading} from 'app/components/charts/styles';
 import {SectionHeading} from 'app/components/charts/styles';
 import {getInterval} from 'app/components/charts/utils';
 import {getInterval} from 'app/components/charts/utils';
 import DateTime from 'app/components/dateTime';
 import DateTime from 'app/components/dateTime';
@@ -17,7 +14,7 @@ import DropdownControl, {DropdownItem} from 'app/components/dropdownControl';
 import Duration from 'app/components/duration';
 import Duration from 'app/components/duration';
 import {KeyValueTable, KeyValueTableRow} from 'app/components/keyValueTable';
 import {KeyValueTable, KeyValueTableRow} from 'app/components/keyValueTable';
 import * as Layout from 'app/components/layouts/thirds';
 import * as Layout from 'app/components/layouts/thirds';
-import {Panel, PanelBody, PanelFooter} from 'app/components/panels';
+import {Panel, PanelBody} from 'app/components/panels';
 import Placeholder from 'app/components/placeholder';
 import Placeholder from 'app/components/placeholder';
 import TimeSince from 'app/components/timeSince';
 import TimeSince from 'app/components/timeSince';
 import {IconCheckmark, IconFire, IconUser, IconWarning} from 'app/icons';
 import {IconCheckmark, IconFire, IconUser, IconWarning} from 'app/icons';
@@ -25,12 +22,10 @@ import {t, tct} from 'app/locale';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
 import space from 'app/styles/space';
 import {Actor, Organization, Project} from 'app/types';
 import {Actor, Organization, Project} from 'app/types';
-import {defined} from 'app/utils';
 import Projects from 'app/utils/projects';
 import Projects from 'app/utils/projects';
-import {Theme} from 'app/utils/theme';
+import theme from 'app/utils/theme';
 import Timeline from 'app/views/alerts/rules/details/timeline';
 import Timeline from 'app/views/alerts/rules/details/timeline';
 import {DATASET_EVENT_TYPE_FILTERS} from 'app/views/settings/incidentRules/constants';
 import {DATASET_EVENT_TYPE_FILTERS} from 'app/views/settings/incidentRules/constants';
-import {makeDefaultCta} from 'app/views/settings/incidentRules/incidentRulePresets';
 import {
 import {
   AlertRuleThresholdType,
   AlertRuleThresholdType,
   Dataset,
   Dataset,
@@ -40,7 +35,6 @@ import {
 import {extractEventTypeFilterFromRule} from 'app/views/settings/incidentRules/utils/getEventTypeFilter';
 import {extractEventTypeFilterFromRule} from 'app/views/settings/incidentRules/utils/getEventTypeFilter';
 
 
 import {Incident, IncidentStatus} from '../../types';
 import {Incident, IncidentStatus} from '../../types';
-import {getIncidentRuleMetricPreset} from '../../utils';
 
 
 import {API_INTERVAL_POINTS_LIMIT, TIME_OPTIONS} from './constants';
 import {API_INTERVAL_POINTS_LIMIT, TIME_OPTIONS} from './constants';
 import MetricChart from './metricChart';
 import MetricChart from './metricChart';
@@ -59,34 +53,10 @@ type Props = {
   };
   };
   organization: Organization;
   organization: Organization;
   location: Location;
   location: Location;
-  theme: Theme;
   handleTimePeriodChange: (value: string) => void;
   handleTimePeriodChange: (value: string) => void;
 } & RouteComponentProps<{orgId: string}, {}>;
 } & RouteComponentProps<{orgId: string}, {}>;
 
 
-class DetailsBody extends React.Component<Props> {
-  get metricPreset() {
-    const {rule} = this.props;
-    return rule ? getIncidentRuleMetricPreset(rule) : undefined;
-  }
-
-  /**
-   * Return a string describing the threshold based on the threshold and the type
-   */
-  getThresholdText(
-    value: number | '' | null | undefined,
-    thresholdType?: AlertRuleThresholdType,
-    isAlert: boolean = false
-  ) {
-    if (!defined(value) || !defined(thresholdType)) {
-      return '';
-    }
-
-    const isAbove = thresholdType === AlertRuleThresholdType.ABOVE;
-    const direction = isAbove === isAlert ? '>' : '<';
-
-    return `${direction} ${value}`;
-  }
-
+export default class DetailsBody extends React.Component<Props> {
   getMetricText(): React.ReactNode {
   getMetricText(): React.ReactNode {
     const {rule} = this.props;
     const {rule} = this.props;
 
 
@@ -121,40 +91,6 @@ class DetailsBody extends React.Component<Props> {
     return getInterval({start, end}, true);
     return getInterval({start, end}, true);
   }
   }
 
 
-  calculateSummaryPercentages(
-    incidents: Incident[] | undefined,
-    startTime: string,
-    endTime: string
-  ) {
-    const startDate = moment.utc(startTime);
-    const endDate = moment.utc(endTime);
-    const totalTime = endDate.diff(startDate);
-
-    let criticalPercent = '0';
-    let warningPercent = '0';
-    if (incidents) {
-      const filteredIncidents = incidents.filter(incident => {
-        return !incident.dateClosed || moment(incident.dateClosed).isAfter(startDate);
-      });
-      let criticalDuration = 0;
-      const warningDuration = 0;
-      for (const incident of filteredIncidents) {
-        // use the larger of the start of the incident or the start of the time period
-        const incidentStart = moment.max(moment(incident.dateStarted), startDate);
-        const incidentClose = incident.dateClosed ? moment(incident.dateClosed) : endDate;
-        criticalDuration += incidentClose.diff(incidentStart);
-      }
-      criticalPercent = ((criticalDuration / totalTime) * 100).toFixed(2);
-      warningPercent = ((warningDuration / totalTime) * 100).toFixed(2);
-    }
-    const resolvedPercent = (
-      100 -
-      (Number(criticalPercent) + Number(warningPercent))
-    ).toFixed(2);
-
-    return {criticalPercent, warningPercent, resolvedPercent};
-  }
-
   renderTrigger(trigger: Trigger): React.ReactNode {
   renderTrigger(trigger: Trigger): React.ReactNode {
     const {rule} = this.props;
     const {rule} = this.props;
 
 
@@ -248,71 +184,8 @@ class DetailsBody extends React.Component<Props> {
     );
     );
   }
   }
 
 
-  renderSummaryStatItems({
-    criticalPercent,
-    warningPercent,
-    resolvedPercent,
-  }: {
-    criticalPercent: string;
-    warningPercent: string;
-    resolvedPercent: string;
-  }) {
-    return (
-      <React.Fragment>
-        <StatItem>
-          <IconCheckmark color="green300" isCircled />
-          <StatCount>{resolvedPercent}%</StatCount>
-        </StatItem>
-        <StatItem>
-          <IconWarning color="yellow300" />
-          <StatCount>{warningPercent}%</StatCount>
-        </StatItem>
-        <StatItem>
-          <IconFire color="red300" />
-          <StatCount>{criticalPercent}%</StatCount>
-        </StatItem>
-      </React.Fragment>
-    );
-  }
-
-  renderChartActions(projects: Project[]) {
-    const {rule, params, incidents, timePeriod} = this.props;
-    const preset = this.metricPreset;
-    const ctaOpts = {
-      orgSlug: params.orgId,
-      projects,
-      rule,
-      start: timePeriod.start,
-      end: timePeriod.end,
-    };
-
-    const {buttonText, ...props} = preset
-      ? preset.makeCtaParams(ctaOpts)
-      : makeDefaultCta(ctaOpts);
-
-    const percentages = this.calculateSummaryPercentages(
-      incidents,
-      timePeriod.start,
-      timePeriod.end
-    );
-
-    return (
-      <ChartActions>
-        <ChartSummary>
-          <SummaryText>{t('SUMMARY')}</SummaryText>
-          <SummaryStats>{this.renderSummaryStatItems(percentages)}</SummaryStats>
-        </ChartSummary>
-        <Feature features={['discover-basic']}>
-          <Button size="small" disabled={!rule} {...props}>
-            {buttonText}
-          </Button>
-        </Feature>
-      </ChartActions>
-    );
-  }
-
   renderMetricStatus() {
   renderMetricStatus() {
-    const {incidents, theme} = this.props;
+    const {incidents} = this.props;
 
 
     // get current status
     // get current status
     const activeIncident = incidents?.find(({dateClosed}) => !dateClosed);
     const activeIncident = incidents?.find(({dateClosed}) => !dateClosed);
@@ -394,10 +267,8 @@ class DetailsBody extends React.Component<Props> {
       return this.renderLoading();
       return this.renderLoading();
     }
     }
 
 
-    const {query, environment, aggregate, projects: projectSlugs, triggers} = rule;
+    const {query, projects: projectSlugs} = rule;
 
 
-    const criticalTrigger = triggers.find(({label}) => label === 'critical');
-    const warningTrigger = triggers.find(({label}) => label === 'warning');
     const queryWithTypeFilter = `${query} ${extractEventTypeFilterFromRule(rule)}`.trim();
     const queryWithTypeFilter = `${query} ${extractEventTypeFilterFromRule(rule)}`.trim();
 
 
     return (
     return (
@@ -426,45 +297,18 @@ class DetailsBody extends React.Component<Props> {
                     </StyledTimeRange>
                     </StyledTimeRange>
                   )}
                   )}
                 </ChartControls>
                 </ChartControls>
-                <ChartPanel>
-                  <PanelBody withPadding>
-                    <ChartHeader>
-                      <PresetName>
-                        {this.metricPreset?.name ?? t('Custom metric')}
-                      </PresetName>
-                      {this.getMetricText()}
-                    </ChartHeader>
-                    <EventsRequest
-                      api={api}
-                      organization={organization}
-                      query={queryWithTypeFilter}
-                      environment={environment ? [environment] : undefined}
-                      project={(projects as Project[]).map(project => Number(project.id))}
-                      interval={this.getInterval()}
-                      start={timePeriod.start}
-                      end={timePeriod.end}
-                      yAxis={aggregate}
-                      includePrevious={false}
-                      currentSeriesName={aggregate}
-                      partial={false}
-                    >
-                      {({loading, timeseriesData}) =>
-                        !loading && timeseriesData ? (
-                          <MetricChart
-                            data={timeseriesData}
-                            ruleChangeThreshold={rule?.dateModified}
-                            incidents={incidents}
-                            criticalTrigger={criticalTrigger}
-                            warningTrigger={warningTrigger}
-                          />
-                        ) : (
-                          <Placeholder height="200px" />
-                        )
-                      }
-                    </EventsRequest>
-                  </PanelBody>
-                  {this.renderChartActions(projects as Project[])}
-                </ChartPanel>
+                <MetricChart
+                  api={api}
+                  rule={rule}
+                  incidents={incidents}
+                  timePeriod={timePeriod}
+                  organization={organization}
+                  projects={projects}
+                  metricText={this.getMetricText()}
+                  interval={this.getInterval()}
+                  query={queryWithTypeFilter}
+                  orgId={orgId}
+                />
                 <DetailWrapper>
                 <DetailWrapper>
                   <ActivityWrapper>
                   <ActivityWrapper>
                     {rule?.dataset === Dataset.ERRORS && (
                     {rule?.dataset === Dataset.ERRORS && (
@@ -597,53 +441,6 @@ const ChartPanel = styled(Panel)`
   margin-top: ${space(2)};
   margin-top: ${space(2)};
 `;
 `;
 
 
-const ChartHeader = styled('header')`
-  margin-bottom: ${space(1)};
-  display: flex;
-  flex-direction: row;
-`;
-
-const PresetName = styled('div')`
-  text-transform: capitalize;
-  margin-right: ${space(0.5)};
-`;
-
-const ChartActions = styled(PanelFooter)`
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  padding: ${space(1)} 20px;
-`;
-
-const ChartSummary = styled('div')`
-  display: flex;
-  margin-right: auto;
-`;
-
-const SummaryText = styled('span')`
-  margin-top: ${space(0.25)};
-  font-weight: bold;
-  font-size: ${p => p.theme.fontSizeSmall};
-`;
-
-const SummaryStats = styled('div')`
-  display: flex;
-  align-items: center;
-  margin: 0 ${space(2)};
-`;
-
-const StatItem = styled('div')`
-  display: flex;
-  align-items: center;
-  margin: 0 ${space(2)} 0 0;
-`;
-
-const StatCount = styled('span')`
-  margin-left: ${space(0.5)};
-  margin-top: ${space(0.25)};
-  color: ${p => p.theme.textColor};
-`;
-
 const RuleText = styled('div')`
 const RuleText = styled('div')`
   font-size: ${p => p.theme.fontSizeLarge};
   font-size: ${p => p.theme.fontSizeLarge};
 `;
 `;
@@ -667,5 +464,3 @@ const TriggerText = styled('div')`
 const CreatedBy = styled('div')`
 const CreatedBy = styled('div')`
   ${overflowEllipsis}
   ${overflowEllipsis}
 `;
 `;
-
-export default withTheme(DetailsBody);

+ 373 - 103
src/sentry/static/sentry/app/views/alerts/rules/details/metricChart.tsx

@@ -1,26 +1,48 @@
 import React from 'react';
 import React from 'react';
+import styled from '@emotion/styled';
 import color from 'color';
 import color from 'color';
 import moment from 'moment';
 import moment from 'moment';
 
 
+import {Client} from 'app/api';
+import Feature from 'app/components/acl/feature';
+import Button from 'app/components/button';
 import Graphic from 'app/components/charts/components/graphic';
 import Graphic from 'app/components/charts/components/graphic';
 import MarkLine from 'app/components/charts/components/markLine';
 import MarkLine from 'app/components/charts/components/markLine';
+import EventsRequest from 'app/components/charts/eventsRequest';
 import LineChart, {LineChartSeries} from 'app/components/charts/lineChart';
 import LineChart, {LineChartSeries} from 'app/components/charts/lineChart';
+import {Panel, PanelBody, PanelFooter} from 'app/components/panels';
+import Placeholder from 'app/components/placeholder';
+import {IconCheckmark, IconFire, IconWarning} from 'app/icons';
+import {t} from 'app/locale';
 import space from 'app/styles/space';
 import space from 'app/styles/space';
-import {ReactEchartsRef, Series} from 'app/types/echarts';
+import {AvatarProject, Organization, Project} from 'app/types';
+import {ReactEchartsRef} from 'app/types/echarts';
 import theme from 'app/utils/theme';
 import theme from 'app/utils/theme';
-import {Trigger} from 'app/views/settings/incidentRules/types';
+import {makeDefaultCta} from 'app/views/settings/incidentRules/incidentRulePresets';
+import {IncidentRule} from 'app/views/settings/incidentRules/types';
 
 
-import {Incident} from '../../types';
+import {Incident, IncidentActivityType, IncidentStatus} from '../../types';
+import {getIncidentRuleMetricPreset} from '../../utils';
 
 
 const X_AXIS_BOUNDARY_GAP = 15;
 const X_AXIS_BOUNDARY_GAP = 15;
 const VERTICAL_PADDING = 22;
 const VERTICAL_PADDING = 22;
 
 
 type Props = {
 type Props = {
-  data: Series[];
-  ruleChangeThreshold?: string;
+  api: Client;
+  rule?: IncidentRule;
   incidents?: Incident[];
   incidents?: Incident[];
-  warningTrigger?: Trigger;
-  criticalTrigger?: Trigger;
+  timePeriod: {
+    start: string;
+    end: string;
+    label: string;
+    custom?: boolean;
+  };
+  organization: Organization;
+  projects: Project[] | AvatarProject[];
+  metricText: React.ReactNode;
+  interval: string;
+  query: string;
+  orgId: string;
 };
 };
 
 
 type State = {
 type State = {
@@ -41,6 +63,47 @@ function createThresholdSeries(lineColor: string, threshold: number): LineChartS
   };
   };
 }
 }
 
 
+function createStatusAreaSeries(
+  lineColor: string,
+  startTime: number,
+  endTime: number
+): LineChartSeries {
+  return {
+    seriesName: 'Status Area',
+    type: 'line',
+    markLine: MarkLine({
+      silent: true,
+      lineStyle: {color: lineColor, type: 'solid', width: 4},
+      data: [[{coord: [startTime, 0]}, {coord: [endTime, 0]}] as any],
+    }),
+    data: [],
+  };
+}
+
+function createIncidentSeries(
+  lineColor: string,
+  incidentTimestamp: number,
+  label?: string
+) {
+  return {
+    seriesName: 'Incident Line',
+    type: 'line',
+    markLine: MarkLine({
+      silent: true,
+      lineStyle: {color: lineColor, type: 'solid'},
+      data: [{xAxis: incidentTimestamp}] as any,
+      label: {
+        show: !!label,
+        position: 'insideEndBottom',
+        formatter: label || '-',
+        color: lineColor,
+        fontSize: 10,
+      } as any,
+    }),
+    data: [],
+  };
+}
+
 export default class MetricChart extends React.PureComponent<Props, State> {
 export default class MetricChart extends React.PureComponent<Props, State> {
   state = {
   state = {
     width: -1,
     width: -1,
@@ -49,6 +112,11 @@ export default class MetricChart extends React.PureComponent<Props, State> {
 
 
   ref: null | ReactEchartsRef = null;
   ref: null | ReactEchartsRef = null;
 
 
+  get metricPreset() {
+    const {rule} = this.props;
+    return rule ? getIncidentRuleMetricPreset(rule) : undefined;
+  }
+
   /**
   /**
    * Syncs component state with the chart's width/heights
    * Syncs component state with the chart's width/heights
    */
    */
@@ -79,18 +147,18 @@ export default class MetricChart extends React.PureComponent<Props, State> {
     }
     }
   };
   };
 
 
-  getRuleCreatedThresholdElements = () => {
+  getRuleChangeThresholdElements = data => {
     const {height, width} = this.state;
     const {height, width} = this.state;
-    const {data, ruleChangeThreshold} = this.props;
+    const {dateModified} = this.props.rule || {};
 
 
-    if (!data.length || !data[0].data.length) {
+    if (!data.length || !data[0].data.length || !dateModified) {
       return [];
       return [];
     }
     }
 
 
     const seriesData = data[0].data;
     const seriesData = data[0].data;
     const seriesStart = seriesData[0].name as number;
     const seriesStart = seriesData[0].name as number;
     const seriesEnd = seriesData[seriesData.length - 1].name as number;
     const seriesEnd = seriesData[seriesData.length - 1].name as number;
-    const ruleChanged = moment(ruleChangeThreshold).valueOf();
+    const ruleChanged = moment(dateModified).valueOf();
 
 
     if (ruleChanged < seriesStart) {
     if (ruleChanged < seriesStart) {
       return [];
       return [];
@@ -128,102 +196,66 @@ export default class MetricChart extends React.PureComponent<Props, State> {
     ];
     ];
   };
   };
 
 
-  render() {
-    const {data, incidents, warningTrigger, criticalTrigger} = this.props;
-
-    const series: LineChartSeries[] = [...data];
-    // Ensure series data appears above incident lines
-    series[0].z = 100;
-    const dataArr = data[0].data;
-    const maxSeriesValue = dataArr.reduce(
-      (currMax, coord) => Math.max(currMax, coord.value),
-      0
-    );
-    const firstPoint = Number(dataArr[0].name);
-    const lastPoint = dataArr[dataArr.length - 1].name;
-    const resolvedArea = {
-      seriesName: 'Resolved Area',
-      type: 'line',
-      markLine: MarkLine({
-        silent: true,
-        lineStyle: {color: theme.green300, type: 'solid', width: 4},
-        data: [[{coord: [firstPoint, 0]}, {coord: [lastPoint, 0]}] as any],
-      }),
-      data: [],
+  renderChartActions(
+    totalDuration: number,
+    criticalDuration: number,
+    warningDuration: number
+  ) {
+    const {rule, orgId, projects, timePeriod} = this.props;
+    const preset = this.metricPreset;
+    const ctaOpts = {
+      orgSlug: orgId,
+      projects: projects as Project[],
+      rule,
+      start: timePeriod.start,
+      end: timePeriod.end,
     };
     };
-    series.push(resolvedArea);
-    if (incidents) {
-      // select incidents that fall within the graph range
-      const periodStart = moment.utc(firstPoint);
-      const filteredIncidents = incidents.filter(incident => {
-        return !incident.dateClosed || moment(incident.dateClosed).isAfter(periodStart);
-      });
 
 
-      const criticalLines = filteredIncidents.map(incident => {
-        const detectTime = Math.max(moment(incident.dateStarted).valueOf(), firstPoint);
-        let resolveTime;
-        if (incident.dateClosed) {
-          resolveTime = moment(incident.dateClosed).valueOf();
-        } else {
-          resolveTime = lastPoint;
-        }
-        return [{coord: [detectTime, 0]}, {coord: [resolveTime, 0]}];
-      });
-      const criticalArea = {
-        seriesName: 'Critical Area',
-        type: 'line',
-        markLine: MarkLine({
-          silent: true,
-          lineStyle: {color: theme.red300, type: 'solid', width: 4},
-          data: criticalLines as any,
-        }),
-        data: [],
-      };
-      series.push(criticalArea);
-
-      const incidentValueMap: Record<number, string> = {};
-      const incidentLines = filteredIncidents.map(({dateStarted, identifier}) => {
-        const incidentStart = moment(dateStarted).valueOf();
-        incidentValueMap[incidentStart] = identifier;
-        return {xAxis: incidentStart};
-      });
-      const incidentLinesSeries = {
-        seriesName: 'Incident Line',
-        type: 'line',
-        markLine: MarkLine({
-          silent: true,
-          lineStyle: {color: theme.red300, type: 'solid'},
-          data: incidentLines as any,
-          label: {
-            show: true,
-            position: 'insideEndBottom',
-            formatter: ({value}) => {
-              return incidentValueMap[value] ?? '-';
-            },
-            color: theme.red300,
-            fontSize: 10,
-          } as any,
-        }),
-        data: [],
-      };
-      series.push(incidentLinesSeries);
-    }
+    const {buttonText, ...props} = preset
+      ? preset.makeCtaParams(ctaOpts)
+      : makeDefaultCta(ctaOpts);
 
 
-    let maxThresholdValue = 0;
-    if (warningTrigger?.alertThreshold) {
-      const {alertThreshold} = warningTrigger;
-      const warningThresholdLine = createThresholdSeries(theme.yellow300, alertThreshold);
-      series.push(warningThresholdLine);
-      maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
-    }
+    const resolvedPercent = (
+      (100 * (totalDuration - criticalDuration - warningDuration)) /
+      totalDuration
+    ).toFixed(2);
+    const criticalPercent = ((100 * criticalDuration) / totalDuration).toFixed(2);
+    const warningPercent = ((100 * warningDuration) / totalDuration).toFixed(2);
 
 
-    if (criticalTrigger?.alertThreshold) {
-      const {alertThreshold} = criticalTrigger;
-      const criticalThresholdLine = createThresholdSeries(theme.red300, alertThreshold);
-      series.push(criticalThresholdLine);
-      maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
-    }
+    return (
+      <ChartActions>
+        <ChartSummary>
+          <SummaryText>{t('SUMMARY')}</SummaryText>
+          <SummaryStats>
+            <StatItem>
+              <IconCheckmark color="green300" isCircled />
+              <StatCount>{resolvedPercent}%</StatCount>
+            </StatItem>
+            <StatItem>
+              <IconWarning color="yellow300" />
+              <StatCount>{warningPercent}%</StatCount>
+            </StatItem>
+            <StatItem>
+              <IconFire color="red300" />
+              <StatCount>{criticalPercent}%</StatCount>
+            </StatItem>
+          </SummaryStats>
+        </ChartSummary>
+        <Feature features={['discover-basic']}>
+          <Button size="small" disabled={!rule} {...props}>
+            {buttonText}
+          </Button>
+        </Feature>
+      </ChartActions>
+    );
+  }
 
 
+  renderChart(
+    data: LineChartSeries[],
+    series: LineChartSeries[],
+    maxThresholdValue: number,
+    maxSeriesValue: number
+  ) {
     return (
     return (
       <LineChart
       <LineChart
         isGroupedByDate
         isGroupedByDate
@@ -238,7 +270,7 @@ export default class MetricChart extends React.PureComponent<Props, State> {
         yAxis={maxThresholdValue > maxSeriesValue ? {max: maxThresholdValue} : undefined}
         yAxis={maxThresholdValue > maxSeriesValue ? {max: maxThresholdValue} : undefined}
         series={series}
         series={series}
         graphic={Graphic({
         graphic={Graphic({
-          elements: this.getRuleCreatedThresholdElements(),
+          elements: this.getRuleChangeThresholdElements(data),
         })}
         })}
         onFinished={() => {
         onFinished={() => {
           // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
           // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
@@ -248,4 +280,242 @@ export default class MetricChart extends React.PureComponent<Props, State> {
       />
       />
     );
     );
   }
   }
+
+  renderEmpty() {
+    return (
+      <ChartPanel>
+        <PanelBody withPadding>
+          <Placeholder height="200px" />
+        </PanelBody>
+      </ChartPanel>
+    );
+  }
+
+  render() {
+    const {
+      api,
+      rule,
+      organization,
+      timePeriod,
+      projects,
+      interval,
+      metricText,
+      query,
+      incidents,
+    } = this.props;
+
+    if (!rule) {
+      return this.renderEmpty();
+    }
+
+    const criticalTrigger = rule.triggers.find(({label}) => label === 'critical');
+    const warningTrigger = rule.triggers.find(({label}) => label === 'warning');
+
+    return (
+      <EventsRequest
+        api={api}
+        organization={organization}
+        query={query}
+        environment={rule.environment ? [rule.environment] : undefined}
+        project={(projects as Project[]).map(project => Number(project.id))}
+        interval={interval}
+        start={timePeriod.start}
+        end={timePeriod.end}
+        yAxis={rule.aggregate}
+        includePrevious={false}
+        currentSeriesName={rule.aggregate}
+        partial={false}
+      >
+        {({loading, timeseriesData}) => {
+          if (loading || !timeseriesData) {
+            return this.renderEmpty();
+          }
+
+          const series: LineChartSeries[] = [...timeseriesData];
+          // Ensure series data appears above incident lines
+          series[0].z = 100;
+          const dataArr = timeseriesData[0].data;
+          const maxSeriesValue = dataArr.reduce(
+            (currMax, coord) => Math.max(currMax, coord.value),
+            0
+          );
+          const firstPoint = Number(dataArr[0].name);
+          const lastPoint = dataArr[dataArr.length - 1].name as number;
+          const totalDuration = lastPoint - firstPoint;
+          let criticalDuration = 0;
+          let warningDuration = 0;
+
+          series.push(createStatusAreaSeries(theme.green300, firstPoint, lastPoint));
+          if (incidents) {
+            // select incidents that fall within the graph range
+            const periodStart = moment.utc(firstPoint);
+
+            incidents
+              .filter(
+                incident =>
+                  !incident.dateClosed || moment(incident.dateClosed).isAfter(periodStart)
+              )
+              .forEach(incident => {
+                const statusChanges = incident.activities
+                  ?.filter(
+                    ({type, value}) =>
+                      type === IncidentActivityType.STATUS_CHANGE &&
+                      value &&
+                      [
+                        `${IncidentStatus.WARNING}`,
+                        `${IncidentStatus.CRITICAL}`,
+                      ].includes(value)
+                  )
+                  .sort(
+                    (a, b) =>
+                      moment(a.dateCreated).valueOf() - moment(b.dateCreated).valueOf()
+                  );
+
+                const incidentEnd = incident.dateClosed ?? moment().valueOf();
+
+                const timeWindowMs = rule.timeWindow * 60 * 1000;
+
+                series.push(
+                  createIncidentSeries(
+                    warningTrigger &&
+                      statusChanges &&
+                      !statusChanges.find(
+                        ({value}) => value === `${IncidentStatus.CRITICAL}`
+                      )
+                      ? theme.yellow300
+                      : theme.red300,
+                    moment(incident.dateStarted).valueOf(),
+                    incident.identifier
+                  )
+                );
+                const areaStart = moment(incident.dateStarted).valueOf();
+                const areaEnd =
+                  statusChanges?.length && statusChanges[0].dateCreated
+                    ? moment(statusChanges[0].dateCreated).valueOf() - timeWindowMs
+                    : moment(incidentEnd).valueOf();
+                const areaColor = warningTrigger ? theme.yellow300 : theme.red300;
+                series.push(createStatusAreaSeries(areaColor, areaStart, areaEnd));
+                if (areaColor === theme.yellow300) {
+                  warningDuration += areaEnd - areaStart;
+                } else {
+                  criticalDuration += areaEnd - areaStart;
+                }
+
+                statusChanges?.forEach((activity, idx) => {
+                  const statusAreaStart =
+                    moment(activity.dateCreated).valueOf() - timeWindowMs;
+                  const statusAreaColor =
+                    activity.value === `${IncidentStatus.CRITICAL}`
+                      ? theme.red300
+                      : theme.yellow300;
+                  const statusAreaEnd =
+                    idx === statusChanges.length - 1
+                      ? moment(incidentEnd).valueOf()
+                      : moment(statusChanges[idx + 1].dateCreated).valueOf() -
+                        timeWindowMs;
+                  series.push(
+                    createStatusAreaSeries(statusAreaColor, areaStart, statusAreaEnd)
+                  );
+                  if (statusAreaColor === theme.yellow300) {
+                    warningDuration += statusAreaEnd - statusAreaStart;
+                  } else {
+                    criticalDuration += statusAreaEnd - statusAreaStart;
+                  }
+                });
+              });
+          }
+
+          let maxThresholdValue = 0;
+          if (warningTrigger?.alertThreshold) {
+            const {alertThreshold} = warningTrigger;
+            const warningThresholdLine = createThresholdSeries(
+              theme.yellow300,
+              alertThreshold
+            );
+            series.push(warningThresholdLine);
+            maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
+          }
+
+          if (criticalTrigger?.alertThreshold) {
+            const {alertThreshold} = criticalTrigger;
+            const criticalThresholdLine = createThresholdSeries(
+              theme.red300,
+              alertThreshold
+            );
+            series.push(criticalThresholdLine);
+            maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
+          }
+
+          return (
+            <ChartPanel>
+              <PanelBody withPadding>
+                <ChartHeader>
+                  <PresetName>{this.metricPreset?.name ?? t('Custom metric')}</PresetName>
+                  {metricText}
+                </ChartHeader>
+                {this.renderChart(
+                  timeseriesData,
+                  series,
+                  maxThresholdValue,
+                  maxSeriesValue
+                )}
+              </PanelBody>
+              {this.renderChartActions(totalDuration, criticalDuration, warningDuration)}
+            </ChartPanel>
+          );
+        }}
+      </EventsRequest>
+    );
+  }
 }
 }
+
+const ChartPanel = styled(Panel)`
+  margin-top: ${space(2)};
+`;
+
+const ChartHeader = styled('header')`
+  margin-bottom: ${space(1)};
+  display: flex;
+  flex-direction: row;
+`;
+
+const PresetName = styled('div')`
+  text-transform: capitalize;
+  margin-right: ${space(0.5)};
+`;
+
+const ChartActions = styled(PanelFooter)`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  padding: ${space(1)} 20px;
+`;
+
+const ChartSummary = styled('div')`
+  display: flex;
+  margin-right: auto;
+`;
+
+const SummaryText = styled('span')`
+  margin-top: ${space(0.25)};
+  font-weight: bold;
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const SummaryStats = styled('div')`
+  display: flex;
+  align-items: center;
+  margin: 0 ${space(2)};
+`;
+
+const StatItem = styled('div')`
+  display: flex;
+  align-items: center;
+  margin: 0 ${space(2)} 0 0;
+`;
+
+const StatCount = styled('span')`
+  margin-left: ${space(0.5)};
+  margin-top: ${space(0.25)};
+  color: ${p => p.theme.textColor};
+`;

+ 5 - 50
src/sentry/static/sentry/app/views/alerts/rules/details/timeline.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 import moment from 'moment-timezone';
 import moment from 'moment-timezone';
 
 
-import {fetchIncidentActivities} from 'app/actionCreators/incident';
 import {Client} from 'app/api';
 import {Client} from 'app/api';
 import {SectionHeading} from 'app/components/charts/styles';
 import {SectionHeading} from 'app/components/charts/styles';
 import DateTime from 'app/components/dateTime';
 import DateTime from 'app/components/dateTime';
@@ -24,8 +23,6 @@ import {
 } from 'app/views/alerts/types';
 } from 'app/views/alerts/types';
 import {IncidentRule} from 'app/views/settings/incidentRules/types';
 import {IncidentRule} from 'app/views/settings/incidentRules/types';
 
 
-type Activities = Array<ActivityType>;
-
 type IncidentProps = {
 type IncidentProps = {
   api: Client;
   api: Client;
   orgId: string;
   orgId: string;
@@ -33,52 +30,11 @@ type IncidentProps = {
   rule: IncidentRule;
   rule: IncidentRule;
 };
 };
 
 
-type IncidentState = {
-  loading: boolean;
-  error: boolean;
-  activities: null | Activities;
-};
-
-class TimelineIncident extends React.Component<IncidentProps, IncidentState> {
-  state: IncidentState = {
-    loading: true,
-    error: false,
-    activities: null,
-  };
-
-  componentDidMount() {
-    this.fetchData();
-  }
-
-  componentDidUpdate(prevProps: IncidentProps) {
-    // Only refetch if incidentStatus changes.
-    //
-    // This component can mount before incident details is fully loaded.
-    // In which case, `incidentStatus` is null and we will be fetching via `cDM`
-    // There's no need to fetch this gets updated due to incident details being loaded
-    if (
-      prevProps.incident.status !== null &&
-      prevProps.incident.status !== this.props.incident.status
-    ) {
-      this.fetchData();
-    }
-  }
-
-  async fetchData() {
-    const {api, orgId, incident} = this.props;
-
-    try {
-      const activities = await fetchIncidentActivities(api, orgId, incident.identifier);
-      this.setState({activities, loading: false});
-    } catch (err) {
-      this.setState({loading: false, error: !!err});
-    }
-  }
-
+class TimelineIncident extends React.Component<IncidentProps> {
   renderActivity(activity: ActivityType, idx: number) {
   renderActivity(activity: ActivityType, idx: number) {
     const {incident, rule} = this.props;
     const {incident, rule} = this.props;
-    const {activities} = this.state;
-    const last = this.state.activities && idx === this.state.activities.length - 1;
+    const {activities} = incident;
+    const last = activities && idx === activities.length - 1;
     const authorName = activity.user?.name ?? 'Sentry';
     const authorName = activity.user?.name ?? 'Sentry';
 
 
     const isDetected = activity.type === IncidentActivityType.DETECTED;
     const isDetected = activity.type === IncidentActivityType.DETECTED;
@@ -179,7 +135,6 @@ class TimelineIncident extends React.Component<IncidentProps, IncidentState> {
 
 
   render() {
   render() {
     const {incident} = this.props;
     const {incident} = this.props;
-    const {activities} = this.state;
     return (
     return (
       <IncidentSection key={incident.identifier}>
       <IncidentSection key={incident.identifier}>
         <IncidentHeader>
         <IncidentHeader>
@@ -194,9 +149,9 @@ class TimelineIncident extends React.Component<IncidentProps, IncidentState> {
             )}
             )}
           </SeenByTab>
           </SeenByTab>
         </IncidentHeader>
         </IncidentHeader>
-        {activities && (
+        {incident.activities && (
           <IncidentBody>
           <IncidentBody>
-            {activities
+            {incident.activities
               .filter(activity => activity.type !== IncidentActivityType.COMMENT)
               .filter(activity => activity.type !== IncidentActivityType.COMMENT)
               .map((activity, idx) => this.renderActivity(activity, idx))}
               .map((activity, idx) => this.renderActivity(activity, idx))}
           </IncidentBody>
           </IncidentBody>

+ 1 - 0
src/sentry/static/sentry/app/views/alerts/types.tsx

@@ -21,6 +21,7 @@ export type Incident = {
   title: string;
   title: string;
   hasSeen: boolean;
   hasSeen: boolean;
   alertRule: IncidentRule;
   alertRule: IncidentRule;
+  activities?: ActivityType[];
 };
 };
 
 
 export type IncidentStats = {
 export type IncidentStats = {

+ 1 - 1
src/sentry/static/sentry/app/views/alerts/utils/index.tsx

@@ -33,7 +33,7 @@ export function fetchIncidentsForRule(
   end: string
   end: string
 ): Promise<Incident[]> {
 ): Promise<Incident[]> {
   return uncancellableApi.requestPromise(`/organizations/${orgId}/incidents/`, {
   return uncancellableApi.requestPromise(`/organizations/${orgId}/incidents/`, {
-    query: {alertRule, start, end, detailed: true},
+    query: {alertRule, start, end, expand: ['activities', 'seen_by']},
   });
   });
 }
 }
 
 

+ 1 - 1
tests/sentry/incidents/endpoints/test_organization_incident_details.py

@@ -61,7 +61,7 @@ class OrganizationIncidentDetailsTest(BaseIncidentDetailsTest, APITestCase):
         assert resp.data["dateDetected"] == expected["dateDetected"]
         assert resp.data["dateDetected"] == expected["dateDetected"]
         assert resp.data["dateCreated"] == expected["dateCreated"]
         assert resp.data["dateCreated"] == expected["dateCreated"]
         assert resp.data["projects"] == expected["projects"]
         assert resp.data["projects"] == expected["projects"]
-        assert resp.data["seenBy"] == seen_by
+        assert [item["id"] for item in resp.data["seenBy"]] == [item["id"] for item in seen_by]
 
 
 
 
 class OrganizationIncidentUpdateStatusTest(BaseIncidentDetailsTest, APITestCase):
 class OrganizationIncidentUpdateStatusTest(BaseIncidentDetailsTest, APITestCase):