Browse Source

feat: Add Activated Alert Rules to alert rule index (#69124)

Duplicates the AlertRuleRow component to enable Activated Alert Rule
rows.

<img width="1418" alt="Screenshot 2024-04-17 at 11 24 44 AM"
src="https://github.com/getsentry/sentry/assets/6186377/3d07f663-84a2-412c-bc98-602b794b15cc">

Moves Alert Badge next to the rule status
Introduces a new "active state" to determine whether we are "ready to
monitor" or "actively monitoring" states
cleans up the status logic
Nathan Hsieh 10 months ago
parent
commit
e4303d3302

+ 1 - 0
fixtures/js-stubs/metricRule.ts

@@ -7,6 +7,7 @@ export function MetricRuleFixture(
   params: Partial<SavedMetricRule> = {}
 ): SavedMetricRule {
   return {
+    activations: [],
     status: 0,
     dateCreated: '2019-07-31T23:02:02.731Z',
     dataset: Dataset.ERRORS,

+ 23 - 3
static/app/components/badge/alertBadge.tsx

@@ -1,14 +1,25 @@
 import styled from '@emotion/styled';
 
 import {DiamondStatus} from 'sentry/components/diamondStatus';
-import {IconCheckmark, IconExclamation, IconFire, IconIssues} from 'sentry/icons';
+import {
+  IconCheckmark,
+  IconEllipsis,
+  IconExclamation,
+  IconFire,
+  IconIssues,
+  IconShow,
+} from 'sentry/icons';
 import type {SVGIconProps} from 'sentry/icons/svgIcon';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {ColorOrAlias} from 'sentry/utils/theme';
-import {IncidentStatus} from 'sentry/views/alerts/types';
+import {ActivationStatus, IncidentStatus} from 'sentry/views/alerts/types';
 
 type Props = {
+  /**
+   * The rule is actively monitoring
+   */
+  activationStatus?: ActivationStatus;
   /**
    * @deprecated use withText
    */
@@ -31,7 +42,7 @@ type Props = {
  * This badge is a composition of DiamondStatus specifically used for incident
  * alerts.
  */
-function AlertBadge({status, withText, isIssue}: Props) {
+function AlertBadge({status, withText, isIssue, activationStatus}: Props) {
   let statusText = t('Resolved');
   let Icon: React.ComponentType<SVGIconProps> = IconCheckmark;
   let color: ColorOrAlias = 'successText';
@@ -50,6 +61,15 @@ function AlertBadge({status, withText, isIssue}: Props) {
     color = 'warningText';
   }
 
+  if (activationStatus === ActivationStatus.WAITING) {
+    statusText = t('Ready');
+    Icon = IconEllipsis;
+    color = 'purple300';
+  } else if (activationStatus === ActivationStatus.MONITORING) {
+    statusText = t('Monitoring');
+    Icon = IconShow;
+  }
+
   return (
     <Wrapper data-test-id="alert-badge">
       <DiamondStatus

+ 456 - 0
static/app/views/alerts/list/rules/activatedRuleRow.tsx

@@ -0,0 +1,456 @@
+import {useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import Access from 'sentry/components/acl/access';
+import ActorAvatar from 'sentry/components/avatar/actorAvatar';
+import TeamAvatar from 'sentry/components/avatar/teamAvatar';
+import AlertBadge from 'sentry/components/badge/alertBadge';
+import {openConfirmModal} from 'sentry/components/confirm';
+import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
+import type {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types';
+import DropdownBubble from 'sentry/components/dropdownBubble';
+import type {MenuItemProps} from 'sentry/components/dropdownMenu';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import IdBadge from 'sentry/components/idBadge';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import TextOverflow from 'sentry/components/textOverflow';
+import TimeSince from 'sentry/components/timeSince';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconArrow, IconChevron, IconEllipsis, IconMute, IconUser} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Actor, Project} from 'sentry/types';
+import type {ColorOrAlias} from 'sentry/utils/theme';
+import {useUserTeams} from 'sentry/utils/useUserTeams';
+import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants';
+import {
+  AlertRuleComparisonType,
+  AlertRuleThresholdType,
+  AlertRuleTriggerType,
+} from 'sentry/views/alerts/rules/metric/types';
+
+import type {CombinedMetricIssueAlerts, MetricAlert} from '../../types';
+import {ActivationStatus, CombinedAlertType, IncidentStatus} from '../../types';
+
+type Props = {
+  hasEditAccess: boolean;
+  onDelete: (projectId: string, rule: CombinedMetricIssueAlerts) => void;
+  onOwnerChange: (
+    projectId: string,
+    rule: CombinedMetricIssueAlerts,
+    ownerValue: string
+  ) => void;
+  orgId: string;
+  projects: Project[];
+  projectsLoaded: boolean;
+  rule: MetricAlert;
+};
+
+function ActivatedRuleListRow({
+  rule,
+  projectsLoaded,
+  projects,
+  orgId,
+  onDelete,
+  onOwnerChange,
+  hasEditAccess,
+}: Props) {
+  const {teams: userTeams} = useUserTeams();
+  const [assignee, setAssignee] = useState<string>('');
+  const isWaiting = useMemo(
+    () =>
+      !rule.activations?.length ||
+      (rule.activations?.length && rule.activations[0].isComplete),
+    [rule]
+  );
+
+  function renderLatestActivation(): React.ReactNode {
+    if (!rule.activations?.length) {
+      return t('Alert has not been activated yet');
+    }
+
+    return (
+      <div>
+        {t('Last activated ')}
+        <TimeSince date={rule.activations[0].dateCreated} />
+      </div>
+    );
+  }
+
+  function renderSnoozeStatus(): React.ReactNode {
+    return (
+      <IssueAlertStatusWrapper>
+        <IconMute size="sm" color="subText" />
+        {t('Muted')}
+      </IssueAlertStatusWrapper>
+    );
+  }
+
+  function renderAlertRuleStatus(): React.ReactNode {
+    if (rule.snooze) {
+      return renderSnoozeStatus();
+    }
+
+    const isUnhealthy =
+      rule.latestIncident?.status !== undefined &&
+      [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes(
+        rule.latestIncident.status
+      );
+
+    let iconColor: ColorOrAlias = 'successText';
+    let iconDirection: 'up' | 'down' =
+      rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up';
+    let thresholdTypeText =
+      rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above');
+    if (isUnhealthy) {
+      iconColor =
+        rule.latestIncident?.status === IncidentStatus.CRITICAL
+          ? 'errorText'
+          : 'warningText';
+      // if unhealthy, swap icon direction
+      iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down';
+      thresholdTypeText =
+        rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Above') : t('Below');
+    }
+
+    let threshold = rule.triggers.find(
+      ({label}) => label === AlertRuleTriggerType.CRITICAL
+    )?.alertThreshold;
+    if (isUnhealthy && rule.latestIncident?.status === IncidentStatus.WARNING) {
+      threshold = rule.triggers.find(
+        ({label}) => label === AlertRuleTriggerType.WARNING
+      )?.alertThreshold;
+    } else if (!isUnhealthy && rule.latestIncident && rule.resolveThreshold) {
+      threshold = rule.resolveThreshold;
+    }
+
+    return (
+      <FlexCenter>
+        <IconArrow color={iconColor} direction={iconDirection} />
+        <TriggerText>
+          {`${thresholdTypeText} ${threshold}`}
+          {getThresholdUnits(
+            rule.aggregate,
+            rule.comparisonDelta
+              ? AlertRuleComparisonType.CHANGE
+              : AlertRuleComparisonType.COUNT
+          )}
+        </TriggerText>
+      </FlexCenter>
+    );
+  }
+
+  const slug = rule.projects[0];
+  const editLink = `/organizations/${orgId}/alerts/metric-rules/${slug}/${rule.id}/`;
+
+  const duplicateLink = {
+    pathname: `/organizations/${orgId}/alerts/new/${
+      rule.type === CombinedAlertType.METRIC ? 'metric' : 'issue'
+    }/`,
+    query: {
+      project: slug,
+      duplicateRuleId: rule.id,
+      createFromDuplicate: true,
+      referrer: 'alert_stream',
+    },
+  };
+
+  const ownerId = rule.owner?.split(':')[1];
+  const teamActor = ownerId
+    ? {type: 'team' as Actor['type'], id: ownerId, name: ''}
+    : null;
+
+  const canEdit = ownerId ? userTeams.some(team => team.id === ownerId) : true;
+
+  const actions: MenuItemProps[] = [
+    {
+      key: 'edit',
+      label: t('Edit'),
+      to: editLink,
+    },
+    {
+      key: 'duplicate',
+      label: t('Duplicate'),
+      to: duplicateLink,
+    },
+    {
+      key: 'delete',
+      label: t('Delete'),
+      priority: 'danger',
+      onAction: () => {
+        openConfirmModal({
+          onConfirm: () => onDelete(slug, rule),
+          header: <h5>{t('Delete Alert Rule?')}</h5>,
+          message: t(
+            'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.',
+            rule.name
+          ),
+          confirmText: t('Delete Rule'),
+          priority: 'danger',
+        });
+      },
+    },
+  ];
+
+  function handleOwnerChange({value}: {value: string}) {
+    const ownerValue = value && `team:${value}`;
+    setAssignee(ownerValue);
+    onOwnerChange(slug, rule, ownerValue);
+  }
+
+  const unassignedOption: ItemsBeforeFilter[number] = {
+    value: '',
+    label: (
+      <MenuItemWrapper>
+        <PaddedIconUser size="lg" />
+        <Label>{t('Unassigned')}</Label>
+      </MenuItemWrapper>
+    ),
+    searchKey: 'unassigned',
+    actor: '',
+    disabled: false,
+  };
+
+  const project = projects.find(p => p.slug === slug);
+  const filteredProjectTeams = (project?.teams ?? []).filter(projTeam => {
+    return userTeams.some(team => team.id === projTeam.id);
+  });
+  const dropdownTeams = filteredProjectTeams
+    .map<ItemsBeforeFilter[number]>((team, idx) => ({
+      value: team.id,
+      searchKey: team.slug,
+      label: (
+        <MenuItemWrapper data-test-id="assignee-option" key={idx}>
+          <IconContainer>
+            <TeamAvatar team={team} size={24} />
+          </IconContainer>
+          <Label>#{team.slug}</Label>
+        </MenuItemWrapper>
+      ),
+    }))
+    .concat(unassignedOption);
+
+  const teamId = assignee?.split(':')[1];
+  const teamName = filteredProjectTeams.find(team => team.id === teamId);
+
+  const assigneeTeamActor = assignee && {
+    type: 'team' as Actor['type'],
+    id: teamId,
+    name: '',
+  };
+
+  const avatarElement = assigneeTeamActor ? (
+    <ActorAvatar
+      actor={assigneeTeamActor}
+      className="avatar"
+      size={24}
+      tooltipOptions={{overlayStyle: {textAlign: 'left'}}}
+      tooltip={tct('Assigned to [name]', {name: teamName && `#${teamName.name}`})}
+    />
+  ) : (
+    <Tooltip isHoverable skipWrapper title={t('Unassigned')}>
+      <PaddedIconUser size="lg" color="gray400" />
+    </Tooltip>
+  );
+
+  return (
+    <ErrorBoundary>
+      <AlertNameWrapper>
+        <AlertNameAndStatus>
+          <AlertName>{rule.name}</AlertName>
+          <AlertActivationDate>{renderLatestActivation()}</AlertActivationDate>
+        </AlertNameAndStatus>
+      </AlertNameWrapper>
+      <FlexCenter>
+        <FlexCenter>
+          <Tooltip
+            title={tct('Metric Alert Status: [status]', {
+              status: isWaiting ? 'Ready to monitor' : 'Monitoring',
+            })}
+          >
+            <AlertBadge
+              status={rule?.latestIncident?.status}
+              activationStatus={
+                isWaiting ? ActivationStatus.WAITING : ActivationStatus.MONITORING
+              }
+            />
+          </Tooltip>
+        </FlexCenter>
+        <MarginLeft>{renderAlertRuleStatus()}</MarginLeft>
+      </FlexCenter>
+      <FlexCenter>
+        <ProjectBadgeContainer>
+          <ProjectBadge
+            avatarSize={18}
+            project={projectsLoaded && project ? project : {slug}}
+          />
+        </ProjectBadgeContainer>
+      </FlexCenter>
+
+      <FlexCenter>
+        {teamActor ? (
+          <ActorAvatar actor={teamActor} size={24} />
+        ) : (
+          <AssigneeWrapper>
+            {!projectsLoaded && <StyledLoadingIndicator mini />}
+            {projectsLoaded && (
+              <DropdownAutoComplete
+                data-test-id="alert-row-assignee"
+                maxHeight={400}
+                onOpen={e => {
+                  e?.stopPropagation();
+                }}
+                items={dropdownTeams}
+                alignMenu="right"
+                onSelect={handleOwnerChange}
+                itemSize="small"
+                searchPlaceholder={t('Filter teams')}
+                disableLabelPadding
+                emptyHidesInput
+                disabled={!hasEditAccess}
+              >
+                {({getActorProps, isOpen}) => (
+                  <DropdownButton {...getActorProps({})}>
+                    {avatarElement}
+                    {hasEditAccess && (
+                      <StyledChevron direction={isOpen ? 'up' : 'down'} size="xs" />
+                    )}
+                  </DropdownButton>
+                )}
+              </DropdownAutoComplete>
+            )}
+          </AssigneeWrapper>
+        )}
+      </FlexCenter>
+      <ActionsColumn>
+        <Access access={['alerts:write']}>
+          {({hasAccess}) => (
+            <DropdownMenu
+              items={actions}
+              position="bottom-end"
+              triggerProps={{
+                'aria-label': t('Actions'),
+                size: 'xs',
+                icon: <IconEllipsis />,
+                showChevron: false,
+              }}
+              disabledKeys={hasAccess && canEdit ? [] : ['delete']}
+            />
+          )}
+        </Access>
+      </ActionsColumn>
+    </ErrorBoundary>
+  );
+}
+
+// TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component
+const FlexCenter = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+
+const IssueAlertStatusWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+  line-height: 2;
+`;
+
+const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>`
+  ${p => p.theme.overflowEllipsis}
+  display: flex;
+  align-items: center;
+  gap: ${space(2)};
+  ${p => p.isIssueAlert && `padding: ${space(3)} ${space(2)}; line-height: 2.4;`}
+`;
+
+const AlertNameAndStatus = styled('div')`
+  ${p => p.theme.overflowEllipsis}
+  line-height: 1.35;
+`;
+
+const AlertName = styled('div')`
+  ${p => p.theme.overflowEllipsis}
+  font-size: ${p => p.theme.fontSizeLarge};
+`;
+
+const AlertActivationDate = styled('div')`
+  color: ${p => p.theme.gray300};
+`;
+
+const ProjectBadgeContainer = styled('div')`
+  width: 100%;
+`;
+
+const ProjectBadge = styled(IdBadge)`
+  flex-shrink: 0;
+`;
+
+const TriggerText = styled('div')`
+  margin-left: ${space(1)};
+  white-space: nowrap;
+  font-variant-numeric: tabular-nums;
+`;
+
+const ActionsColumn = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: ${space(1)};
+`;
+
+const AssigneeWrapper = styled('div')`
+  display: flex;
+  justify-content: flex-end;
+
+  /* manually align menu underneath dropdown caret */
+  ${DropdownBubble} {
+    right: -14px;
+  }
+`;
+
+const DropdownButton = styled('div')`
+  display: flex;
+  align-items: center;
+  font-size: 20px;
+`;
+
+const StyledChevron = styled(IconChevron)`
+  margin-left: ${space(1)};
+`;
+
+const PaddedIconUser = styled(IconUser)`
+  padding: ${space(0.25)};
+`;
+
+const IconContainer = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: ${p => p.theme.iconSizes.lg};
+  height: ${p => p.theme.iconSizes.lg};
+  flex-shrink: 0;
+`;
+
+const MenuItemWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const Label = styled(TextOverflow)`
+  margin-left: ${space(0.75)};
+`;
+
+const MarginLeft = styled('div')`
+  margin-left: ${space(1)};
+`;
+
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  height: 24px;
+  margin: 0;
+  margin-right: ${space(1.5)};
+`;
+
+export default ActivatedRuleListRow;

+ 52 - 7
static/app/views/alerts/list/rules/alertRulesList.spec.tsx

@@ -333,8 +333,55 @@ describe('AlertRulesList', () => {
     expect(rules[0]).toBeInTheDocument();
 
     expect(screen.getByText('Triggered')).toBeInTheDocument();
-    expect(screen.getByText('Above 70')).toBeInTheDocument();
-    expect(screen.getByText('Below 36')).toBeInTheDocument();
+    expect(screen.getByText('Above 70')).toBeInTheDocument(); // the fixture trigger threshold
+    expect(screen.getByText('Below 36')).toBeInTheDocument(); // the fixture resolved threshold
+    expect(screen.getAllByTestId('alert-badge')[0]).toBeInTheDocument();
+  });
+
+  it('displays activated metric alert status', async () => {
+    rulesMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/combined-rules/',
+      headers: {Link: pageLinks},
+      body: [
+        MetricRuleFixture({
+          id: '1',
+          projects: ['earth'],
+          name: 'Active Activated Alert',
+          monitorType: 1,
+          activationCondition: 0,
+          activations: [
+            {
+              alertRuleId: '1',
+              dateCreated: '2021-08-01T00:00:00Z',
+              finishedAt: '',
+              id: '1',
+              isComplete: false,
+              querySubscriptionId: '1',
+            },
+          ],
+          latestIncident: IncidentFixture({
+            status: IncidentStatus.CRITICAL,
+          }),
+        }),
+        MetricRuleFixture({
+          id: '2',
+          projects: ['earth'],
+          name: 'Ready Activated Alert',
+          monitorType: 1,
+          activationCondition: 0,
+        }),
+      ],
+    });
+    const {routerContext, organization} = initializeOrg({organization: defaultOrg});
+    render(<AlertRulesList />, {context: routerContext, organization});
+
+    expect(await screen.findByText('Active Activated Alert')).toBeInTheDocument();
+    expect(await screen.findByText('Ready Activated Alert')).toBeInTheDocument();
+
+    expect(screen.getByText('Last activated')).toBeInTheDocument();
+    expect(screen.getByText('Alert has not been activated yet')).toBeInTheDocument();
+    expect(screen.getByText('Above 70')).toBeInTheDocument(); // the fixture trigger threshold
+    expect(screen.getByText('Below 70')).toBeInTheDocument(); // Alert has never fired, so no resolved threshold
     expect(screen.getAllByTestId('alert-badge')[0]).toBeInTheDocument();
   });
 
@@ -423,7 +470,6 @@ describe('AlertRulesList', () => {
       expect.objectContaining({
         query: {
           expand: ['latestIncident', 'lastTriggered'],
-          monitor_type: '0',
           sort: ['incident_status', 'date_triggered'],
           team: ['myteams', 'unassigned'],
         },
@@ -450,7 +496,7 @@ describe('AlertRulesList', () => {
     );
   });
 
-  it('does not display ACTIVATED Metric Alerts', async () => {
+  it('renders ACTIVATED Metric Alerts', async () => {
     rulesMock = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/combined-rules/',
       headers: {Link: pageLinks},
@@ -464,7 +510,7 @@ describe('AlertRulesList', () => {
         MetricRuleFixture({
           id: '345',
           projects: ['earth'],
-          name: 'Omitted Test Metric Alert',
+          name: 'activated Test Metric Alert',
           monitorType: 1,
           latestIncident: IncidentFixture({
             status: IncidentStatus.CRITICAL,
@@ -485,7 +531,6 @@ describe('AlertRulesList', () => {
 
     expect(await screen.findByText('Test Metric Alert 2')).toBeInTheDocument();
     expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
-
-    expect(screen.queryByText('Omitted Test Metric Alert')).not.toBeInTheDocument();
+    expect(await screen.findByText('activated Test Metric Alert')).toBeInTheDocument();
   });
 });

+ 15 - 2
static/app/views/alerts/list/rules/alertRulesList.tsx

@@ -39,6 +39,7 @@ import {AlertRuleType} from '../../types';
 import {getTeamParams, isIssueAlert} from '../../utils';
 import AlertHeader from '../header';
 
+import ActivatedRuleRow from './activatedRuleRow';
 import RuleListRow from './row';
 
 type SortField = 'date_added' | 'name' | ['incident_status', 'date_triggered'];
@@ -48,7 +49,6 @@ function getAlertListQueryKey(orgSlug: string, query: Location['query']): ApiQue
   const queryParams = {...query};
   queryParams.expand = ['latestIncident', 'lastTriggered'];
   queryParams.team = getTeamParams(queryParams.team!);
-  queryParams.monitor_type = MonitorType.CONTINUOUS.toString();
 
   if (!queryParams.sort) {
     queryParams.sort = defaultSort;
@@ -76,6 +76,7 @@ function AlertRulesList() {
       : location.query.sort,
   });
 
+  // Fetch alert rules
   const {
     data: ruleListResponse = [],
     refetch,
@@ -254,7 +255,19 @@ function AlertRulesList() {
                         !isIssueAlertInstance &&
                         rule.monitorType === MonitorType.ACTIVATED
                       ) {
-                        return null;
+                        return (
+                          <ActivatedRuleRow
+                            // Metric and issue alerts can have the same id
+                            key={`${keyPrefix}-${rule.id}`}
+                            projectsLoaded={initiallyLoaded}
+                            projects={projects as Project[]}
+                            rule={rule}
+                            orgId={organization.slug}
+                            onOwnerChange={handleOwnerChange}
+                            onDelete={handleDeleteRule}
+                            hasEditAccess={hasEditAccess}
+                          />
+                        );
                       }
 
                       return (

+ 35 - 26
static/app/views/alerts/list/rules/row.tsx

@@ -143,6 +143,7 @@ function RuleListRow({
       ({label}) => label === AlertRuleTriggerType.WARNING
     );
     const resolvedTrigger = rule.resolveThreshold;
+
     const trigger =
       activeIncident && rule.latestIncident?.status === IncidentStatus.CRITICAL
         ? criticalTrigger
@@ -315,6 +316,22 @@ function RuleListRow({
   return (
     <ErrorBoundary>
       <AlertNameWrapper isIssueAlert={isIssueAlert(rule)}>
+        <AlertNameAndStatus>
+          <AlertName>
+            <Link
+              to={
+                isIssueAlert(rule)
+                  ? `/organizations/${orgId}/alerts/rules/${rule.projects[0]}/${rule.id}/details/`
+                  : `/organizations/${orgId}/alerts/rules/details/${rule.id}/`
+              }
+            >
+              {rule.name}
+            </Link>
+          </AlertName>
+          <AlertIncidentDate>{renderLastIncidentDate()}</AlertIncidentDate>
+        </AlertNameAndStatus>
+      </AlertNameWrapper>
+      <FlexCenter>
         <FlexCenter>
           <Tooltip
             title={
@@ -334,22 +351,8 @@ function RuleListRow({
             />
           </Tooltip>
         </FlexCenter>
-        <AlertNameAndStatus>
-          <AlertName>
-            <Link
-              to={
-                isIssueAlert(rule)
-                  ? `/organizations/${orgId}/alerts/rules/${rule.projects[0]}/${rule.id}/details/`
-                  : `/organizations/${orgId}/alerts/rules/details/${rule.id}/`
-              }
-            >
-              {rule.name}
-            </Link>
-          </AlertName>
-          <AlertIncidentDate>{renderLastIncidentDate()}</AlertIncidentDate>
-        </AlertNameAndStatus>
-      </AlertNameWrapper>
-      <FlexCenter>{renderAlertRuleStatus()}</FlexCenter>
+        <MarginLeft>{renderAlertRuleStatus()}</MarginLeft>
+      </FlexCenter>
       <FlexCenter>
         <ProjectBadgeContainer>
           <ProjectBadge
@@ -364,12 +367,7 @@ function RuleListRow({
           <ActorAvatar actor={teamActor} size={24} />
         ) : (
           <AssigneeWrapper>
-            {!projectsLoaded && (
-              <LoadingIndicator
-                mini
-                style={{height: '24px', margin: 0, marginRight: 11}}
-              />
-            )}
+            {!projectsLoaded && <StyledLoadingIndicator mini />}
             {projectsLoaded && (
               <DropdownAutoComplete
                 data-test-id="alert-row-assignee"
@@ -420,6 +418,7 @@ function RuleListRow({
   );
 }
 
+// TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component
 const FlexCenter = styled('div')`
   display: flex;
   align-items: center;
@@ -503,19 +502,29 @@ const IconContainer = styled('div')`
   display: flex;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
+  width: ${p => p.theme.iconSizes.lg};
+  height: ${p => p.theme.iconSizes.lg};
   flex-shrink: 0;
 `;
 
 const MenuItemWrapper = styled('div')`
   display: flex;
   align-items: center;
-  font-size: 13px;
+  font-size: ${p => p.theme.fontSizeSmall};
 `;
 
 const Label = styled(TextOverflow)`
-  margin-left: 6px;
+  margin-left: ${space(0.75)};
+`;
+
+const MarginLeft = styled('div')`
+  margin-left: ${space(1)};
+`;
+
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  height: 24px;
+  margin: 0;
+  margin-right: ${space(1.5)};
 `;
 
 export default RuleListRow;

+ 16 - 0
static/app/views/alerts/rules/metric/types.tsx

@@ -85,6 +85,21 @@ export type SavedTrigger = Omit<UnsavedTrigger, 'actions'> & {
 
 export type Trigger = Partial<SavedTrigger> & UnsavedTrigger;
 
+export type AlertRuleActivation = {
+  alertRuleId: string;
+  dateCreated: string;
+  finishedAt: string;
+  id: string;
+  isComplete: boolean;
+  querySubscriptionId: string;
+  metricValue?: number;
+};
+
+export enum ActivationCondition {
+  RELEASE_CONDITION = 0,
+  DEPLOY_CONDITION = 1,
+}
+
 // Form values for creating a new metric alert rule
 export type UnsavedMetricRule = {
   aggregate: string;
@@ -108,6 +123,7 @@ export type UnsavedMetricRule = {
 
 // Form values for updating a metric alert rule
 export interface SavedMetricRule extends UnsavedMetricRule {
+  activations: AlertRuleActivation[];
   dateCreated: string;
   dateModified: string;
   id: string;

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

@@ -70,6 +70,11 @@ export enum IncidentStatus {
   CRITICAL = 20,
 }
 
+export enum ActivationStatus {
+  WAITING = 0,
+  MONITORING = 1,
+}
+
 export enum IncidentStatusMethod {
   MANUAL = 1,
   RULE_UPDATED = 2,
@@ -92,9 +97,8 @@ interface IssueAlert extends IssueAlertRule {
   latestIncident?: Incident | null;
 }
 
-interface MetricAlert extends MetricRule {
+export interface MetricAlert extends MetricRule {
   type: CombinedAlertType.METRIC;
-  latestIncident?: Incident | null;
 }
 
 export type CombinedMetricIssueAlerts = IssueAlert | MetricAlert;