Browse Source

feat(ui): Add release comparison table (#26921)

Matej Minar 3 years ago
parent
commit
43b6ca562e

+ 0 - 1
static/app/components/organizations/timeRangeSelector/pageTimeRangeSelector.tsx

@@ -42,7 +42,6 @@ const DropdownDate = styled(Panel)<{isCalendarOpen: boolean}>`
   margin: 0;
   font-size: ${p => p.theme.fontSizeMedium};
   color: ${p => p.theme.textColor};
-  z-index: ${p => p.theme.zIndex.globalSelectionHeader};
 
   /* TimeRangeRoot in TimeRangeSelector */
   > div {

+ 5 - 0
static/app/types/index.tsx

@@ -2082,6 +2082,11 @@ export type SessionApiResponse = SeriesApi & {
   }[];
 };
 
+export enum SessionField {
+  SESSIONS = 'sum(session)',
+  USERS = 'count_unique(user)',
+}
+
 export enum HealthStatsPeriodOption {
   AUTO = 'auto',
   TWENTY_FOUR_HOURS = '24h',

+ 30 - 0
static/app/utils/sessions.tsx

@@ -0,0 +1,30 @@
+import {SessionApiResponse, SessionField} from 'app/types';
+import {defined, percent} from 'app/utils';
+import {getCrashFreePercent} from 'app/views/releases/utils';
+
+export function getCount(groups: SessionApiResponse['groups'] = [], field: SessionField) {
+  return groups.reduce((acc, group) => acc + group.totals[field], 0);
+}
+
+export function getCrashCount(
+  groups: SessionApiResponse['groups'] = [],
+  field: SessionField
+) {
+  return getCount(
+    groups.filter(({by}) => by['session.status'] === 'crashed'),
+    field
+  );
+}
+
+export function getCrashFreeRate(
+  groups: SessionApiResponse['groups'] = [],
+  field: SessionField
+) {
+  const totalCount = groups.reduce((acc, group) => acc + group.totals[field], 0);
+
+  const crashedCount = getCrashCount(groups, field);
+
+  return !defined(totalCount) || totalCount === 0
+    ? null
+    : getCrashFreePercent(100 - percent(crashedCount ?? 0, totalCount ?? 0));
+}

+ 164 - 145
static/app/views/releases/detail/overview/index.tsx

@@ -34,12 +34,7 @@ import {DisplayModes} from 'app/views/performance/transactionSummary/charts';
 import {transactionSummaryRouteWithQuery} from 'app/views/performance/transactionSummary/utils';
 import {TrendChangeType, TrendView} from 'app/views/performance/trends/types';
 
-import {
-  getReleaseBounds,
-  getReleaseParams,
-  isReleaseArchived,
-  ReleaseBounds,
-} from '../../utils';
+import {getReleaseParams, isReleaseArchived, ReleaseBounds} from '../../utils';
 import {ReleaseContext} from '..';
 
 import ReleaseChart from './chart/';
@@ -50,6 +45,8 @@ import Issues from './issues';
 import OtherProjects from './otherProjects';
 import ProjectReleaseDetails from './projectReleaseDetails';
 import ReleaseArchivedNotice from './releaseArchivedNotice';
+import ReleaseComparisonChart from './releaseComparisonChart';
+import ReleaseDetailsRequest from './releaseDetailsRequest';
 import ReleaseStats from './releaseStats';
 import TotalCrashFreeUsers from './totalCrashFreeUsers';
 
@@ -382,7 +379,6 @@ class ReleaseOverview extends AsyncView<Props> {
           releaseBounds,
         }) => {
           const {commitCount, version} = release;
-          const {releaseStart, releaseEnd} = getReleaseBounds(release);
           const hasDiscover = organization.features.includes('discover-basic');
           const hasPerformance = organization.features.includes('performance-view');
           const yAxis = this.getYAxis(hasHealthData, hasPerformance);
@@ -419,144 +415,167 @@ class ReleaseOverview extends AsyncView<Props> {
           };
 
           return (
-            <Body>
-              <Main>
-                {isReleaseArchived(release) && (
-                  <ReleaseArchivedNotice
-                    onRestore={() => this.handleRestore(project, refetchData)}
-                  />
-                )}
-                <Feature features={['release-comparison']}>
-                  {({hasFeature}) =>
-                    hasFeature ? (
-                      <Fragment>
-                        <StyledPageTimeRangeSelector
-                          organization={organization}
-                          relative={period ?? ''}
-                          start={start ?? null}
-                          end={end ?? null}
-                          utc={utc ?? null}
-                          onUpdate={this.handleDateChange}
-                          relativeOptions={{
-                            [RELEASE_PERIOD_KEY]: (
-                              <Fragment>
-                                {t('Entire Release Period')} (
-                                <DateTime date={releaseStart} timeAndDate /> -{' '}
-                                <DateTime date={releaseEnd} timeAndDate />)
-                              </Fragment>
-                            ),
-                            ...DEFAULT_RELATIVE_PERIODS,
-                          }}
-                          defaultPeriod={RELEASE_PERIOD_KEY}
-                        />
-                        {/* TODO(release-comparison): new chart */}
-                      </Fragment>
-                    ) : (
-                      (hasDiscover || hasPerformance || hasHealthData) && (
-                        <ReleaseChart
-                          releaseMeta={releaseMeta}
-                          selection={selection}
-                          yAxis={yAxis}
-                          onYAxisChange={display =>
-                            this.handleYAxisChange(display, project)
-                          }
-                          eventType={eventType}
-                          onEventTypeChange={type =>
-                            this.handleEventTypeChange(type, project)
-                          }
-                          vitalType={vitalType}
-                          onVitalTypeChange={type =>
-                            this.handleVitalTypeChange(type, project)
-                          }
-                          router={router}
-                          organization={organization}
-                          hasHealthData={hasHealthData}
-                          location={location}
-                          api={api}
-                          version={version}
-                          hasDiscover={hasDiscover}
-                          hasPerformance={hasPerformance}
-                          platform={project.platform}
-                          defaultStatsPeriod={defaultStatsPeriod}
-                          projectSlug={project.slug}
-                        />
-                      )
-                    )
-                  }
-                </Feature>
-
-                <Issues
-                  organization={organization}
-                  selection={selection}
-                  version={version}
-                  location={location}
-                  defaultStatsPeriod={defaultStatsPeriod}
-                  releaseBounds={releaseBounds}
-                />
-                <Feature features={['performance-view']}>
-                  <TransactionsList
-                    location={location}
-                    organization={organization}
-                    eventView={releaseEventView}
-                    trendView={releaseTrendView}
-                    selected={selectedSort}
-                    options={sortOptions}
-                    handleDropdownChange={this.handleTransactionsListSortChange}
-                    titles={titles}
-                    generateLink={generateLink}
-                  />
-                </Feature>
-              </Main>
-              <Side>
-                <ReleaseStats
-                  organization={organization}
-                  release={release}
-                  project={project}
-                  location={location}
-                  selection={selection}
-                  hasHealthData={hasHealthData}
-                  getHealthData={getHealthData}
-                  isHealthLoading={isHealthLoading}
-                />
-                <ProjectReleaseDetails
-                  release={release}
-                  releaseMeta={releaseMeta}
-                  orgSlug={organization.slug}
-                  projectSlug={project.slug}
-                />
-                {commitCount > 0 && (
-                  <CommitAuthorBreakdown
-                    version={version}
-                    orgId={organization.slug}
-                    projectSlug={project.slug}
-                  />
-                )}
-                {releaseMeta.projects.length > 1 && (
-                  <OtherProjects
-                    projects={releaseMeta.projects.filter(p => p.slug !== project.slug)}
-                    location={location}
-                    version={version}
-                    organization={organization}
-                  />
-                )}
-                {hasHealthData && (
-                  <TotalCrashFreeUsers
-                    organization={organization}
-                    version={version}
-                    projectSlug={project.slug}
-                    location={location}
-                  />
-                )}
-                {deploys.length > 0 && (
-                  <Deploys
-                    version={version}
-                    orgSlug={organization.slug}
-                    deploys={deploys}
-                    projectId={project.id}
-                  />
-                )}
-              </Side>
-            </Body>
+            <ReleaseDetailsRequest
+              organization={organization}
+              location={location}
+              disable={!organization.features.includes('release-comparison')}
+              version={version}
+              releaseBounds={releaseBounds}
+            >
+              {({thisRelease, allReleases}) => (
+                <Body>
+                  <Main>
+                    {isReleaseArchived(release) && (
+                      <ReleaseArchivedNotice
+                        onRestore={() => this.handleRestore(project, refetchData)}
+                      />
+                    )}
+                    <Feature features={['release-comparison']}>
+                      {({hasFeature}) =>
+                        hasFeature ? (
+                          <Fragment>
+                            <StyledPageTimeRangeSelector
+                              organization={organization}
+                              relative={period ?? ''}
+                              start={start ?? null}
+                              end={end ?? null}
+                              utc={utc ?? null}
+                              onUpdate={this.handleDateChange}
+                              relativeOptions={{
+                                [RELEASE_PERIOD_KEY]: (
+                                  <Fragment>
+                                    {t('Entire Release Period')} (
+                                    <DateTime
+                                      date={releaseBounds.releaseStart}
+                                      timeAndDate
+                                    />{' '}
+                                    -{' '}
+                                    <DateTime
+                                      date={releaseBounds.releaseEnd}
+                                      timeAndDate
+                                    />
+                                    )
+                                  </Fragment>
+                                ),
+                                ...DEFAULT_RELATIVE_PERIODS,
+                              }}
+                              defaultPeriod={RELEASE_PERIOD_KEY}
+                            />
+                            <ReleaseComparisonChart
+                              releaseSessions={thisRelease}
+                              allSessions={allReleases}
+                            />
+                          </Fragment>
+                        ) : (
+                          (hasDiscover || hasPerformance || hasHealthData) && (
+                            <ReleaseChart
+                              releaseMeta={releaseMeta}
+                              selection={selection}
+                              yAxis={yAxis}
+                              onYAxisChange={display =>
+                                this.handleYAxisChange(display, project)
+                              }
+                              eventType={eventType}
+                              onEventTypeChange={type =>
+                                this.handleEventTypeChange(type, project)
+                              }
+                              vitalType={vitalType}
+                              onVitalTypeChange={type =>
+                                this.handleVitalTypeChange(type, project)
+                              }
+                              router={router}
+                              organization={organization}
+                              hasHealthData={hasHealthData}
+                              location={location}
+                              api={api}
+                              version={version}
+                              hasDiscover={hasDiscover}
+                              hasPerformance={hasPerformance}
+                              platform={project.platform}
+                              defaultStatsPeriod={defaultStatsPeriod}
+                              projectSlug={project.slug}
+                            />
+                          )
+                        )
+                      }
+                    </Feature>
+
+                    <Issues
+                      organization={organization}
+                      selection={selection}
+                      version={version}
+                      location={location}
+                      defaultStatsPeriod={defaultStatsPeriod}
+                      releaseBounds={releaseBounds}
+                    />
+                    <Feature features={['performance-view']}>
+                      <TransactionsList
+                        location={location}
+                        organization={organization}
+                        eventView={releaseEventView}
+                        trendView={releaseTrendView}
+                        selected={selectedSort}
+                        options={sortOptions}
+                        handleDropdownChange={this.handleTransactionsListSortChange}
+                        titles={titles}
+                        generateLink={generateLink}
+                      />
+                    </Feature>
+                  </Main>
+                  <Side>
+                    <ReleaseStats
+                      organization={organization}
+                      release={release}
+                      project={project}
+                      location={location}
+                      selection={selection}
+                      hasHealthData={hasHealthData}
+                      getHealthData={getHealthData}
+                      isHealthLoading={isHealthLoading}
+                    />
+                    <ProjectReleaseDetails
+                      release={release}
+                      releaseMeta={releaseMeta}
+                      orgSlug={organization.slug}
+                      projectSlug={project.slug}
+                    />
+                    {commitCount > 0 && (
+                      <CommitAuthorBreakdown
+                        version={version}
+                        orgId={organization.slug}
+                        projectSlug={project.slug}
+                      />
+                    )}
+                    {releaseMeta.projects.length > 1 && (
+                      <OtherProjects
+                        projects={releaseMeta.projects.filter(
+                          p => p.slug !== project.slug
+                        )}
+                        location={location}
+                        version={version}
+                        organization={organization}
+                      />
+                    )}
+                    {hasHealthData && (
+                      <TotalCrashFreeUsers
+                        organization={organization}
+                        version={version}
+                        projectSlug={project.slug}
+                        location={location}
+                      />
+                    )}
+                    {deploys.length > 0 && (
+                      <Deploys
+                        version={version}
+                        orgSlug={organization.slug}
+                        deploys={deploys}
+                        projectId={project.id}
+                      />
+                    )}
+                  </Side>
+                </Body>
+              )}
+            </ReleaseDetailsRequest>
           );
         }}
       </ReleaseContext.Consumer>

+ 240 - 0
static/app/views/releases/detail/overview/releaseComparisonChart/index.tsx

@@ -0,0 +1,240 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+import round from 'lodash/round';
+
+import Count from 'app/components/count';
+import NotAvailable from 'app/components/notAvailable';
+import {PanelTable} from 'app/components/panels';
+import Radio from 'app/components/radio';
+import {IconArrow} from 'app/icons';
+import {t} from 'app/locale';
+import overflowEllipsis from 'app/styles/overflowEllipsis';
+import space from 'app/styles/space';
+import {SessionApiResponse, SessionField} from 'app/types';
+import {defined} from 'app/utils';
+import {getCount, getCrashFreeRate} from 'app/utils/sessions';
+import {Color} from 'app/utils/theme';
+import {displayCrashFreePercent} from 'app/views/releases/utils';
+
+enum ReleaseComparisonChartType {
+  CRASH_FREE_USERS = 'crashFreeUsers',
+  CRASH_FREE_SESSIONS = 'crashFreeSessions',
+  SESSION_COUNT = 'sessionCount',
+  USER_COUNT = 'userCount',
+}
+
+const releaseComparisonChartLabels = {
+  [ReleaseComparisonChartType.CRASH_FREE_USERS]: t('Crash Free Users'),
+  [ReleaseComparisonChartType.CRASH_FREE_SESSIONS]: t('Crash Free Sessions'),
+  [ReleaseComparisonChartType.SESSION_COUNT]: t('Session Count'),
+  [ReleaseComparisonChartType.USER_COUNT]: t('User Count'),
+};
+
+type ComparisonRow = {
+  type: ReleaseComparisonChartType;
+  thisRelease: React.ReactNode;
+  allReleases: React.ReactNode;
+  diff: React.ReactNode;
+  diffDirection: 'up' | 'down' | null;
+  diffColor: Color | null;
+};
+
+type Props = {
+  releaseSessions: SessionApiResponse | null;
+  allSessions: SessionApiResponse | null;
+};
+
+function ReleaseComparisonChart({releaseSessions, allSessions}: Props) {
+  const [activeChart, setActiveChart] = useState(
+    ReleaseComparisonChartType.CRASH_FREE_SESSIONS
+  );
+
+  const releaseCrashFreeSessions = getCrashFreeRate(
+    releaseSessions?.groups,
+    SessionField.SESSIONS
+  );
+  const allCrashFreeSessions = getCrashFreeRate(
+    allSessions?.groups,
+    SessionField.SESSIONS
+  );
+  const diffCrashFreeSessions =
+    defined(releaseCrashFreeSessions) && defined(allCrashFreeSessions)
+      ? releaseCrashFreeSessions - allCrashFreeSessions
+      : null;
+
+  const releaseCrashFreeUsers = getCrashFreeRate(
+    releaseSessions?.groups,
+    SessionField.USERS
+  );
+  const allCrashFreeUsers = getCrashFreeRate(allSessions?.groups, SessionField.USERS);
+  const diffCrashFreeUsers =
+    defined(releaseCrashFreeUsers) && defined(allCrashFreeUsers)
+      ? releaseCrashFreeUsers - allCrashFreeUsers
+      : null;
+
+  const releaseSessionsCount = getCount(releaseSessions?.groups, SessionField.SESSIONS);
+  const allSessionsCount = getCount(allSessions?.groups, SessionField.SESSIONS);
+  const diffSessionsCount =
+    defined(releaseSessions) && defined(allSessions)
+      ? releaseSessionsCount - allSessionsCount
+      : null;
+
+  const releaseUsersCount = getCount(releaseSessions?.groups, SessionField.USERS);
+  const allUsersCount = getCount(allSessions?.groups, SessionField.USERS);
+  const diffUsersCount =
+    defined(releaseUsersCount) && defined(allUsersCount)
+      ? releaseUsersCount - allUsersCount
+      : null;
+
+  const charts: ComparisonRow[] = [
+    {
+      type: ReleaseComparisonChartType.CRASH_FREE_SESSIONS,
+      thisRelease: defined(releaseCrashFreeSessions)
+        ? displayCrashFreePercent(releaseCrashFreeSessions)
+        : null,
+      allReleases: defined(allCrashFreeSessions)
+        ? displayCrashFreePercent(allCrashFreeSessions)
+        : null,
+      diff: defined(diffCrashFreeSessions)
+        ? `${Math.abs(round(diffCrashFreeSessions, 3))}%`
+        : null,
+      diffDirection: diffCrashFreeSessions
+        ? diffCrashFreeSessions > 0
+          ? 'up'
+          : 'down'
+        : null,
+      diffColor: diffCrashFreeSessions
+        ? diffCrashFreeSessions > 0
+          ? 'green300'
+          : 'red300'
+        : null,
+    },
+    {
+      type: ReleaseComparisonChartType.CRASH_FREE_USERS,
+      thisRelease: defined(releaseCrashFreeUsers)
+        ? displayCrashFreePercent(releaseCrashFreeUsers)
+        : null,
+      allReleases: defined(allCrashFreeUsers)
+        ? displayCrashFreePercent(allCrashFreeUsers)
+        : null,
+      diff: defined(diffCrashFreeUsers)
+        ? `${Math.abs(round(diffCrashFreeUsers, 3))}%`
+        : null,
+      diffDirection: diffCrashFreeUsers ? (diffCrashFreeUsers > 0 ? 'up' : 'down') : null,
+      diffColor: diffCrashFreeUsers
+        ? diffCrashFreeUsers > 0
+          ? 'green300'
+          : 'red300'
+        : null,
+    },
+    {
+      type: ReleaseComparisonChartType.SESSION_COUNT,
+      thisRelease: defined(releaseSessionsCount) ? (
+        <Count value={releaseSessionsCount} />
+      ) : null,
+      allReleases: defined(allSessionsCount) ? <Count value={allSessionsCount} /> : null,
+      diff: defined(diffSessionsCount) ? (
+        <Count value={Math.abs(diffSessionsCount)} />
+      ) : null,
+      diffDirection: defined(diffSessionsCount)
+        ? diffSessionsCount > 0
+          ? 'up'
+          : 'down'
+        : null,
+      diffColor: null,
+    },
+    {
+      type: ReleaseComparisonChartType.USER_COUNT,
+      thisRelease: defined(releaseUsersCount) ? (
+        <Count value={releaseUsersCount} />
+      ) : null,
+      allReleases: defined(allUsersCount) ? <Count value={allUsersCount} /> : null,
+      diff: defined(diffUsersCount) ? <Count value={Math.abs(diffUsersCount)} /> : null,
+      diffDirection: defined(diffUsersCount)
+        ? diffUsersCount > 0
+          ? 'up'
+          : 'down'
+        : null,
+      diffColor: null,
+    },
+  ];
+
+  return (
+    <StyledPanelTable
+      headers={[
+        <Cell key="stability" align="left">
+          {t('Stability')}
+        </Cell>,
+        <Cell key="releases" align="right">
+          {t('All Releases')}
+        </Cell>,
+        <Cell key="release" align="right">
+          {t('This Release')}
+        </Cell>,
+        <Cell key="change" align="right">
+          {t('Change')}
+        </Cell>,
+      ]}
+    >
+      {charts.map(({type, thisRelease, allReleases, diff, diffDirection, diffColor}) => {
+        return (
+          <Fragment key={type}>
+            <Cell align="left">
+              <ChartToggle htmlFor={type}>
+                <Radio
+                  id={type}
+                  disabled={false}
+                  checked={type === activeChart}
+                  onChange={() => setActiveChart(type)}
+                />
+                {releaseComparisonChartLabels[type]}
+              </ChartToggle>
+            </Cell>
+            <Cell align="right">{allReleases}</Cell>
+            <Cell align="right">{thisRelease}</Cell>
+            <Cell align="right">
+              {defined(diff) ? (
+                <Change color={defined(diffColor) ? diffColor : undefined}>
+                  {defined(diffDirection) && (
+                    <IconArrow direction={diffDirection} size="xs" />
+                  )}{' '}
+                  {diff}
+                </Change>
+              ) : (
+                <NotAvailable />
+              )}
+            </Cell>
+          </Fragment>
+        );
+      })}
+    </StyledPanelTable>
+  );
+}
+
+const StyledPanelTable = styled(PanelTable)`
+  @media (max-width: ${p => p.theme.breakpoints[2]}) {
+    grid-template-columns: min-content 1fr 1fr 1fr;
+  }
+`;
+
+const Cell = styled('div')<{align: 'left' | 'right'}>`
+  text-align: ${p => p.align};
+  ${overflowEllipsis}
+`;
+
+const ChartToggle = styled('label')`
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  margin-bottom: 0;
+  input {
+    flex-shrink: 0;
+    margin-right: ${space(1)} !important;
+  }
+`;
+
+const Change = styled('div')<{color?: Color}>`
+  ${p => p.color && `color: ${p.theme[p.color]}`}
+`;
+
+export default ReleaseComparisonChart;

+ 167 - 0
static/app/views/releases/detail/overview/releaseDetailsRequest.tsx

@@ -0,0 +1,167 @@
+import * as React from 'react';
+import {Location} from 'history';
+import isEqual from 'lodash/isEqual';
+import omit from 'lodash/omit';
+
+import {addErrorMessage} from 'app/actionCreators/indicator';
+import {Client} from 'app/api';
+import {DEFAULT_STATS_PERIOD} from 'app/constants';
+import {t} from 'app/locale';
+import {Organization, SessionApiResponse} from 'app/types';
+import withApi from 'app/utils/withApi';
+
+import {getReleaseParams, ReleaseBounds} from '../../utils';
+
+function omitIgnoredProps(props: Props) {
+  // TODO(release-comparison): pick the right props
+  return omit(props, ['api', 'organization', 'children', 'location']);
+}
+
+export function reduceTimeSeriesGroups(
+  acc: number[],
+  group: SessionApiResponse['groups'][number],
+  field: 'count_unique(user)' | 'sum(session)'
+) {
+  group.series[field]?.forEach(
+    (value, index) => (acc[index] = (acc[index] ?? 0) + value)
+  );
+
+  return acc;
+}
+
+export type ReleaseHealthRequestRenderProps = {
+  loading: boolean;
+  errored: boolean;
+  thisRelease: SessionApiResponse | null;
+  allReleases: SessionApiResponse | null;
+};
+
+type Props = {
+  api: Client;
+  organization: Organization;
+  children: (renderProps: ReleaseHealthRequestRenderProps) => React.ReactNode;
+  location: Location;
+  version: string;
+  releaseBounds: ReleaseBounds;
+  disable?: boolean;
+};
+type State = {
+  loading: boolean;
+  errored: boolean;
+  thisRelease: SessionApiResponse | null;
+  allReleases: SessionApiResponse | null;
+};
+
+class ReleaseDetailsRequest extends React.Component<Props, State> {
+  state: State = {
+    loading: false,
+    errored: false,
+    thisRelease: null,
+    allReleases: null,
+  };
+
+  componentDidMount() {
+    this.fetchData();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
+      return;
+    }
+
+    this.fetchData();
+  }
+
+  get path() {
+    const {organization} = this.props;
+
+    return `/organizations/${organization.slug}/sessions/`;
+  }
+
+  get baseQueryParams() {
+    const {location, releaseBounds} = this.props;
+
+    return {
+      field: ['count_unique(user)', 'sum(session)'],
+      groupBy: ['session.status'],
+      interval: '1h', // TODO(release-comparison): calculatete interval dynamically
+      ...getReleaseParams({
+        location,
+        releaseBounds,
+        defaultStatsPeriod: DEFAULT_STATS_PERIOD, // this will be removed once we get rid off legacy release details
+        allowEmptyPeriod: true,
+      }),
+    };
+  }
+
+  fetchData = async () => {
+    const {api, disable} = this.props;
+
+    if (disable) {
+      return;
+    }
+
+    api.clear();
+    this.setState({
+      loading: true,
+      errored: false,
+    });
+
+    const promises = [this.fetchThisRelease(), this.fetchAllReleases()];
+
+    try {
+      const [thisRelease, allReleases] = await Promise.all(promises);
+
+      this.setState({
+        loading: false,
+        thisRelease,
+        allReleases,
+      });
+    } catch (error) {
+      addErrorMessage(error.responseJSON?.detail ?? t('Error loading health data'));
+      this.setState({
+        loading: false,
+        errored: true,
+        thisRelease: null,
+        allReleases: null,
+      });
+    }
+  };
+
+  async fetchThisRelease() {
+    const {api, version} = this.props;
+
+    const response: SessionApiResponse = await api.requestPromise(this.path, {
+      query: {
+        ...this.baseQueryParams,
+        query: `release:${version}`,
+      },
+    });
+
+    return response;
+  }
+
+  async fetchAllReleases() {
+    const {api} = this.props;
+
+    const response: SessionApiResponse = await api.requestPromise(this.path, {
+      query: this.baseQueryParams,
+    });
+
+    return response;
+  }
+
+  render() {
+    const {loading, errored, thisRelease, allReleases} = this.state;
+    const {children} = this.props;
+
+    return children({
+      loading,
+      errored,
+      thisRelease,
+      allReleases,
+    });
+  }
+}
+
+export default withApi(ReleaseDetailsRequest);