Browse Source

feat(ui): Split fetching of releases into two network requests (#18795)

Releases endpoint can be called with health: 1 parameter which will return releases with their health data.

This request can take significantly longer (especially for big orgs) compared to health: 0.

We decided to call health: 0 on the releases page and then fetch health: 1 lazily, showing placeholders in the meantime.

We will skip the two-phased load for sorting other than date created (default) since in those cases we already need to go to the session store anyways (which takes here the long time).
Matej Minar 4 years ago
parent
commit
be31b596c3

+ 14 - 5
src/sentry/static/sentry/app/types/index.tsx

@@ -880,7 +880,17 @@ export type UserReport = {
   email: string;
 };
 
-export type Release = {
+export type Release = BaseRelease &
+  ReleaseData & {
+    projects: ReleaseProject[];
+  };
+
+export type ReleaseWithHealth = BaseRelease &
+  ReleaseData & {
+    projects: Required<ReleaseProject>[];
+  };
+
+type ReleaseData = {
   commitCount: number;
   data: {};
   lastDeploy?: Deploy;
@@ -891,11 +901,10 @@ export type Release = {
   authors: User[];
   owner?: any; // TODO(ts)
   newGroups: number;
-  projects: ReleaseProject[];
   versionInfo: VersionInfo;
-} & BaseRelease;
+};
 
-export type BaseRelease = {
+type BaseRelease = {
   dateReleased: string;
   url: string;
   dateCreated: string;
@@ -911,7 +920,7 @@ export type ReleaseProject = {
   platform: string;
   platforms: string[];
   newGroups: number;
-  healthData: Health;
+  healthData?: Health;
 };
 
 export type ReleaseMeta = {

+ 4 - 4
src/sentry/static/sentry/app/views/releasesV2/detail/index.tsx

@@ -6,11 +6,11 @@ import styled from '@emotion/styled';
 import {t} from 'app/locale';
 import {
   Organization,
-  Release,
   ReleaseProject,
   ReleaseMeta,
   Deploy,
   GlobalSelection,
+  ReleaseWithHealth,
 } from 'app/types';
 import AsyncView from 'app/views/asyncView';
 import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
@@ -31,8 +31,8 @@ import ReleaseHeader from './releaseHeader';
 import PickProjectToContinue from './pickProjectToContinue';
 
 type ReleaseContext = {
-  release: Release;
-  project: ReleaseProject;
+  release: ReleaseWithHealth;
+  project: Required<ReleaseProject>;
   deploys: Deploy[];
   releaseMeta: ReleaseMeta;
 };
@@ -50,7 +50,7 @@ type Props = RouteComponentProps<RouteParams, {}> & {
 };
 
 type State = {
-  release: Release;
+  release: ReleaseWithHealth;
   deploys: Deploy[];
 } & AsyncView['state'];
 

+ 2 - 2
src/sentry/static/sentry/app/views/releasesV2/detail/overview/projectReleaseDetails.tsx

@@ -3,7 +3,7 @@ import styled from '@emotion/styled';
 
 import {t} from 'app/locale';
 import space from 'app/styles/space';
-import {Release} from 'app/types';
+import {ReleaseWithHealth} from 'app/types';
 import Version from 'app/components/version';
 import TimeSince from 'app/components/timeSince';
 import DateTime from 'app/components/dateTime';
@@ -11,7 +11,7 @@ import DateTime from 'app/components/dateTime';
 import {SectionHeading, Wrapper} from './styles';
 
 type Props = {
-  release: Release;
+  release: ReleaseWithHealth;
 };
 
 const ProjectReleaseDetails = ({release}: Props) => {

+ 1 - 1
src/sentry/static/sentry/app/views/releasesV2/detail/releaseHeader.tsx

@@ -26,7 +26,7 @@ type Props = {
   location: Location;
   orgId: string;
   release: Release;
-  project: ReleaseProject;
+  project: Required<ReleaseProject>;
   releaseMeta: ReleaseMeta;
 };
 

+ 114 - 0
src/sentry/static/sentry/app/views/releasesV2/list/clippedHealthRows.tsx

@@ -0,0 +1,114 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import space from 'app/styles/space';
+import {t, tct} from 'app/locale';
+import Button from 'app/components/button';
+
+type DefaultProps = {
+  maxVisibleItems: number;
+  fadeHeight: string;
+};
+
+type Props = DefaultProps & {
+  children: React.ReactNode[];
+  className?: string;
+};
+
+type State = {
+  collapsed: boolean;
+};
+
+// TODO(matej): refactor to reusable component
+
+class clippedHealthRows extends React.Component<Props, State> {
+  static defaultProps: DefaultProps = {
+    maxVisibleItems: 5,
+    fadeHeight: '40px',
+  };
+
+  state: State = {
+    collapsed: true,
+  };
+
+  reveal = () => {
+    this.setState({collapsed: false});
+  };
+
+  collapse = () => {
+    this.setState({collapsed: true});
+  };
+
+  render() {
+    const {children, maxVisibleItems, fadeHeight, className} = this.props;
+    const {collapsed} = this.state;
+
+    return (
+      <Wrapper className={className}>
+        {children.map((item, index) => {
+          if (!collapsed || index < maxVisibleItems) {
+            return item;
+          }
+
+          if (index === maxVisibleItems) {
+            return (
+              <ShowMoreWrapper fadeHeight={fadeHeight} key="show-more">
+                <Button
+                  onClick={this.reveal}
+                  priority="primary"
+                  size="xsmall"
+                  data-test-id="show-more"
+                >
+                  {tct('Show [numberOfFrames] More', {
+                    numberOfFrames: children.length - maxVisibleItems,
+                  })}
+                </Button>
+              </ShowMoreWrapper>
+            );
+          }
+          return null;
+        })}
+
+        {!collapsed && children.length > maxVisibleItems && (
+          <CollapseWrapper>
+            <Button
+              onClick={this.collapse}
+              priority="primary"
+              size="xsmall"
+              data-test-id="collapse"
+            >
+              {t('Collapse')}
+            </Button>
+          </CollapseWrapper>
+        )}
+      </Wrapper>
+    );
+  }
+}
+
+const Wrapper = styled('div')`
+  position: relative;
+`;
+
+const ShowMoreWrapper = styled('div')<{fadeHeight: string}>`
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-image: linear-gradient(180deg, hsla(0, 0%, 100%, 0.15) 0, #fff);
+  background-repeat: repeat-x;
+  text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-bottom: ${space(1)} solid #fff;
+  border-top: ${space(1)} solid transparent;
+  height: ${p => p.fadeHeight};
+`;
+
+const CollapseWrapper = styled('div')`
+  text-align: center;
+  padding: ${space(0.25)} 0 ${space(1)} 0;
+`;
+
+export default clippedHealthRows;

+ 45 - 17
src/sentry/static/sentry/app/views/releasesV2/list/index.tsx

@@ -16,10 +16,8 @@ import withOrganization from 'app/utils/withOrganization';
 import withGlobalSelection from 'app/utils/withGlobalSelection';
 import LoadingIndicator from 'app/components/loadingIndicator';
 import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
-import IntroBanner from 'app/views/releasesV2/list/introBanner';
 import {PageContent, PageHeader} from 'app/styles/organization';
 import EmptyStateWarning from 'app/components/emptyStateWarning';
-import ReleaseCard from 'app/views/releasesV2/list/releaseCard';
 import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
 import {getRelativeSummary} from 'app/components/organizations/timeRangeSelector/utils';
 import {DEFAULT_STATS_PERIOD} from 'app/constants';
@@ -27,7 +25,9 @@ import {defined} from 'app/utils';
 
 import ReleaseListSortOptions from './releaseListSortOptions';
 import ReleasePromo from './releasePromo';
+import IntroBanner from './introBanner';
 import SwitchReleasesButton from '../utils/switchReleasesButton';
+import ReleaseCard from './releaseCard';
 
 type RouteParams = {
   orgId: string;
@@ -38,7 +38,10 @@ type Props = RouteComponentProps<RouteParams, {}> & {
   selection: GlobalSelection;
 };
 
-type State = {releases: Release[]} & AsyncView['state'];
+type State = {
+  releases: Release[];
+  loadingHealth: boolean;
+} & AsyncView['state'];
 
 class ReleasesList extends AsyncView<Props, State> {
   shouldReload = true;
@@ -47,15 +50,10 @@ class ReleasesList extends AsyncView<Props, State> {
     return routeTitleGen(t('Releases'), this.props.organization.slug, false);
   }
 
-  getDefaultState() {
-    return {
-      ...super.getDefaultState(),
-    };
-  }
-
-  getEndpoints(): [string, string, {}][] {
+  getEndpoints() {
     const {organization, location} = this.props;
-    const {statsPeriod, sort} = location.query;
+    const {statsPeriod} = location.query;
+    const sort = this.getSort();
 
     const query = {
       ...pick(location.query, [
@@ -68,15 +66,43 @@ class ReleasesList extends AsyncView<Props, State> {
         'healthStat',
       ]),
       summaryStatsPeriod: statsPeriod,
-      per_page: 50,
+      per_page: 25,
       health: 1,
-      flatten: !sort || sort === 'date' ? 0 : 1,
+      flatten: sort === 'date' ? 0 : 1,
     };
 
-    return [['releases', `/organizations/${organization.slug}/releases/`, {query}]];
+    const endpoints: ReturnType<AsyncView['getEndpoints']> = [
+      ['releasesWithHealth', `/organizations/${organization.slug}/releases/`, {query}],
+    ];
+
+    // when sorting by date we fetch releases without health and then fetch health lazily
+    if (sort === 'date') {
+      endpoints.push([
+        'releasesWithoutHealth',
+        `/organizations/${organization.slug}/releases/`,
+        {query: {...query, health: 0}},
+      ]);
+    }
+
+    return endpoints;
+  }
+
+  onRequestSuccess({stateKey, data, jqXHR}) {
+    const {remainingRequests} = this.state;
+
+    // make sure there's no withHealth/withoutHealth race condition and set proper loading state
+    if (stateKey === 'releasesWithHealth' || remainingRequests === 1) {
+      this.setState({
+        reloading: false,
+        loading: false,
+        loadingHealth: stateKey === 'releasesWithoutHealth',
+        releases: data,
+        releasesPageLinks: jqXHR?.getResponseHeader('Link'),
+      });
+    }
   }
 
-  componentDidUpdate(prevProps, prevState) {
+  componentDidUpdate(prevProps: Props, prevState: State) {
     super.componentDidUpdate(prevProps, prevState);
 
     if (prevState.releases !== this.state.releases) {
@@ -176,7 +202,7 @@ class ReleasesList extends AsyncView<Props, State> {
 
   renderInnerBody() {
     const {location, selection, organization} = this.props;
-    const {releases, reloading} = this.state;
+    const {releases, reloading, loadingHealth} = this.state;
 
     if (this.shouldShowLoadingIndicator()) {
       return <LoadingIndicator />;
@@ -194,12 +220,14 @@ class ReleasesList extends AsyncView<Props, State> {
         selection={selection}
         reloading={reloading}
         key={`${release.version}-${release.projects[0].slug}`}
+        showHealthPlaceholders={loadingHealth}
       />
     ));
   }
 
   renderBody() {
     const {organization} = this.props;
+    const {releasesPageLinks} = this.state;
 
     return (
       <GlobalSelectionHeader
@@ -229,7 +257,7 @@ class ReleasesList extends AsyncView<Props, State> {
 
             {this.renderInnerBody()}
 
-            <Pagination pageLinks={this.state.releasesPageLinks} />
+            <Pagination pageLinks={releasesPageLinks} />
 
             {!this.shouldShowLoadingIndicator() && (
               <SwitchReleasesButton version="1" orgId={organization.id} />

+ 10 - 1
src/sentry/static/sentry/app/views/releasesV2/list/releaseCard.tsx

@@ -28,9 +28,17 @@ type Props = {
   location: Location;
   selection: GlobalSelection;
   reloading: boolean;
+  showHealthPlaceholders: boolean;
 };
 
-const ReleaseCard = ({release, orgSlug, location, selection, reloading}: Props) => {
+const ReleaseCard = ({
+  release,
+  orgSlug,
+  location,
+  reloading,
+  selection,
+  showHealthPlaceholders,
+}: Props) => {
   const {version, commitCount, lastDeploy, authors, dateCreated} = release;
 
   return (
@@ -112,6 +120,7 @@ const ReleaseCard = ({release, orgSlug, location, selection, reloading}: Props)
         release={release}
         orgSlug={orgSlug}
         location={location}
+        showPlaceholders={showHealthPlaceholders}
         selection={selection}
       />
     </StyledPanel>

+ 52 - 28
src/sentry/static/sentry/app/views/releasesV2/list/releaseHealth.tsx

@@ -17,7 +17,7 @@ import ScoreBar from 'app/components/scoreBar';
 import Tooltip from 'app/components/tooltip';
 import ProjectBadge from 'app/components/idBadge/projectBadge';
 import TextOverflow from 'app/components/textOverflow';
-import ClippedBox from 'app/components/clippedBox';
+import Placeholder from 'app/components/placeholder';
 import Link from 'app/components/links/link';
 
 import HealthStatsChart from './healthStatsChart';
@@ -31,15 +31,23 @@ import HealthStatsSubject, {StatsSubject} from './healthStatsSubject';
 import HealthStatsPeriod, {StatsPeriod} from './healthStatsPeriod';
 import AdoptionTooltip from './adoptionTooltip';
 import NotAvailable from './notAvailable';
+import ClippedHealthRows from './clippedHealthRows';
 
 type Props = {
   release: Release;
   orgSlug: string;
   location: Location;
+  showPlaceholders: boolean;
   selection: GlobalSelection;
 };
 
-const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
+const ReleaseHealth = ({
+  release,
+  orgSlug,
+  location,
+  selection,
+  showPlaceholders,
+}: Props) => {
   const activeStatsPeriod = (location.query.healthStatsPeriod || '24h') as StatsPeriod;
   const activeStatsSubject = (location.query.healthStat || 'sessions') as StatsSubject;
 
@@ -61,17 +69,8 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
           <CrashFreeUsersColumn>{t('Crash free users')}</CrashFreeUsersColumn>
           <CrashFreeSessionsColumn>{t('Crash free sessions')}</CrashFreeSessionsColumn>
           <DailyUsersColumn>
-            {release.projects.some(p => p.healthData.hasHealthData) ? (
-              <React.Fragment>
-                <HealthStatsSubject
-                  location={location}
-                  activeSubject={activeStatsSubject}
-                />
-                <HealthStatsPeriod location={location} activePeriod={activeStatsPeriod} />
-              </React.Fragment>
-            ) : (
-              t('Daily active users')
-            )}
+            <HealthStatsSubject location={location} activeSubject={activeStatsSubject} />
+            <HealthStatsPeriod location={location} activePeriod={activeStatsPeriod} />
           </DailyUsersColumn>
           <CrashesColumn>{t('Crashes')}</CrashesColumn>
           <NewIssuesColumn>{t('New Issues')}</NewIssuesColumn>
@@ -79,8 +78,8 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
       </StyledPanelHeader>
 
       <PanelBody>
-        <ClippedBox clipHeight={200}>
-          {sortedProjects.map(project => {
+        <ClippedHealthRows fadeHeight="46px" maxVisibleItems={4}>
+          {sortedProjects.map((project, index) => {
             const {id, slug, healthData, newGroups} = project;
             const {
               hasHealthData,
@@ -93,10 +92,13 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
               totalUsers24h,
               totalSessions,
               totalSessions24h,
-            } = healthData;
+            } = healthData || {};
 
             return (
-              <StyledPanelItem key={`${release.version}-${slug}-health`}>
+              <StyledPanelItem
+                key={`${release.version}-${slug}-health`}
+                isLast={index === sortedProjects.length - 1}
+              >
                 <Layout>
                   <ProjectColumn>
                     <GlobalSelectionLink
@@ -112,15 +114,17 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
                   </ProjectColumn>
 
                   <AdoptionColumn>
-                    {defined(adoption) ? (
+                    {showPlaceholders ? (
+                      <StyledPlaceholder width="150px" />
+                    ) : defined(adoption) ? (
                       <AdoptionWrapper>
                         <Tooltip
                           title={
                             <AdoptionTooltip
-                              totalUsers={totalUsers}
-                              totalSessions={totalSessions}
-                              totalUsers24h={totalUsers24h}
-                              totalSessions24h={totalSessions24h}
+                              totalUsers={totalUsers!}
+                              totalSessions={totalSessions!}
+                              totalUsers24h={totalUsers24h!}
+                              totalSessions24h={totalSessions24h!}
                             />
                           }
                         >
@@ -143,7 +147,9 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
                   </AdoptionColumn>
 
                   <CrashFreeUsersColumn>
-                    {defined(crashFreeUsers) ? (
+                    {showPlaceholders ? (
+                      <StyledPlaceholder width="60px" />
+                    ) : defined(crashFreeUsers) ? (
                       <React.Fragment>
                         <StyledProgressRing
                           progressColor={getCrashFreePercentColor}
@@ -159,7 +165,9 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
                   </CrashFreeUsersColumn>
 
                   <CrashFreeSessionsColumn>
-                    {defined(crashFreeSessions) ? (
+                    {showPlaceholders ? (
+                      <StyledPlaceholder width="60px" />
+                    ) : defined(crashFreeSessions) ? (
                       <React.Fragment>
                         <StyledProgressRing
                           progressColor={getCrashFreePercentColor}
@@ -175,7 +183,9 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
                   </CrashFreeSessionsColumn>
 
                   <DailyUsersColumn>
-                    {hasHealthData ? (
+                    {showPlaceholders ? (
+                      <StyledPlaceholder />
+                    ) : hasHealthData && defined(stats) ? (
                       <ChartWrapper>
                         <HealthStatsChart
                           data={stats}
@@ -190,7 +200,13 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
                   </DailyUsersColumn>
 
                   <CrashesColumn>
-                    {hasHealthData ? <Count value={sessionsCrashed} /> : <NotAvailable />}
+                    {showPlaceholders ? (
+                      <StyledPlaceholder width="30px" />
+                    ) : hasHealthData && defined(sessionsCrashed) ? (
+                      <Count value={sessionsCrashed} />
+                    ) : (
+                      <NotAvailable />
+                    )}
                   </CrashesColumn>
 
                   <NewIssuesColumn>
@@ -206,7 +222,7 @@ const ReleaseHealth = ({release, orgSlug, location, selection}: Props) => {
               </StyledPanelItem>
             );
           })}
-        </ClippedBox>
+        </ClippedHealthRows>
       </PanelBody>
     </React.Fragment>
   );
@@ -220,9 +236,10 @@ const StyledPanelHeader = styled(PanelHeader)`
   font-size: ${p => p.theme.fontSizeSmall};
 `;
 
-const StyledPanelItem = styled(PanelItem)`
+const StyledPanelItem = styled(PanelItem)<{isLast: boolean}>`
   padding: ${space(1)} ${space(2)};
   min-height: 46px;
+  border: ${p => (p.isLast ? 'none' : null)};
 `;
 
 const Layout = styled('div')`
@@ -333,4 +350,11 @@ const ChartWrapper = styled('div')`
   }
 `;
 
+const StyledPlaceholder = styled(Placeholder)`
+  height: 20px;
+  display: inline-block;
+  position: relative;
+  top: ${space(0.25)};
+`;
+
 export default ReleaseHealth;