Browse Source

feat(active-release): Add release activity timeline page (#36625)

Adds an activity tab to an active release that displays some activity that happened during a release.
Scott Cooper 2 years ago
parent
commit
b5d71ff03c

+ 4 - 0
static/app/routes.tsx

@@ -1049,6 +1049,10 @@ function buildRoutes() {
         <IndexRoute
           component={make(() => import('sentry/views/releases/detail/overview'))}
         />
+        <Route
+          path="activity/"
+          component={make(() => import('sentry/views/releases/detail/activity'))}
+        />
         <Route
           path="commits/"
           component={make(

+ 29 - 0
static/app/views/releases/detail/activity/index.tsx

@@ -0,0 +1,29 @@
+import Feature from 'sentry/components/acl/feature';
+import FeatureDisabled from 'sentry/components/acl/featureDisabled';
+import {PanelAlert} from 'sentry/components/panels';
+import {t} from 'sentry/locale';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {ReleaseActivityList} from './releaseActivity';
+
+function ReleaseDetailsActivity() {
+  const organization = useOrganization();
+
+  return (
+    <Feature
+      features={['organizations:active-release-monitor-alpha']}
+      organization={organization}
+      renderDisabled={() => (
+        <FeatureDisabled
+          alert={PanelAlert}
+          features={['organizations:active-release-monitor-alpha']}
+          featureName={t('Active Release Details')}
+        />
+      )}
+    >
+      <ReleaseActivityList />
+    </Feature>
+  );
+}
+
+export default ReleaseDetailsActivity;

+ 67 - 0
static/app/views/releases/detail/activity/releaseActivity.tsx

@@ -0,0 +1,67 @@
+import {useContext, useEffect} from 'react';
+import styled from '@emotion/styled';
+
+import * as Layout from 'sentry/components/layouts/thirds';
+import GroupStore from 'sentry/stores/groupStore';
+import space from 'sentry/styles/space';
+import useApiRequests from 'sentry/utils/useApiRequests';
+import {useParams} from 'sentry/utils/useParams';
+
+import {ReleaseContext} from '../index';
+
+import {ReleaseActivityItem, ReleaseActivityWaiting} from './releaseActivityItems';
+import {ReleaseActivity, ReleaseActivityIssue, ReleaseActivityType} from './types';
+
+export function ReleaseActivityList() {
+  const params = useParams();
+  const {project} = useContext(ReleaseContext);
+
+  const {data, renderComponent} = useApiRequests({
+    endpoints: [
+      [
+        'activities',
+        `/projects/${params.orgId}/${project.slug}/releases/${params.release}/activity/`,
+      ],
+    ],
+  });
+
+  useEffect(() => {
+    const groups = (data.activities as ReleaseActivity[] | null)
+      ?.filter(
+        (activity): activity is ReleaseActivityIssue =>
+          activity.type === ReleaseActivityType.ISSUE
+      )
+      .map(activity => activity.data.group);
+
+    // Add groups to the store for displaying via EventOrGroupHeader
+    GroupStore.add(groups ?? []);
+
+    return () => {
+      GroupStore.reset();
+    };
+  }, [data.activities]);
+
+  const activities: ReleaseActivity[] = data.activities ?? [];
+  const isFinished = activities.some(
+    activity => activity.type === ReleaseActivityType.FINISHED
+  );
+
+  return renderComponent(
+    <Layout.Body>
+      <Layout.Main fullWidth>
+        <ActivityList>
+          {activities.map((activity, idx) => (
+            <ReleaseActivityItem key={idx} activity={activity} />
+          ))}
+          {isFinished ? null : <ReleaseActivityWaiting />}
+        </ActivityList>
+      </Layout.Main>
+    </Layout.Body>
+  );
+}
+
+const ActivityList = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(4)};
+`;

+ 133 - 0
static/app/views/releases/detail/activity/releaseActivityItems.tsx

@@ -0,0 +1,133 @@
+import styled from '@emotion/styled';
+
+import EventOrGroupExtraDetails from 'sentry/components/eventOrGroupExtraDetails';
+import EventOrGroupHeader from 'sentry/components/eventOrGroupHeader';
+import TimeSince from 'sentry/components/timeSince';
+import {IconExclamation, IconSentry} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {ReleaseActivityRow} from './releaseActivityRow';
+import {
+  ReleaseActivity,
+  ReleaseActivityDeployed,
+  ReleaseActivityIssue,
+  ReleaseActivityType,
+} from './types';
+
+function ReleaseActivityStartStop(props: ReleaseActivityItemProps) {
+  const isFinished = props.activity.type === ReleaseActivityType.FINISHED;
+  return (
+    <ReleaseActivityRow
+      icon={<StyledIconSentry color="white" size="lg" />}
+      iconColor="gray500"
+      hideConnector={isFinished}
+    >
+      <div>
+        {isFinished
+          ? t('Release has been deployed for an hour and is no longer active')
+          : t('Release Created')}
+      </div>
+      <DateContainer>
+        <TimeSince date={props.activity.dateAdded} />
+      </DateContainer>
+    </ReleaseActivityRow>
+  );
+}
+
+export function ReleaseActivityWaiting() {
+  return (
+    <ReleaseActivityRow
+      icon={<StyledIconSentry color="white" size="lg" />}
+      iconColor="gray500"
+      hideConnector
+    >
+      <WaitingContainer>{t('Waiting for issues in this release...')}</WaitingContainer>
+    </ReleaseActivityRow>
+  );
+}
+
+interface ReleaseActivityDeployProps {
+  activity: ReleaseActivityDeployed;
+}
+
+function ReleaseActivityDeploy(props: ReleaseActivityDeployProps) {
+  return (
+    <ReleaseActivityRow
+      icon={<StyledIconSentry color="white" size="lg" />}
+      iconColor="gray500"
+    >
+      <div>{t('Deployed to %s', props.activity.data.environment)}</div>
+      <DateContainer>
+        <TimeSince date={props.activity.dateAdded} />
+      </DateContainer>
+    </ReleaseActivityRow>
+  );
+}
+
+interface ReleaseIssueActivityProps {
+  activity: ReleaseActivityIssue;
+}
+
+function ReleaseIssueActivity(props: ReleaseIssueActivityProps) {
+  const org = useOrganization();
+  const group = props.activity.data.group;
+
+  return (
+    <ReleaseActivityRow
+      icon={<IconExclamation color="white" size="lg" />}
+      iconColor="yellow300"
+    >
+      <GroupSummary>
+        <EventOrGroupHeader
+          organization={org}
+          data={group}
+          query=""
+          size="normal"
+          includeLink
+          hideLevel
+        />
+        <EventOrGroupExtraDetails data={group} showInboxTime={false} />
+      </GroupSummary>
+    </ReleaseActivityRow>
+  );
+}
+
+interface ReleaseActivityItemProps {
+  activity: ReleaseActivity;
+}
+
+export function ReleaseActivityItem(props: ReleaseActivityItemProps) {
+  switch (props.activity.type) {
+    case ReleaseActivityType.CREATED:
+    case ReleaseActivityType.FINISHED:
+      return <ReleaseActivityStartStop activity={props.activity} />;
+    case ReleaseActivityType.DEPLOYED:
+      return <ReleaseActivityDeploy activity={props.activity} />;
+    case ReleaseActivityType.ISSUE:
+      return <ReleaseIssueActivity activity={props.activity} />;
+    default:
+      return null;
+  }
+}
+
+const DateContainer = styled('div')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+// Fix sentry icon looking off center
+const StyledIconSentry = styled(IconSentry)`
+  margin-top: -${space(0.5)};
+`;
+
+const GroupSummary = styled('div')`
+  overflow: hidden;
+  flex: 1;
+  margin-top: ${space(0.75)};
+`;
+
+const WaitingContainer = styled('div')`
+  padding: ${space(1.5)} 0;
+`;

+ 59 - 0
static/app/views/releases/detail/activity/releaseActivityRow.tsx

@@ -0,0 +1,59 @@
+import styled from '@emotion/styled';
+
+import space from 'sentry/styles/space';
+import type {Color} from 'sentry/utils/theme';
+
+interface ReleaseActivityRowProps {
+  children: React.ReactNode;
+  icon: React.ReactNode;
+  iconColor: Color;
+  hideConnector?: boolean;
+}
+
+export function ReleaseActivityRow(props: ReleaseActivityRowProps) {
+  return (
+    <Step>
+      {props.hideConnector ? null : <StepConnector />}
+      <StepContainer>
+        <IconContainer color={props.iconColor}>{props.icon}</IconContainer>
+        <StepContent>{props.children}</StepContent>
+      </StepContainer>
+    </Step>
+  );
+}
+
+const Step = styled('div')`
+  position: relative;
+  display: flex;
+  align-items: center;
+`;
+
+const StepContainer = styled('div')`
+  position: relative;
+  display: flex;
+  align-items: flex-start;
+  flex-grow: 1;
+`;
+
+const StepContent = styled('div')`
+  flex-grow: 1;
+  margin-left: ${space(3)};
+`;
+
+const StepConnector = styled('div')`
+  position: absolute;
+  height: 100%;
+  top: 28px;
+  left: 23px;
+  border-right: 1px ${p => p.theme.gray300} dashed;
+`;
+
+const IconContainer = styled('div')<{color: Color}>`
+  display: flex;
+  align-items: center;
+  padding: ${space(0.5)} ${space(1.5)};
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+  background-color: ${p => p.theme[p.color]};
+`;

+ 39 - 0
static/app/views/releases/detail/activity/types.tsx

@@ -0,0 +1,39 @@
+import type {Group} from 'sentry/types';
+
+export enum ReleaseActivityType {
+  CREATED = 'CREATED',
+  DEPLOYED = 'DEPLOYED',
+  FINISHED = 'FINISHED',
+  ISSUE = 'ISSUE',
+}
+
+interface ReleaseActivityBase {
+  data: {};
+  dateAdded: string;
+  type: ReleaseActivityType;
+}
+
+export interface ReleaseActivityCreated extends ReleaseActivityBase {
+  type: ReleaseActivityType.CREATED;
+}
+export interface ReleaseActivityFinished extends ReleaseActivityBase {
+  type: ReleaseActivityType.FINISHED;
+}
+export interface ReleaseActivityIssue extends ReleaseActivityBase {
+  data: {
+    group: Group;
+  };
+  type: ReleaseActivityType.ISSUE;
+}
+export interface ReleaseActivityDeployed extends ReleaseActivityBase {
+  data: {
+    environment: string;
+  };
+  type: ReleaseActivityType.DEPLOYED;
+}
+
+export type ReleaseActivity =
+  | ReleaseActivityCreated
+  | ReleaseActivityFinished
+  | ReleaseActivityIssue
+  | ReleaseActivityDeployed;

+ 14 - 0
static/app/views/releases/detail/header/releaseHeader.tsx

@@ -6,6 +6,7 @@ import pick from 'lodash/pick';
 import Badge from 'sentry/components/badge';
 import Breadcrumbs from 'sentry/components/breadcrumbs';
 import Clipboard from 'sentry/components/clipboard';
+import FeatureBadge from 'sentry/components/featureBadge';
 import IdBadge from 'sentry/components/idBadge';
 import * as Layout from 'sentry/components/layouts/thirds';
 import ExternalLink from 'sentry/components/links/externalLink';
@@ -45,9 +46,22 @@ const ReleaseHeader = ({
   const releasePath = `/organizations/${organization.slug}/releases/${encodeURIComponent(
     version
   )}/`;
+  const hasActiveRelease = organization.features.includes('active-release-monitor-alpha');
 
   const tabs = [
     {title: t('Overview'), to: ''},
+    ...(hasActiveRelease
+      ? [
+          {
+            title: (
+              <Fragment>
+                {t('Activity')} <FeatureBadge type="alpha" noTooltip />
+              </Fragment>
+            ),
+            to: 'activity/',
+          },
+        ]
+      : []),
     {
       title: (
         <Fragment>

+ 75 - 0
tests/js/spec/views/releases/detail/activity/releaseActivity.spec.jsx

@@ -0,0 +1,75 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {act, render, screen} from 'sentry-test/reactTestingLibrary';
+
+import GroupStore from 'sentry/stores/groupStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {ReleaseContext} from 'sentry/views/releases/detail';
+import ReleaseDetailsActivity from 'sentry/views/releases/detail/activity';
+import {ReleaseActivityType} from 'sentry/views/releases/detail/activity/types';
+import {RouteContext} from 'sentry/views/routeContext';
+
+describe('ReleaseActivity', () => {
+  const {organization, project, router, routerContext} = initializeOrg({
+    organization: {
+      features: ['active-release-monitor-alpha'],
+    },
+  });
+  const release = TestStubs.Release({version: '1.0.0'});
+  const group = TestStubs.Group();
+  const activities = [
+    {
+      type: ReleaseActivityType.CREATED,
+      dateAdded: new Date().toISOString(),
+      data: {},
+    },
+    {
+      type: ReleaseActivityType.DEPLOYED,
+      dateAdded: new Date().toISOString(),
+      data: {environment: 'production'},
+    },
+    {
+      type: ReleaseActivityType.ISSUE,
+      dateAdded: new Date().toISOString(),
+      data: {group},
+    },
+  ];
+
+  beforeEach(() => {
+    GroupStore.init();
+    act(() => ProjectsStore.loadInitialData(organization.projects));
+  });
+
+  afterEach(() => {
+    MockApiClient.clearMockResponses();
+    GroupStore.teardown();
+    ProjectsStore.teardown();
+  });
+
+  it('renders active release activity', async () => {
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/releases/${release.version}/activity/`,
+      body: activities,
+    });
+
+    render(
+      <ReleaseContext.Provider value={{release, project}}>
+        <RouteContext.Provider
+          value={{
+            location: router.location,
+            params: {orgId: organization.slug, release: release.version},
+            router,
+            routes: [],
+          }}
+        >
+          <ReleaseDetailsActivity />
+        </RouteContext.Provider>
+      </ReleaseContext.Provider>,
+      {organization, context: routerContext}
+    );
+
+    expect(await screen.findByText('Release Created')).toBeInTheDocument();
+    expect(screen.getByText('Deployed to production')).toBeInTheDocument();
+    expect(screen.getByText(group.culprit)).toBeInTheDocument();
+    expect(screen.getByText('Waiting for issues in this release...')).toBeInTheDocument();
+  });
+});