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

feat(profiling): New layout for profiling landing widgets (#51907)

This updates the layout of the landing widgets to show a small
percentiles chart up top and allow toggling between all the available
widgets.
Tony Xiao 1 год назад
Родитель
Сommit
c00d215e81

+ 44 - 30
static/app/views/profiling/content.tsx

@@ -35,16 +35,15 @@ import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents';
 import {useProfileFilters} from 'sentry/utils/profiling/hooks/useProfileFilters';
 import {formatError, formatSort} from 'sentry/utils/profiling/hooks/utils';
 import {decodeScalar} from 'sentry/utils/queryString';
-import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjects from 'sentry/utils/useProjects';
 import {DEFAULT_PROFILING_DATETIME_SELECTION} from 'sentry/views/profiling/utils';
 
-import {FunctionTrendsWidget} from './landing/functionTrendsWidget';
+import {LandingWidgetSelector} from './landing/landingWidgetSelector';
 import {ProfileCharts} from './landing/profileCharts';
+import {ProfilesChartWidget} from './landing/profilesChartWidget';
 import {ProfilingSlowestTransactionsPanel} from './landing/profilingSlowestTransactionsPanel';
-import {SlowestFunctionsWidget} from './landing/slowestFunctionsWidget';
 import {ProfilingOnboardingPanel} from './profilingOnboardingPanel';
 
 interface ProfilingContentProps {
@@ -152,12 +151,6 @@ function ProfilingContent({location}: ProfilingContentProps) {
 
   const isProfilingGA = organization.features.includes('profiling-ga');
 
-  const functionQuery = useMemo(() => {
-    const conditions = new MutableSearch('');
-    conditions.setFilterValues('is_application', ['1']);
-    return conditions.formatString();
-  }, []);
-
   return (
     <SentryDocumentTitle title={t('Profiling')} orgSlug={organization.slug}>
       <PageFiltersContainer
@@ -269,30 +262,42 @@ function ProfilingContent({location}: ProfilingContentProps) {
                 )
               ) : (
                 <Fragment>
-                  <PanelsGrid>
-                    {organization.features.includes(
-                      'profiling-global-suspect-functions'
-                    ) ? (
-                      <Fragment>
-                        <SlowestFunctionsWidget userQuery={functionQuery} />
-                        <FunctionTrendsWidget
-                          trendFunction="p95()"
-                          trendType="regression"
-                          userQuery={functionQuery}
+                  {organization.features.includes(
+                    'profiling-global-suspect-functions'
+                  ) ? (
+                    <Fragment>
+                      <ProfilesChartWidget
+                        chartHeight={100}
+                        referrer="api.profiling.landing-chart"
+                        userQuery={query}
+                        selection={selection}
+                      />
+                      <WidgetsContainer>
+                        <LandingWidgetSelector
+                          widgetHeight="340px"
+                          defaultWidget="slowest functions"
+                          query={query}
+                          storageKey="profiling-landing-widget-0"
                         />
-                      </Fragment>
-                    ) : (
-                      <Fragment>
-                        <ProfilingSlowestTransactionsPanel />
-                        <ProfileCharts
-                          referrer="api.profiling.landing-chart"
+                        <LandingWidgetSelector
+                          widgetHeight="340px"
+                          defaultWidget="regressed functions"
                           query={query}
-                          selection={selection}
-                          hideCount
+                          storageKey="profiling-landing-widget-1"
                         />
-                      </Fragment>
-                    )}
-                  </PanelsGrid>
+                      </WidgetsContainer>
+                    </Fragment>
+                  ) : (
+                    <PanelsGrid>
+                      <ProfilingSlowestTransactionsPanel />
+                      <ProfileCharts
+                        referrer="api.profiling.landing-chart"
+                        query={query}
+                        selection={selection}
+                        hideCount
+                      />
+                    </PanelsGrid>
+                  )}
                   <ProfileEventsTable
                     columns={fields.slice()}
                     data={transactions.status === 'success' ? transactions.data : null}
@@ -354,4 +359,13 @@ const PanelsGrid = styled('div')`
   }
 `;
 
+const WidgetsContainer = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: ${space(2)};
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 1fr;
+  }
+`;
+
 export default ProfilingContent;

+ 16 - 5
static/app/views/profiling/landing/functionTrendsWidget.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
+import {Fragment, ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
 import {browserHistory} from 'react-router';
 import {Theme, useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
@@ -45,13 +45,17 @@ const CURSOR_NAME = 'fnTrendCursor';
 interface FunctionTrendsWidgetProps {
   trendFunction: 'p50()' | 'p75()' | 'p95()' | 'p99()';
   trendType: TrendType;
+  header?: ReactNode;
   userQuery?: string;
+  widgetHeight?: string;
 }
 
 export function FunctionTrendsWidget({
-  userQuery,
+  header,
   trendFunction,
   trendType,
+  widgetHeight,
+  userQuery,
 }: FunctionTrendsWidgetProps) {
   const location = useLocation();
 
@@ -86,8 +90,9 @@ export function FunctionTrendsWidget({
   const isError = trendsQuery.isError;
 
   return (
-    <WidgetContainer>
+    <WidgetContainer height={widgetHeight}>
       <FunctionTrendsWidgetHeader
+        header={header}
         handleCursor={handleCursor}
         pageLinks={trendsQuery.getResponseHeader?.('Link') ?? null}
         trendType={trendType}
@@ -130,12 +135,14 @@ export function FunctionTrendsWidget({
 
 interface FunctionTrendsWidgetHeaderProps {
   handleCursor: CursorHandler;
+  header: ReactNode;
   pageLinks: string | null;
   trendType: TrendType;
 }
 
 function FunctionTrendsWidgetHeader({
   handleCursor,
+  header,
   pageLinks,
   trendType,
 }: FunctionTrendsWidgetHeaderProps) {
@@ -143,7 +150,9 @@ function FunctionTrendsWidgetHeader({
     case 'regression':
       return (
         <HeaderContainer>
-          <HeaderTitleLegend>{t('Most Regressed Functions')}</HeaderTitleLegend>
+          {header ?? (
+            <HeaderTitleLegend>{t('Most Regressed Functions')}</HeaderTitleLegend>
+          )}
           <Subtitle>{t('Functions by most regressed.')}</Subtitle>
           <StyledPagination pageLinks={pageLinks} size="xs" onCursor={handleCursor} />
         </HeaderContainer>
@@ -151,7 +160,9 @@ function FunctionTrendsWidgetHeader({
     case 'improvement':
       return (
         <HeaderContainer>
-          <HeaderTitleLegend>{t('Most Improved Functions')}</HeaderTitleLegend>
+          {header ?? (
+            <HeaderTitleLegend>{t('Most Improved Functions')}</HeaderTitleLegend>
+          )}
           <Subtitle>{t('Functions by most improved.')}</Subtitle>
           <StyledPagination pageLinks={pageLinks} size="xs" onCursor={handleCursor} />
         </HeaderContainer>

+ 96 - 0
static/app/views/profiling/landing/landingWidgetSelector.tsx

@@ -0,0 +1,96 @@
+import {useMemo} from 'react';
+
+import {CompactSelect, SelectOption} from 'sentry/components/compactSelect';
+import {t} from 'sentry/locale';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
+
+import {FunctionTrendsWidget} from './functionTrendsWidget';
+import {SlowestFunctionsWidget} from './slowestFunctionsWidget';
+
+export type WidgetOption =
+  | 'slowest functions'
+  | 'regressed functions'
+  | 'improved functions';
+
+interface LandingWidgetSelectorProps {
+  defaultWidget: WidgetOption;
+  query: string;
+  storageKey: string;
+  widgetHeight?: string;
+}
+
+export function LandingWidgetSelector({
+  defaultWidget,
+  storageKey,
+  widgetHeight,
+}: LandingWidgetSelectorProps) {
+  const [selectedWidget, setSelectedWidget] = useSyncedLocalStorageState<WidgetOption>(
+    storageKey,
+    defaultWidget
+  );
+
+  const functionQuery = useMemo(() => {
+    const conditions = new MutableSearch('');
+    conditions.setFilterValues('is_application', ['1']);
+    return conditions.formatString();
+  }, []);
+
+  const header = (
+    <CompactSelect
+      value={selectedWidget}
+      options={WIDGET_OPTIONS}
+      onChange={opt => setSelectedWidget(opt.value)}
+      triggerProps={{borderless: true, size: 'zero'}}
+      offset={4}
+    />
+  );
+
+  switch (selectedWidget) {
+    case 'slowest functions':
+      return (
+        <SlowestFunctionsWidget
+          header={header}
+          userQuery={functionQuery}
+          widgetHeight={widgetHeight}
+        />
+      );
+    case 'regressed functions':
+      return (
+        <FunctionTrendsWidget
+          header={header}
+          trendFunction="p95()"
+          trendType="regression"
+          userQuery={functionQuery}
+          widgetHeight={widgetHeight}
+        />
+      );
+    case 'improved functions':
+      return (
+        <FunctionTrendsWidget
+          header={header}
+          trendFunction="p95()"
+          trendType="improvement"
+          userQuery={functionQuery}
+          widgetHeight={widgetHeight}
+        />
+      );
+    default:
+      throw new Error('unknown widget type');
+  }
+}
+
+const WIDGET_OPTIONS: SelectOption<WidgetOption>[] = [
+  {
+    label: t('Suspect Functions'),
+    value: 'slowest functions' as const,
+  },
+  {
+    label: t('Most Regressed Functions'),
+    value: 'regressed functions' as const,
+  },
+  {
+    label: t('Most Improved Functions'),
+    value: 'improved functions' as const,
+  },
+];

+ 130 - 0
static/app/views/profiling/landing/profilesChartWidget.tsx

@@ -0,0 +1,130 @@
+import {ReactNode, useMemo} from 'react';
+import {useTheme} from '@emotion/react';
+
+import {AreaChart} from 'sentry/components/charts/areaChart';
+import ChartZoom from 'sentry/components/charts/chartZoom';
+import {t} from 'sentry/locale';
+import {PageFilters} from 'sentry/types';
+import {Series} from 'sentry/types/echarts';
+import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
+import {useProfileEventsStats} from 'sentry/utils/profiling/hooks/useProfileEventsStats';
+import useRouter from 'sentry/utils/useRouter';
+
+import {
+  ContentContainer,
+  HeaderContainer,
+  HeaderTitleLegend,
+  Subtitle,
+  WidgetContainer,
+} from './styles';
+
+interface ProfilesChartWidgetProps {
+  chartHeight: number;
+  referrer: string;
+  header?: ReactNode;
+  selection?: PageFilters;
+  userQuery?: string;
+  widgetHeight?: string;
+}
+
+const SERIES_ORDER = ['p99()', 'p95()', 'p75()', 'p50()'] as const;
+
+export function ProfilesChartWidget({
+  chartHeight,
+  header,
+  referrer,
+  selection,
+  userQuery,
+  widgetHeight,
+}: ProfilesChartWidgetProps) {
+  const router = useRouter();
+  const theme = useTheme();
+
+  const profileStats = useProfileEventsStats({
+    query: userQuery,
+    referrer,
+    yAxes: SERIES_ORDER,
+  });
+
+  const series: Series[] = useMemo(() => {
+    if (profileStats.status !== 'success') {
+      return [];
+    }
+
+    // the timestamps in the response is in seconds but echarts expects
+    // a timestamp in milliseconds, so multiply by 1e3 to do the conversion
+    const timestamps = profileStats.data[0].timestamps.map(ts => ts * 1e3);
+
+    return profileStats.data[0].data
+      .map(rawData => {
+        if (timestamps.length !== rawData.values.length) {
+          throw new Error('Invalid stats response');
+        }
+
+        return {
+          data: rawData.values.map((value, i) => ({
+            name: timestamps[i]!,
+            // the response value contains nulls when no data
+            // is available, use 0 to represent it
+            value: value ?? 0,
+          })),
+          seriesName: rawData.axis,
+        };
+      })
+      .sort((a, b) => {
+        const idxA = SERIES_ORDER.indexOf(a.seriesName as any);
+        const idxB = SERIES_ORDER.indexOf(b.seriesName as any);
+
+        return idxA - idxB;
+      });
+  }, [profileStats]);
+
+  const chartOptions = useMemo(() => {
+    return {
+      height: chartHeight,
+      grid: {
+        top: '16px',
+        left: '24px',
+        right: '24px',
+        bottom: '16px',
+      },
+      xAxis: {
+        type: 'time' as const,
+      },
+      yAxis: {
+        scale: true,
+        axisLabel: {
+          color: theme.chartLabel,
+          formatter(value: number) {
+            return axisLabelFormatter(value, 'duration');
+          },
+        },
+      },
+      tooltip: {
+        valueFormatter: value => tooltipFormatter(value, 'duration'),
+      },
+    };
+  }, [chartHeight, theme.chartLabel]);
+
+  return (
+    <WidgetContainer height={widgetHeight}>
+      <HeaderContainer>
+        {header ?? <HeaderTitleLegend>{t('Profiles by Percentiles')}</HeaderTitleLegend>}
+        <Subtitle>{t('Percentiles over time')}</Subtitle>
+      </HeaderContainer>
+      <ContentContainer>
+        <ChartZoom router={router} {...selection?.datetime}>
+          {zoomRenderProps => (
+            <AreaChart
+              {...zoomRenderProps}
+              {...chartOptions}
+              series={series}
+              isGroupedByDate
+              showTimeInTooltip
+            />
+          )}
+        </ChartZoom>
+      </ContentContainer>
+    </WidgetContainer>
+  );
+}

+ 3 - 3
static/app/views/profiling/landing/slowestFunctionsWidget.spec.tsx

@@ -24,7 +24,7 @@ describe('SlowestFunctionsWidget', function () {
       statusCode: 400,
     });
 
-    render(<SlowestFunctionsWidget />);
+    render(<SlowestFunctionsWidget widgetHeight="100px" />);
 
     // starts by rendering loading
     expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
@@ -48,7 +48,7 @@ describe('SlowestFunctionsWidget', function () {
       ],
     });
 
-    render(<SlowestFunctionsWidget />);
+    render(<SlowestFunctionsWidget widgetHeight="100px" />);
 
     // starts by rendering loading
     expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
@@ -160,7 +160,7 @@ describe('SlowestFunctionsWidget', function () {
       ],
     });
 
-    render(<SlowestFunctionsWidget />);
+    render(<SlowestFunctionsWidget widgetHeight="100px" />);
 
     // starts by rendering loading
     expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();

+ 10 - 4
static/app/views/profiling/landing/slowestFunctionsWidget.tsx

@@ -1,4 +1,4 @@
-import {CSSProperties, Fragment, useCallback, useMemo, useState} from 'react';
+import {CSSProperties, Fragment, ReactNode, useCallback, useMemo, useState} from 'react';
 import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 
@@ -41,10 +41,16 @@ const MAX_FUNCTIONS = 3;
 const CURSOR_NAME = 'slowFnCursor';
 
 interface SlowestFunctionsWidgetProps {
+  header?: ReactNode;
   userQuery?: string;
+  widgetHeight?: string;
 }
 
-export function SlowestFunctionsWidget({userQuery}: SlowestFunctionsWidgetProps) {
+export function SlowestFunctionsWidget({
+  header,
+  userQuery,
+  widgetHeight,
+}: SlowestFunctionsWidgetProps) {
   const location = useLocation();
 
   const [expandedIndex, setExpandedIndex] = useState(0);
@@ -99,9 +105,9 @@ export function SlowestFunctionsWidget({userQuery}: SlowestFunctionsWidgetProps)
   const isError = functionsQuery.isError || totalsQuery.isError;
 
   return (
-    <WidgetContainer>
+    <WidgetContainer height={widgetHeight}>
       <HeaderContainer>
-        <HeaderTitleLegend>{t('Suspect Functions')}</HeaderTitleLegend>
+        {header ?? <HeaderTitleLegend>{t('Suspect Functions')}</HeaderTitleLegend>}
         <Subtitle>{t('Slowest functions by total time spent.')}</Subtitle>
         <StyledPagination
           pageLinks={functionsQuery.getResponseHeader?.('Link') ?? null}

+ 3 - 2
static/app/views/profiling/landing/styles.tsx

@@ -3,9 +3,10 @@ import styled from '@emotion/styled';
 import {HeaderTitleLegend as _HeaderTitleLegend} from 'sentry/components/charts/styles';
 import {Panel} from 'sentry/components/panels';
 import {space} from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
 
-export const WidgetContainer = styled(Panel)`
-  height: 340px;
+export const WidgetContainer = styled(Panel)<{height?: string}>`
+  ${p => defined(p.height) && `height: ${p.height};`}
   display: flex;
   flex-direction: column;
   padding-top: ${space(2)};