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

feat(perf): Add vitals overview and details (#22202)

* feat(perf): Add vitals overview and details

This adds cards to the landing page to show a quick overview of your vitals pass rate, and clicking on them brings you to a detail page for that vital.
k-fish 4 лет назад
Родитель
Сommit
3f9823b6f8

+ 18 - 0
src/sentry/static/sentry/app/routes.jsx

@@ -1772,6 +1772,24 @@ function routes() {
               component={errorHandler(LazyLoad)}
             />
           </Route>
+          <Route
+            path="/organizations/:orgId/performance/vitaldetail/"
+            componentPromise={() =>
+              import(
+                /* webpackChunkName: "PerformanceContainer" */ 'app/views/performance'
+              )
+            }
+            component={errorHandler(LazyLoad)}
+          >
+            <IndexRoute
+              componentPromise={() =>
+                import(
+                  /* webpackChunkName: "PerformanceVitalDetail" */ 'app/views/performance/vitalDetail'
+                )
+              }
+              component={errorHandler(LazyLoad)}
+            />
+          </Route>
           <Route
             path="/organizations/:orgId/performance/:eventSlug/"
             componentPromise={() =>

+ 16 - 1
src/sentry/static/sentry/app/views/performance/breadcrumb.tsx

@@ -8,12 +8,14 @@ import {decodeScalar} from 'app/utils/queryString';
 
 import {transactionSummaryRouteWithQuery} from './transactionSummary/utils';
 import {vitalsRouteWithQuery} from './transactionVitals/utils';
+import {vitalDetailRouteWithQuery} from './vitalDetail/utils';
 import {getPerformanceLandingUrl} from './utils';
 
 type Props = {
   organization: Organization;
   location: Location;
   transactionName?: string;
+  vitalName?: string;
   eventSlug?: string;
   transactionComparison?: boolean;
   realUserMonitoring?: boolean;
@@ -26,6 +28,7 @@ class Breadcrumb extends React.Component<Props> {
       organization,
       location,
       transactionName,
+      vitalName,
       eventSlug,
       transactionComparison,
       realUserMonitoring,
@@ -46,7 +49,19 @@ class Breadcrumb extends React.Component<Props> {
       preserveGlobalSelection: true,
     });
 
-    if (transactionName) {
+    if (vitalName) {
+      const rumTarget = vitalDetailRouteWithQuery({
+        orgSlug: organization.slug,
+        vitalName: 'fcp',
+        projectID: decodeScalar(location.query.project),
+        query: location.query,
+      });
+      crumbs.push({
+        to: rumTarget,
+        label: t('Vital Detail'),
+        preserveGlobalSelection: true,
+      });
+    } else if (transactionName) {
       if (realUserMonitoring) {
         const rumTarget = vitalsRouteWithQuery({
           orgSlug: organization.slug,

+ 54 - 0
src/sentry/static/sentry/app/views/performance/data.tsx

@@ -6,6 +6,11 @@ import EventView from 'app/utils/discover/eventView';
 import {decodeScalar} from 'app/utils/queryString';
 import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
 
+import {
+  getVitalDetailTableStatusFunction,
+  vitalNameFromLocation,
+} from './vitalDetail/utils';
+
 export const DEFAULT_STATS_PERIOD = '24h';
 
 export const COLUMN_TITLES = [
@@ -141,3 +146,52 @@ export function generatePerformanceEventView(
 
   return EventView.fromNewQueryWithLocation(savedQuery, location);
 }
+
+export function generatePerformanceVitalDetailView(
+  _organization: LightWeightOrganization,
+  location: Location
+): EventView {
+  const {query} = location;
+
+  const vitalName = vitalNameFromLocation(location);
+
+  const hasStartAndEnd = query.start && query.end;
+  const savedQuery: NewQuery = {
+    id: undefined,
+    name: t('Vitals Performance Details'),
+    query: 'event.type:transaction',
+    projects: [],
+    fields: [
+      'transaction',
+      'project',
+      'count_unique(user)',
+      'count()',
+      `p50(${vitalName})`,
+      `p75(${vitalName})`,
+      `p95(${vitalName})`,
+      getVitalDetailTableStatusFunction(vitalName),
+    ],
+    version: 2,
+  };
+
+  if (!query.statsPeriod && !hasStartAndEnd) {
+    savedQuery.range = DEFAULT_STATS_PERIOD;
+  }
+  savedQuery.orderby = decodeScalar(query.sort) || `-count`;
+
+  const searchQuery = decodeScalar(query.query) || '';
+  const conditions = tokenizeSearch(searchQuery);
+
+  conditions.setTagValues('has', [vitalName]);
+  conditions.setTagValues('event.type', ['transaction']);
+
+  // If there is a bare text search, we want to treat it as a search
+  // on the transaction name.
+  if (conditions.query.length > 0) {
+    conditions.setTagValues('transaction', [`*${conditions.query.join(' ')}*`]);
+    conditions.query = [];
+  }
+  savedQuery.query = stringifyQueryObject(conditions);
+
+  return EventView.fromNewQueryWithLocation(savedQuery, location);
+}

+ 9 - 0
src/sentry/static/sentry/app/views/performance/landing.tsx

@@ -7,6 +7,7 @@ import isEqual from 'lodash/isEqual';
 import {updateDateTime} from 'app/actionCreators/globalSelection';
 import {loadOrganizationTags} from 'app/actionCreators/tags';
 import {Client} from 'app/api';
+import Feature from 'app/components/acl/feature';
 import Alert from 'app/components/alert';
 import Button from 'app/components/button';
 import ButtonBar from 'app/components/buttonBar';
@@ -46,6 +47,7 @@ import {DEFAULT_STATS_PERIOD, generatePerformanceEventView} from './data';
 import Onboarding from './onboarding';
 import Table from './table';
 import {addRoutePerformanceContext, getTransactionSearchQuery} from './utils';
+import VitalsCards from './vitalsCards';
 
 export enum FilterViews {
   ALL_TRANSACTIONS = 'ALL_TRANSACTIONS',
@@ -359,6 +361,13 @@ class PerformanceLanding extends React.Component<Props, State> {
                     )}
                     onSearch={this.handleSearch}
                   />
+                  <Feature features={['performance-vitals-overview']}>
+                    <VitalsCards
+                      eventView={eventView}
+                      organization={organization}
+                      location={location}
+                    />
+                  </Feature>
                   <Charts
                     eventView={eventView}
                     organization={organization}

+ 7 - 5
src/sentry/static/sentry/app/views/performance/transactionSummary/transactionList.tsx

@@ -31,6 +31,7 @@ import {GridCell, GridCellNumber} from '../styles';
 import {getTransactionComparisonUrl, getTransactionDetailsUrl} from '../utils';
 
 import BaselineQuery, {BaselineQueryResults} from './baselineQuery';
+import {TransactionFilterOptions} from './utils';
 
 const TOP_TRANSACTION_LIMIT = 5;
 
@@ -46,25 +47,25 @@ function getFilterOptions({p95}: {p95: number}): FilterOption[] {
     {
       query: null,
       sort: {kind: 'asc', field: 'transaction.duration'},
-      value: 'fastest',
+      value: TransactionFilterOptions.FASTEST,
       label: t('Fastest Transactions'),
     },
     {
       query: [['transaction.duration', `<=${p95.toFixed(0)}`]],
       sort: {kind: 'desc', field: 'transaction.duration'},
-      value: 'slow',
+      value: TransactionFilterOptions.SLOW,
       label: t('Slow Transactions (p95)'),
     },
     {
       query: null,
       sort: {kind: 'desc', field: 'transaction.duration'},
-      value: 'outlier',
+      value: TransactionFilterOptions.OUTLIER,
       label: t('Outlier Transactions (p100)'),
     },
     {
       query: null,
       sort: {kind: 'desc', field: 'timestamp'},
-      value: 'recent',
+      value: TransactionFilterOptions.RECENT,
       label: t('Recent Transactions'),
     },
   ];
@@ -75,7 +76,8 @@ function getTransactionSort(
   p95: number
 ): {selected: FilterOption; options: FilterOption[]} {
   const options = getFilterOptions({p95});
-  const urlParam = decodeScalar(location.query.showTransactions) || 'slow';
+  const urlParam =
+    decodeScalar(location.query.showTransactions) || TransactionFilterOptions.SLOW;
   const selected = options.find(opt => opt.value === urlParam) || options[0];
   return {selected, options};
 }

+ 10 - 0
src/sentry/static/sentry/app/views/performance/transactionSummary/utils.tsx

@@ -4,6 +4,13 @@ import {TrendFunctionField} from '../trends/types';
 
 import {DisplayModes} from './charts';
 
+export enum TransactionFilterOptions {
+  FASTEST = 'fastest',
+  SLOW = 'slow',
+  OUTLIER = 'outlier',
+  RECENT = 'recent',
+}
+
 export function generateTransactionSummaryRoute({orgSlug}: {orgSlug: String}): string {
   return `/organizations/${orgSlug}/performance/summary/`;
 }
@@ -16,6 +23,7 @@ export function transactionSummaryRouteWithQuery({
   unselectedSeries = 'p100()',
   display,
   trendDisplay,
+  showTransactions,
 }: {
   orgSlug: string;
   transaction: string;
@@ -24,6 +32,7 @@ export function transactionSummaryRouteWithQuery({
   trendDisplay?: TrendFunctionField;
   unselectedSeries?: string | string[];
   projectID?: string | string[];
+  showTransactions?: TransactionFilterOptions;
 }) {
   const pathname = generateTransactionSummaryRoute({
     orgSlug,
@@ -40,6 +49,7 @@ export function transactionSummaryRouteWithQuery({
       end: query.end,
       query: query.query,
       unselectedSeries,
+      showTransactions,
       display,
       trendDisplay,
     },

+ 37 - 21
src/sentry/static/sentry/app/views/performance/trends/utils.tsx

@@ -316,6 +316,14 @@ export const smoothTrend = (data: [number, number][], resolution = 100) => {
   return ASAP(data, resolution);
 };
 
+export const replaceSeriesName = (seriesName: string) => {
+  return ['p50', 'p75'].find(aggregate => seriesName.includes(aggregate));
+};
+
+export const replaceSmoothedSeriesName = (seriesName: string) => {
+  return `Smoothed ${['p50', 'p75'].find(aggregate => seriesName.includes(aggregate))}`;
+};
+
 export function transformEventStatsSmoothed(data?: Series[], seriesName?: string) {
   let minValue = Number.MAX_SAFE_INTEGER;
   let maxValue = 0;
@@ -326,34 +334,42 @@ export function transformEventStatsSmoothed(data?: Series[], seriesName?: string
       smoothedResults: undefined,
     };
   }
-  const currentData = data[0].data;
-  const resultData: SeriesDataUnit[] = [];
 
-  const smoothed = smoothTrend(currentData.map(({name, value}) => [Number(name), value]));
-
-  for (let i = 0; i < smoothed.length; i++) {
-    const point = smoothed[i] as any;
-    const value = point.y;
-    resultData.push({
-      name: point.x,
-      value,
-    });
-    if (!isNaN(value)) {
-      const rounded = Math.round(value);
-      minValue = Math.min(rounded, minValue);
-      maxValue = Math.max(rounded, maxValue);
+  const smoothedResults: Series[] = [];
+
+  for (const current of data) {
+    const currentData = current.data;
+    const resultData: SeriesDataUnit[] = [];
+
+    const smoothed = smoothTrend(
+      currentData.map(({name, value}) => [Number(name), value])
+    );
+
+    for (let i = 0; i < smoothed.length; i++) {
+      const point = smoothed[i] as any;
+      const value = point.y;
+      resultData.push({
+        name: point.x,
+        value,
+      });
+      if (!isNaN(value)) {
+        const rounded = Math.round(value);
+        minValue = Math.min(rounded, minValue);
+        maxValue = Math.max(rounded, maxValue);
+      }
     }
+    smoothedResults.push({
+      seriesName: seriesName || current.seriesName || 'Current',
+      data: resultData,
+      lineStyle: current.lineStyle,
+      color: current.color,
+    });
   }
 
   return {
     minValue,
     maxValue,
-    smoothedResults: [
-      {
-        seriesName: seriesName || 'Current',
-        data: resultData,
-      },
-    ],
+    smoothedResults,
   };
 }
 

+ 135 - 0
src/sentry/static/sentry/app/views/performance/vitalDetail/index.tsx

@@ -0,0 +1,135 @@
+import React from 'react';
+import {browserHistory, InjectedRouter} from 'react-router';
+import {Params} from 'react-router/lib/Router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+import isEqual from 'lodash/isEqual';
+
+import {loadOrganizationTags} from 'app/actionCreators/tags';
+import {Client} from 'app/api';
+import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
+import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
+import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
+import {t} from 'app/locale';
+import {PageContent} from 'app/styles/organization';
+import {GlobalSelection, Organization, Project} from 'app/types';
+import EventView from 'app/utils/discover/eventView';
+import {WebVital} from 'app/utils/discover/fields';
+import {decodeScalar} from 'app/utils/queryString';
+import withApi from 'app/utils/withApi';
+import withGlobalSelection from 'app/utils/withGlobalSelection';
+import withOrganization from 'app/utils/withOrganization';
+import withProjects from 'app/utils/withProjects';
+
+import {generatePerformanceVitalDetailView} from '../data';
+import {addRoutePerformanceContext, getTransactionName} from '../utils';
+
+import VitalDetailContent from './vitalDetailContent';
+
+type Props = {
+  api: Client;
+  location: Location;
+  params: Params;
+  organization: Organization;
+  projects: Project[];
+  selection: GlobalSelection;
+  loadingProjects: boolean;
+  router: InjectedRouter;
+};
+
+type State = {
+  eventView: EventView | undefined;
+};
+
+class VitalDetail extends React.Component<Props, State> {
+  state: State = {
+    eventView: generatePerformanceVitalDetailView(
+      this.props.organization,
+      this.props.location
+    ),
+  };
+
+  static getDerivedStateFromProps(nextProps: Props, prevState: State): State {
+    return {
+      ...prevState,
+      eventView: generatePerformanceVitalDetailView(
+        nextProps.organization,
+        nextProps.location
+      ),
+    };
+  }
+
+  componentDidMount() {
+    const {api, organization, selection} = this.props;
+    loadOrganizationTags(api, organization.slug, selection);
+    addRoutePerformanceContext(selection);
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const {api, organization, selection} = this.props;
+
+    if (
+      !isEqual(prevProps.selection.projects, selection.projects) ||
+      !isEqual(prevProps.selection.datetime, selection.datetime)
+    ) {
+      loadOrganizationTags(api, organization.slug, selection);
+      addRoutePerformanceContext(selection);
+    }
+  }
+
+  getDocumentTitle(): string {
+    const name = getTransactionName(this.props.location);
+
+    const hasTransactionName = typeof name === 'string' && String(name).trim().length > 0;
+
+    if (hasTransactionName) {
+      return [String(name).trim(), t('Performance')].join(' - ');
+    }
+
+    return [t('Vital Detail'), t('Performance')].join(' - ');
+  }
+
+  render() {
+    const {organization, location, router} = this.props;
+    const {eventView} = this.state;
+    if (!eventView) {
+      browserHistory.replace({
+        pathname: `/organizations/${organization.slug}/performance/`,
+        query: {
+          ...location.query,
+        },
+      });
+      return null;
+    }
+
+    const vitalNameQuery = decodeScalar(location.query.vitalName);
+    const vitalName =
+      Object.values(WebVital).indexOf(vitalNameQuery as WebVital) === -1
+        ? undefined
+        : (vitalNameQuery as WebVital);
+
+    return (
+      <SentryDocumentTitle title={this.getDocumentTitle()} objSlug={organization.slug}>
+        <GlobalSelectionHeader>
+          <StyledPageContent>
+            <LightWeightNoProjectMessage organization={organization}>
+              <VitalDetailContent
+                location={location}
+                organization={organization}
+                eventView={eventView}
+                router={router}
+                vitalName={vitalName || WebVital.LCP}
+              />
+            </LightWeightNoProjectMessage>
+          </StyledPageContent>
+        </GlobalSelectionHeader>
+      </SentryDocumentTitle>
+    );
+  }
+}
+
+const StyledPageContent = styled(PageContent)`
+  padding: 0;
+`;
+
+export default withApi(withGlobalSelection(withProjects(withOrganization(VitalDetail))));

+ 419 - 0
src/sentry/static/sentry/app/views/performance/vitalDetail/table.tsx

@@ -0,0 +1,419 @@
+import React from 'react';
+import * as ReactRouter from 'react-router';
+import styled from '@emotion/styled';
+import {Location, LocationDescriptorObject} from 'history';
+
+import GridEditable, {COL_WIDTH_UNDEFINED, GridColumn} from 'app/components/gridEditable';
+import SortLink from 'app/components/gridEditable/sortLink';
+import Link from 'app/components/links/link';
+import Pagination from 'app/components/pagination';
+import Tag from 'app/components/tag';
+import {IconStar, IconUser} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization, Project} from 'app/types';
+import {trackAnalyticsEvent} from 'app/utils/analytics';
+import DiscoverQuery, {TableData, TableDataRow} from 'app/utils/discover/discoverQuery';
+import EventView, {EventData, isFieldSortable} from 'app/utils/discover/eventView';
+import {getFieldRenderer} from 'app/utils/discover/fieldRenderers';
+import {getAggregateAlias, Sort, WebVital} from 'app/utils/discover/fields';
+import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
+import CellAction, {Actions, updateQuery} from 'app/views/eventsV2/table/cellAction';
+import HeaderCell from 'app/views/eventsV2/table/headerCell';
+import {TableColumn} from 'app/views/eventsV2/table/types';
+
+import {DisplayModes} from '../transactionSummary/charts';
+import {
+  TransactionFilterOptions,
+  transactionSummaryRouteWithQuery,
+} from '../transactionSummary/utils';
+
+import {
+  getVitalDetailTableStatusFunction,
+  vitalAbbreviations,
+  vitalNameFromLocation,
+} from './utils';
+
+const COLUMN_TITLES = ['Transaction', 'Project', 'Unique Users', 'Count'];
+
+const getTableColumnTitle = (index: number, vitalName: WebVital) => {
+  const abbrev = vitalAbbreviations[vitalName];
+  const titles = [
+    ...COLUMN_TITLES,
+    `${abbrev}(p50)`,
+    `${abbrev}(p75)`,
+    `${abbrev}(p95)`,
+    `${abbrev}(Status)`,
+  ];
+  return titles[index];
+};
+
+export function getProjectID(
+  eventData: EventData,
+  projects: Project[]
+): string | undefined {
+  const projectSlug = (eventData?.project as string) || undefined;
+
+  if (typeof projectSlug === undefined) {
+    return undefined;
+  }
+
+  const project = projects.find(currentProject => currentProject.slug === projectSlug);
+
+  if (!project) {
+    return undefined;
+  }
+
+  return project.id;
+}
+
+type Props = {
+  eventView: EventView;
+  organization: Organization;
+  location: Location;
+  setError: (msg: string | undefined) => void;
+  summaryConditions: string;
+
+  projects: Project[];
+};
+
+type State = {
+  widths: number[];
+};
+
+class Table extends React.Component<Props, State> {
+  state = {
+    widths: [],
+  };
+
+  handleCellAction = (column: TableColumn<keyof TableDataRow>) => {
+    return (action: Actions, value: React.ReactText) => {
+      const {eventView, location, organization} = this.props;
+
+      trackAnalyticsEvent({
+        eventKey: 'performance_views.overview.cellaction',
+        eventName: 'Performance Views: Cell Action Clicked',
+        organization_id: parseInt(organization.id, 10),
+        action,
+      });
+
+      const searchConditions = tokenizeSearch(eventView.query);
+
+      // remove any event.type queries since it is implied to apply to only transactions
+      searchConditions.removeTag('event.type');
+
+      updateQuery(searchConditions, action, column.name, value);
+
+      ReactRouter.browserHistory.push({
+        pathname: location.pathname,
+        query: {
+          ...location.query,
+          cursor: undefined,
+          query: stringifyQueryObject(searchConditions),
+        },
+      });
+    };
+  };
+
+  renderBodyCell(
+    tableData: TableData | null,
+    column: TableColumn<keyof TableDataRow>,
+    dataRow: TableDataRow,
+    vitalName: WebVital
+  ): React.ReactNode {
+    const {eventView, organization, projects, location, summaryConditions} = this.props;
+
+    if (!tableData || !tableData.meta) {
+      return dataRow[column.key];
+    }
+    const tableMeta = tableData.meta;
+
+    const field = String(column.key);
+
+    if (field === getVitalDetailTableStatusFunction(vitalName)) {
+      if (dataRow[getAggregateAlias(field)]) {
+        return (
+          <UniqueTagCell>
+            <StyledTag>{t('Fail')}</StyledTag>
+          </UniqueTagCell>
+        );
+      } else {
+        return (
+          <UniqueTagCell>
+            <Tag>{t('Pass')}</Tag>
+          </UniqueTagCell>
+        );
+      }
+    }
+
+    const fieldRenderer = getFieldRenderer(field, tableMeta);
+    const rendered = fieldRenderer(dataRow, {organization, location});
+
+    const allowActions = [
+      Actions.ADD,
+      Actions.EXCLUDE,
+      Actions.SHOW_GREATER_THAN,
+      Actions.SHOW_LESS_THAN,
+    ];
+
+    if (field === 'count_unique(user)') {
+      return (
+        <UniqueUserCell>
+          {rendered}
+          <StyledUserIcon size="20" />
+        </UniqueUserCell>
+      );
+    }
+
+    if (field === 'transaction') {
+      const projectID = getProjectID(dataRow, projects);
+      const summaryView = eventView.clone();
+      const conditions = tokenizeSearch(summaryConditions);
+      conditions.addTagValues('has', [`${vitalName}`]);
+      summaryView.query = stringifyQueryObject(conditions);
+
+      const target = transactionSummaryRouteWithQuery({
+        orgSlug: organization.slug,
+        transaction: String(dataRow.transaction) || '',
+        query: summaryView.generateQueryStringObject(),
+        projectID,
+        showTransactions: TransactionFilterOptions.RECENT,
+        display: DisplayModes.VITALS,
+      });
+
+      return (
+        <CellAction
+          column={column}
+          dataRow={dataRow}
+          handleCellAction={this.handleCellAction(column)}
+          allowActions={allowActions}
+        >
+          <Link to={target} onClick={this.handleSummaryClick}>
+            {rendered}
+          </Link>
+        </CellAction>
+      );
+    }
+
+    if (field.startsWith('key_transaction') || field.startsWith('user_misery')) {
+      return rendered;
+    }
+
+    return (
+      <CellAction
+        column={column}
+        dataRow={dataRow}
+        handleCellAction={this.handleCellAction(column)}
+        allowActions={allowActions}
+      >
+        {rendered}
+      </CellAction>
+    );
+  }
+
+  renderBodyCellWithData = (tableData: TableData | null, vitalName: WebVital) => {
+    return (
+      column: TableColumn<keyof TableDataRow>,
+      dataRow: TableDataRow
+    ): React.ReactNode => this.renderBodyCell(tableData, column, dataRow, vitalName);
+  };
+
+  renderHeadCell(
+    tableMeta: TableData['meta'],
+    column: TableColumn<keyof TableDataRow>,
+    title: React.ReactNode
+  ): React.ReactNode {
+    const {eventView, location} = this.props;
+
+    return (
+      <HeaderCell column={column} tableMeta={tableMeta}>
+        {({align}) => {
+          const field = {field: column.name, width: column.width};
+
+          function generateSortLink(): LocationDescriptorObject | undefined {
+            if (!tableMeta) {
+              return undefined;
+            }
+
+            const nextEventView = eventView.sortOnField(field, tableMeta);
+            const queryStringObject = nextEventView.generateQueryStringObject();
+
+            return {
+              ...location,
+              query: {...location.query, sort: queryStringObject.sort},
+            };
+          }
+          const currentSort = eventView.sortForField(field, tableMeta);
+          const canSort = isFieldSortable(field, tableMeta);
+
+          return (
+            <SortLink
+              align={align}
+              title={title || field.field}
+              direction={currentSort ? currentSort.kind : undefined}
+              canSort={canSort}
+              generateSortLink={generateSortLink}
+            />
+          );
+        }}
+      </HeaderCell>
+    );
+  }
+
+  renderHeadCellWithMeta = (tableMeta: TableData['meta'], vitalName: WebVital) => {
+    return (column: TableColumn<keyof TableDataRow>, index: number): React.ReactNode =>
+      this.renderHeadCell(tableMeta, column, getTableColumnTitle(index, vitalName));
+  };
+
+  renderPrependCellWithData = (tableData: TableData | null, vitalName: WebVital) => {
+    const {eventView} = this.props;
+    const keyTransactionColumn = eventView
+      .getColumns()
+      .find((col: TableColumn<React.ReactText>) => col.name === 'key_transaction');
+    return (isHeader: boolean, dataRow?: any) => {
+      if (!keyTransactionColumn) {
+        return [];
+      }
+
+      if (isHeader) {
+        const star = (
+          <IconStar
+            key="keyTransaction"
+            color="yellow300"
+            isSolid
+            data-test-id="key-transaction-header"
+          />
+        );
+        return [this.renderHeadCell(tableData?.meta, keyTransactionColumn, star)];
+      } else {
+        return [this.renderBodyCell(tableData, keyTransactionColumn, dataRow, vitalName)];
+      }
+    };
+  };
+
+  handleSummaryClick = () => {
+    const {organization} = this.props;
+    trackAnalyticsEvent({
+      eventKey: 'performance_views.overview.navigate.summary',
+      eventName: 'Performance Views: Overview view summary',
+      organization_id: parseInt(organization.id, 10),
+    });
+  };
+
+  handleResizeColumn = (columnIndex: number, nextColumn: GridColumn) => {
+    const widths: number[] = [...this.state.widths];
+    widths[columnIndex] = nextColumn.width
+      ? Number(nextColumn.width)
+      : COL_WIDTH_UNDEFINED;
+    this.setState({widths});
+  };
+
+  getSortedEventView(vitalName: WebVital) {
+    const {eventView} = this.props;
+
+    const aggregateField = getAggregateAlias(
+      getVitalDetailTableStatusFunction(vitalName)
+    );
+    const isSortingByStatus = eventView.sorts.some(sort =>
+      sort.field.includes(aggregateField)
+    );
+
+    const additionalSorts: Sort[] = isSortingByStatus
+      ? []
+      : [
+          {
+            field: aggregateField,
+            kind: 'desc',
+          },
+        ];
+
+    return eventView.withSorts([...additionalSorts, ...eventView.sorts]);
+  }
+
+  render() {
+    const {eventView, organization, location} = this.props;
+    const {widths} = this.state;
+
+    const fakeColumnView = eventView.clone();
+    fakeColumnView.fields = [...eventView.fields];
+    const columnOrder = fakeColumnView
+      .getColumns()
+      // remove key_transactions from the column order as we'll be rendering it
+      // via a prepended column
+      .filter((col: TableColumn<React.ReactText>) => col.name !== 'key_transaction')
+      .map((col: TableColumn<React.ReactText>, i: number) => {
+        if (typeof widths[i] === 'number') {
+          return {...col, width: widths[i]};
+        }
+        return col;
+      });
+
+    const vitalName = vitalNameFromLocation(location);
+    const sortedEventView = this.getSortedEventView(vitalName);
+    const columnSortBy = sortedEventView.getSorts();
+
+    return (
+      <div>
+        <DiscoverQuery
+          eventView={sortedEventView}
+          orgSlug={organization.slug}
+          location={location}
+          limit={10}
+        >
+          {({pageLinks, isLoading, tableData}) => (
+            <React.Fragment>
+              <GridEditable
+                isLoading={isLoading}
+                data={tableData ? tableData.data : []}
+                columnOrder={columnOrder}
+                columnSortBy={columnSortBy}
+                grid={{
+                  onResizeColumn: this.handleResizeColumn,
+                  renderHeadCell: this.renderHeadCellWithMeta(
+                    tableData?.meta,
+                    vitalName
+                  ) as any,
+                  renderBodyCell: this.renderBodyCellWithData(
+                    tableData,
+                    vitalName
+                  ) as any,
+                  renderPrependColumns: this.renderPrependCellWithData(
+                    tableData,
+                    vitalName
+                  ) as any,
+                }}
+                location={location}
+              />
+              <Pagination pageLinks={pageLinks} />
+            </React.Fragment>
+          )}
+        </DiscoverQuery>
+      </div>
+    );
+  }
+}
+
+const UniqueUserCell = styled('span')`
+  display: flex;
+  align-items: center;
+`;
+
+const UniqueTagCell = styled('div')`
+  text-align: right;
+`;
+
+const StyledTag = styled(Tag)`
+  div {
+    background-color: ${p => p.theme.red300};
+  }
+  span {
+    color: ${p => p.theme.white};
+  }
+`;
+
+const StyledUserIcon = styled(IconUser)`
+  margin-left: ${space(1)};
+  color: ${p => p.theme.gray400};
+`;
+
+export default Table;

+ 116 - 0
src/sentry/static/sentry/app/views/performance/vitalDetail/utils.tsx

@@ -0,0 +1,116 @@
+import {Location, Query} from 'history';
+
+import {Series} from 'app/types/echarts';
+import {getAggregateAlias, WebVital} from 'app/utils/discover/fields';
+import {decodeScalar} from 'app/utils/queryString';
+
+import {WEB_VITAL_DETAILS} from '../transactionVitals/constants';
+
+export function generateVitalDetailRoute({orgSlug}: {orgSlug: string}): string {
+  return `/organizations/${orgSlug}/performance/vitaldetail/`;
+}
+
+export const vitalsThresholdFields = {
+  [WebVital.FP]: 'count_at_least(measurements.fp, 3000)',
+  [WebVital.FCP]: 'count_at_least(measurements.fcp, 3000)',
+  [WebVital.LCP]: 'count_at_least(measurements.lcp, 4000)',
+  [WebVital.FID]: 'count_at_least(measurements.fid, 300)',
+  [WebVital.CLS]: 'count_at_least(measurements.cls, 0.25)',
+};
+export const vitalsBaseFields = {
+  [WebVital.FP]: 'count_at_least(measurements.fp, 0)',
+  [WebVital.FCP]: 'count_at_least(measurements.fcp, 0)',
+  [WebVital.LCP]: 'count_at_least(measurements.lcp, 0)',
+  [WebVital.FID]: 'count_at_least(measurements.fid, 0)',
+  [WebVital.CLS]: 'count_at_least(measurements.cls, 0)',
+};
+
+export function vitalDetailRouteWithQuery({
+  orgSlug,
+  vitalName,
+  projectID,
+  query,
+}: {
+  orgSlug: string;
+  vitalName: string;
+  query: Query;
+  projectID?: string | string[];
+}) {
+  const pathname = generateVitalDetailRoute({
+    orgSlug,
+  });
+
+  return {
+    pathname,
+    query: {
+      vitalName,
+      project: projectID,
+      environment: query.environment,
+      statsPeriod: query.statsPeriod,
+      start: query.start,
+      end: query.end,
+      query: query.query,
+    },
+  };
+}
+
+export function vitalNameFromLocation(location: Location): WebVital {
+  const _vitalName = decodeScalar(location.query.vitalName);
+
+  const vitalName = Object.values(WebVital).find(v => v === _vitalName);
+
+  if (vitalName) {
+    return vitalName;
+  } else {
+    return WebVital.LCP;
+  }
+}
+
+export function getVitalDetailTableStatusFunction(vitalName: WebVital): string {
+  const vitalThreshold = WEB_VITAL_DETAILS[vitalName].failureThreshold;
+  const statusFunction = `compare_numeric_aggregate(${getAggregateAlias(
+    `p75(${vitalName})`
+  )},greater,${vitalThreshold})`;
+  return statusFunction;
+}
+
+export const vitalMap: Partial<Record<WebVital, string>> = {
+  [WebVital.FP]: 'First Paint',
+  [WebVital.FCP]: 'First Contentful Paint',
+  [WebVital.CLS]: 'Cumulative Layout Shift',
+  [WebVital.FID]: 'First Input Delay',
+  [WebVital.LCP]: 'Largest Contentful Paint',
+};
+
+export const vitalChartTitleMap = vitalMap;
+
+export const vitalDescription: Partial<Record<WebVital, string>> = {
+  [WebVital.FP]:
+    'First Paint (FP) measures the amount of time the first pixel takes to appear in the viewport, rendering any visual change from what was previously displayed. This may be in the subtle form of a background color, canvas, or image.',
+  [WebVital.FCP]:
+    'First Contentful Paint (FCP) measures the amount of time the first content takes to render in the viewport. Like FP, this could also show up in any form from the document object model (DOM), such as images, SVGs, or text blocks.',
+  [WebVital.CLS]:
+    'Cumulative Layout Shift (CLS) is the sum of individual layout shift scores for every unexpected element shift during the rendering process. Imagine navigating to an article and trying to click a link before the page finishes loading. Before your cursor even gets there, the link may have shifted down due to an image rendering. Rather than using duration for this Web Vital, the CLS score represents the degree of disruptive and visually unstable shifts.',
+  [WebVital.FID]:
+    'First Input Delay measures the response time when the user tries to interact with the viewport. Actions maybe include clicking a button, link or other custom Javascript controller. It is key in helping the user determine if a page is usable or not.',
+  [WebVital.LCP]:
+    'Largest Contentful Paint (LCP) measures the render time for the largest content to appear in the viewport. This may be in any form from the document object model (DOM), such as images, SVGs, or text blocks. It’s the largest pixel area in the viewport, thus most visually defining. LCP helps developers understand how long it takes to see the main content on the page.',
+};
+
+export const vitalAbbreviations: Partial<Record<WebVital, string>> = {
+  [WebVital.FP]: 'FP',
+  [WebVital.FCP]: 'FCP',
+  [WebVital.CLS]: 'CLS',
+  [WebVital.FID]: 'FID',
+  [WebVital.LCP]: 'LCP',
+};
+
+export function getMaxOfSeries(series: Series[]) {
+  let max = -Infinity;
+  for (const {data} of series) {
+    for (const point of data) {
+      max = Math.max(max, point.value);
+    }
+  }
+  return max;
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов