Browse Source

feat(starfish-webvitals-tag-details): Adding tag detail panel. (#58868)

Details for task:
[link](https://www.notion.so/sentry/Web-Vitals-Module-TODO-List-0804a4f318454ac2b98611e0d94a7c7d?pvs=4#d9d8b63a53b94a18ab5711271a5ba177)
This pr adds partial functionality for tags detail panel under wb vitals
overview page.

- Adding the header, progress ring and the samples table.
- Added duration chart with samples plotted
- Fixed event id links from `pageOverviewWebVitalsDetailPanel.tsx`
![Screenshot 2023-10-27 at 10 19 11
AM](https://github.com/getsentry/sentry/assets/60121741/797aa863-cbff-464a-b19d-79fa6c01a2e2)

---------

Co-authored-by: Edward Gou <edward.gou@sentry.io>
Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdkhan14 1 year ago
parent
commit
f87300269f

+ 4 - 4
static/app/views/performance/browser/webVitals/components/pageOverviewFeaturedTagsList.tsx

@@ -3,11 +3,13 @@ import styled from '@emotion/styled';
 import {Button} from 'sentry/components/button';
 import {COUNTRY_CODE_TO_NAME_MAP} from 'sentry/data/countryCodesMap';
 import {space} from 'sentry/styles/space';
+import {Tag} from 'sentry/types';
 import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
 import {calculatePerformanceScore} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
 import {useSlowestTagValuesQuery} from 'sentry/views/performance/browser/webVitals/utils/useSlowestTagValuesQuery';
 
 type Props = {
+  onClick: (tag: Tag) => void;
   tag: string;
   transaction: string;
   title?: string;
@@ -23,7 +25,7 @@ function toReadableValue(tag, tagValue) {
   return tagValue;
 }
 
-export function PageOverviewFeaturedTagsList({transaction, tag, title}: Props) {
+export function PageOverviewFeaturedTagsList({transaction, tag, title, onClick}: Props) {
   const {data} = useSlowestTagValuesQuery({transaction, tag, limit: LIMIT});
   const tagValues = data?.data ?? [];
   return (
@@ -43,9 +45,7 @@ export function PageOverviewFeaturedTagsList({transaction, tag, title}: Props) {
               <TagValue>
                 <TagButton
                   priority="link"
-                  onClick={() => {
-                    // TODO: need to pass in handler here to open detail panel
-                  }}
+                  onClick={() => onClick({key: tag, name: row[tag].toString()})}
                 >
                   {toReadableValue(tag, row[tag])}
                 </TagButton>

+ 95 - 0
static/app/views/performance/browser/webVitals/components/webVitalDescription.tsx

@@ -1,17 +1,24 @@
 import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
+import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import ProgressRing from 'sentry/components/progressRing';
+import {Tooltip} from 'sentry/components/tooltip';
 import {IconCheckmark} from 'sentry/icons/iconCheckmark';
 import {IconClose} from 'sentry/icons/iconClose';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {Tag} from 'sentry/types';
 import {WebVital} from 'sentry/utils/fields';
 import {Browser} from 'sentry/utils/performance/vitals/constants';
 import {getScoreColor} from 'sentry/views/performance/browser/webVitals/utils/getScoreColor';
 import {WebVitals} from 'sentry/views/performance/browser/webVitals/utils/types';
 import {vitalSupportedBrowsers} from 'sentry/views/performance/vitalDetail/utils';
 
+import OverallProgressRing from '../overallProgressRing';
+import {ProjectScore} from '../utils/calculatePerformanceScore';
+
 type Props = {
   score: number;
   value: string;
@@ -44,6 +51,13 @@ const VITAL_DESCRIPTIONS: Partial<Record<WebVital, string>> = {
   ),
 };
 
+type WebVitalDetailHeaderProps = {
+  isProjectScoreCalculated: boolean;
+  projectScore: ProjectScore;
+  tag: Tag;
+  value: string;
+};
+
 export function WebVitalDetailHeader({score, value, webVital}: Props) {
   const theme = useTheme();
   return (
@@ -69,6 +83,51 @@ export function WebVitalDetailHeader({score, value, webVital}: Props) {
   );
 }
 
+export function WebVitalTagsDetailHeader({
+  projectScore,
+  value,
+  tag,
+  isProjectScoreCalculated,
+}: WebVitalDetailHeaderProps) {
+  const theme = useTheme();
+  const ringSegmentColors = theme.charts.getColorPalette(3);
+  const ringBackgroundColors = ringSegmentColors.map(color => `${color}50`);
+  const title = `${tag.key}:${tag.name}`;
+  return (
+    <StyledHeader>
+      <span>
+        <TitleWrapper>
+          <WebVitalName>{title}</WebVitalName>
+          <StyledCopyToClipboardButton borderless text={title} size="sm" iconSize="sm" />
+        </TitleWrapper>
+        <Value>{value}</Value>
+      </span>
+      {isProjectScoreCalculated && projectScore ? (
+        <ProgressRingWrapper>
+          <OverallProgressRing
+            hideWebVitalLabels
+            projectScore={projectScore}
+            text={
+              <ProgressRingTextContainer>
+                <ProgressRingText>{projectScore.totalScore}</ProgressRingText>
+                <StyledTooltip title={title} showOnlyOnOverflow skipWrapper>
+                  <ProgressRingTabSubText>{title.toUpperCase()}</ProgressRingTabSubText>
+                </StyledTooltip>
+              </ProgressRingTextContainer>
+            }
+            width={220}
+            height={180}
+            ringBackgroundColors={ringBackgroundColors}
+            ringSegmentColors={ringSegmentColors}
+          />
+        </ProgressRingWrapper>
+      ) : (
+        <StyledLoadingIndicator size={50} />
+      )}
+    </StyledHeader>
+  );
+}
+
 export function WebVitalDescription({score, value, webVital}: Props) {
   const description: string = VITAL_DESCRIPTIONS[WebVital[webVital.toUpperCase()]];
   return (
@@ -127,6 +186,8 @@ const Value = styled('h2')`
 const WebVitalName = styled('h4')`
   margin-bottom: ${space(1)};
   margin-top: 40px;
+  max-width: 400px;
+  ${p => p.theme.overflowEllipsis}
 `;
 
 const ProgressRingTextContainer = styled('div')`
@@ -145,3 +206,37 @@ const ProgressRingSubText = styled('h5')`
   font-size: ${p => p.theme.fontSizeSmall};
   color: ${p => p.theme.textColor};
 `;
+
+const ProgressRingTabSubText = styled(ProgressRingSubText)`
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  max-width: 70px;
+  text-transform: capitalize;
+  ${p => p.theme.overflowEllipsis}
+`;
+
+const StyledHeader = styled(Header)`
+  align-items: end;
+`;
+
+const StyledTooltip = styled(Tooltip)`
+  ${p => p.theme.overflowEllipsis}
+`;
+
+const TitleWrapper = styled('div')`
+  display: flex;
+  align-items: baseline;
+`;
+
+const StyledCopyToClipboardButton = styled(CopyToClipboardButton)`
+  padding-left: ${space(0.25)};
+`;
+
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  margin: 20px 65px;
+`;
+
+const ProgressRingWrapper = styled('span')`
+  position: absolute;
+  right: 0;
+  top: 15px;
+`;

+ 198 - 0
static/app/views/performance/browser/webVitals/overallProgressRing.tsx

@@ -0,0 +1,198 @@
+import {Fragment, useRef, useState} from 'react';
+import {css, useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import useMouseTracking from 'sentry/utils/replays/hooks/useMouseTracking';
+import PerformanceScoreRing from 'sentry/views/performance/browser/webVitals/components/performanceScoreRing';
+import {
+  PERFORMANCE_SCORE_WEIGHTS,
+  ProjectScore,
+} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
+import {WebVitals} from 'sentry/views/performance/browser/webVitals/utils/types';
+
+import {ORDER} from './performanceScoreChart';
+
+const {
+  lcp: LCP_WEIGHT,
+  fcp: FCP_WEIGHT,
+  fid: FID_WEIGHT,
+  cls: CLS_WEIGHT,
+  ttfb: TTFB_WEIGHT,
+} = PERFORMANCE_SCORE_WEIGHTS;
+
+type Props = {
+  height: number;
+  projectScore: ProjectScore;
+  ringBackgroundColors: string[];
+  ringSegmentColors: string[];
+  text: React.ReactNode;
+  width: number;
+  hideWebVitalLabels?: boolean;
+};
+
+function OverallProgressRing({
+  projectScore,
+  ringBackgroundColors,
+  ringSegmentColors,
+  width,
+  height,
+  text,
+  hideWebVitalLabels = false,
+}: Props) {
+  const theme = useTheme();
+  const [mousePosition, setMousePosition] = useState({x: 0, y: 0});
+  const elem = useRef<HTMLDivElement>(null);
+  const mouseTrackingProps = useMouseTracking({
+    elem,
+    onPositionChange: args => {
+      if (args) {
+        const {left, top} = args;
+        setMousePosition({x: left, y: top});
+      }
+    },
+  });
+  const [webVitalTooltip, setWebVitalTooltip] = useState<WebVitals | null>(null);
+
+  return (
+    <ProgressRingContainer ref={elem} {...mouseTrackingProps}>
+      {webVitalTooltip && (
+        <PerformanceScoreRingTooltip x={mousePosition.x} y={mousePosition.y}>
+          <TooltipRow>
+            <span>
+              <Dot color={ringBackgroundColors[ORDER.indexOf(webVitalTooltip)]} />
+              {webVitalTooltip.toUpperCase()} {t('Opportunity')}
+            </span>
+            <TooltipValue>{100 - projectScore[`${webVitalTooltip}Score`]}</TooltipValue>
+          </TooltipRow>
+          <TooltipRow>
+            <span>
+              <Dot color={ringSegmentColors[ORDER.indexOf(webVitalTooltip)]} />
+              {webVitalTooltip.toUpperCase()} {t('Score')}
+            </span>
+            <TooltipValue>{projectScore[`${webVitalTooltip}Score`]}</TooltipValue>
+          </TooltipRow>
+          <PerformanceScoreRingTooltipArrow />
+        </PerformanceScoreRingTooltip>
+      )}
+      <svg height={height} width={width}>
+        {!hideWebVitalLabels && (
+          <Fragment>
+            <ProgressRingText x={160} y={30}>
+              LCP
+            </ProgressRingText>
+            <ProgressRingText x={175} y={140}>
+              FCP
+            </ProgressRingText>
+            <ProgressRingText x={20} y={140}>
+              FID
+            </ProgressRingText>
+            <ProgressRingText x={10} y={60}>
+              CLS
+            </ProgressRingText>
+            <ProgressRingText x={50} y={20}>
+              TTFB
+            </ProgressRingText>
+          </Fragment>
+        )}
+        <PerformanceScoreRing
+          values={[
+            projectScore.lcpScore * LCP_WEIGHT * 0.01,
+            projectScore.fcpScore * FCP_WEIGHT * 0.01,
+            projectScore.fidScore * FID_WEIGHT * 0.01,
+            projectScore.clsScore * CLS_WEIGHT * 0.01,
+            projectScore.ttfbScore * TTFB_WEIGHT * 0.01,
+          ]}
+          maxValues={[LCP_WEIGHT, FCP_WEIGHT, FID_WEIGHT, CLS_WEIGHT, TTFB_WEIGHT]}
+          text={text}
+          size={140}
+          barWidth={14}
+          textCss={() => css`
+            font-size: 32px;
+            font-weight: bold;
+            color: ${theme.textColor};
+          `}
+          segmentColors={ringSegmentColors}
+          backgroundColors={ringBackgroundColors}
+          x={40}
+          y={20}
+          onHoverActions={[
+            () => setWebVitalTooltip('lcp'),
+            () => setWebVitalTooltip('fcp'),
+            () => setWebVitalTooltip('fid'),
+            () => setWebVitalTooltip('cls'),
+            () => setWebVitalTooltip('ttfb'),
+          ]}
+          onUnhover={() => setWebVitalTooltip(null)}
+        />
+      </svg>
+    </ProgressRingContainer>
+  );
+}
+
+const ProgressRingContainer = styled('div')``;
+
+const ProgressRingText = styled('text')`
+  font-size: ${p => p.theme.fontSizeMedium};
+  fill: ${p => p.theme.textColor};
+  font-weight: bold;
+`;
+
+// Hover element on mouse
+const PerformanceScoreRingTooltip = styled('div')<{x: number; y: number}>`
+  position: absolute;
+  background: ${p => p.theme.backgroundElevated};
+  border-radius: ${p => p.theme.borderRadius};
+  border: 1px solid ${p => p.theme.gray200};
+  transform: translate3d(${p => p.x - 100}px, ${p => p.y - 74}px, 0px);
+  padding: ${space(1)} ${space(2)};
+  width: 200px;
+  height: 60px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+`;
+
+const PerformanceScoreRingTooltipArrow = styled('div')`
+  top: 100%;
+  left: 50%;
+  position: absolute;
+  pointer-events: none;
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-top: 8px solid ${p => p.theme.backgroundElevated};
+  margin-left: -8px;
+  &:before {
+    border-left: 8px solid transparent;
+    border-right: 8px solid transparent;
+    border-top: 8px solid ${p => p.theme.translucentBorder};
+    content: '';
+    display: block;
+    position: absolute;
+    top: -7px;
+    left: -8px;
+    z-index: -1;
+  }
+`;
+
+const Dot = styled('span')<{color: string}>`
+  display: inline-block;
+  margin-right: ${space(0.5)};
+  border-radius: 10px;
+  width: 10px;
+  height: 10px;
+  background-color: ${p => p.color};
+`;
+
+const TooltipRow = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const TooltipValue = styled('span')`
+  color: ${p => p.theme.gray300};
+`;
+
+export default OverallProgressRing;

+ 339 - 0
static/app/views/performance/browser/webVitals/pageOverWebVitalsTagDetailPanel.tsx

@@ -0,0 +1,339 @@
+import {CSSProperties, Fragment, useCallback, useState} from 'react';
+import {browserHistory, Link} from 'react-router';
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+
+import {Button} from 'sentry/components/button';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnHeader,
+  GridColumnOrder,
+  GridColumnSortBy,
+} from 'sentry/components/gridEditable';
+import {t} from 'sentry/locale';
+import {Tag} from 'sentry/types';
+import {EChartClickHandler, EChartHighlightHandler, Series} from 'sentry/types/echarts';
+import {defined} from 'sentry/utils';
+import {generateEventSlug} from 'sentry/utils/discover/urls';
+import {getShortEventId} from 'sentry/utils/events';
+import {getDuration} from 'sentry/utils/formatters';
+import {
+  PageErrorAlert,
+  PageErrorProvider,
+} from 'sentry/utils/performance/contexts/pageError';
+import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
+import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {useRoutes} from 'sentry/utils/useRoutes';
+import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
+import {WebVitalTagsDetailHeader} from 'sentry/views/performance/browser/webVitals/components/webVitalDescription';
+import {calculatePerformanceScore} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
+import {TransactionSampleRowWithScore} from 'sentry/views/performance/browser/webVitals/utils/types';
+import {useProjectWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery';
+import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useTransactionSamplesWebVitalsQuery';
+import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
+import {AVG_COLOR} from 'sentry/views/starfish/colours';
+import Chart from 'sentry/views/starfish/components/chart';
+import ChartPanel from 'sentry/views/starfish/components/chartPanel';
+import DetailPanel from 'sentry/views/starfish/components/detailPanel';
+
+import {PERFORMANCE_SCORE_COLORS} from './utils/performanceScoreColors';
+import {scoreToStatus} from './utils/scoreToStatus';
+import {useProjectWebVitalsTimeseriesQuery} from './utils/useProjectWebVitalsTimeseriesQuery';
+
+type Column = GridColumnHeader;
+
+const columnOrder: GridColumnOrder[] = [
+  {key: 'id', width: COL_WIDTH_UNDEFINED, name: 'Event ID'},
+  {key: 'browser', width: COL_WIDTH_UNDEFINED, name: 'Browser'},
+  {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
+  {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
+  {key: 'transaction.duration', width: COL_WIDTH_UNDEFINED, name: 'Duration'},
+  {key: 'score', width: COL_WIDTH_UNDEFINED, name: 'Performance Score'},
+];
+
+const sort: GridColumnSortBy<keyof TransactionSampleRowWithScore> = {
+  key: 'score',
+  order: 'desc',
+};
+
+export function PageOverviewWebVitalsTagDetailPanel({
+  tag,
+  onClose,
+}: {
+  onClose: () => void;
+  tag?: Tag;
+}) {
+  const location = useLocation();
+  const theme = useTheme();
+  const organization = useOrganization();
+  const pageFilters = usePageFilters();
+  const routes = useRoutes();
+  const [highlightedSampleId, setHighlightedSampleId] = useState<string | undefined>(
+    undefined
+  );
+
+  const replayLinkGenerator = generateReplayLink(routes);
+
+  const transaction = location.query.transaction
+    ? Array.isArray(location.query.transaction)
+      ? location.query.transaction[0]
+      : location.query.transaction
+    : undefined;
+
+  const {data: projectData, isLoading: projectDataLoading} = useProjectWebVitalsQuery({
+    transaction,
+    tag,
+  });
+
+  const {data: chartSeriesData, isLoading: chartSeriesDataIsLoading} =
+    useProjectWebVitalsTimeseriesQuery({transaction, tag});
+
+  const projectScore = calculatePerformanceScore({
+    lcp: projectData?.data[0]['p75(measurements.lcp)'] as number,
+    fcp: projectData?.data[0]['p75(measurements.fcp)'] as number,
+    cls: projectData?.data[0]['p75(measurements.cls)'] as number,
+    ttfb: projectData?.data[0]['p75(measurements.ttfb)'] as number,
+    fid: projectData?.data[0]['p75(measurements.fid)'] as number,
+  });
+
+  const {
+    data: samplesTableData,
+    isLoading: isSamplesTabledDataLoading,
+    isRefetching,
+    refetch,
+  } = useTransactionSamplesWebVitalsQuery({
+    limit: 3,
+    transaction: transaction ?? '',
+    query: tag ? `${tag.key}:${tag.name}` : undefined,
+    enabled: Boolean(tag),
+  });
+
+  // Sample Table props
+  const tableData: TransactionSampleRowWithScore[] = samplesTableData.sort(
+    (a, b) => a.score - b.score
+  );
+
+  const renderHeadCell = (col: Column) => {
+    if (col.key === 'id' || col.key === 'browser') {
+      return <NoOverflow>{col.name}</NoOverflow>;
+    }
+    return <AlignCenter>{col.name}</AlignCenter>;
+  };
+
+  const getFormattedDuration = (value: number | null) => {
+    if (value === null) {
+      return null;
+    }
+    if (value < 1000) {
+      return getDuration(value / 1000, 0, true);
+    }
+    return getDuration(value / 1000, 2, true);
+  };
+
+  const renderBodyCell = (col: Column, row: TransactionSampleRowWithScore) => {
+    const shouldHighlight = row.id === highlightedSampleId;
+
+    const commonProps = {
+      style: (shouldHighlight ? {fontWeight: 'bold'} : {}) satisfies CSSProperties,
+      onMouseEnter: () => setHighlightedSampleId(row.id),
+      onMouseLeave: () => setHighlightedSampleId(undefined),
+    };
+
+    const {key} = col;
+    if (key === 'score') {
+      return (
+        <AlignCenter {...commonProps}>
+          <PerformanceBadge score={row.score} />
+        </AlignCenter>
+      );
+    }
+    if (key === 'browser') {
+      return <NoOverflow {...commonProps}>{row[key]}</NoOverflow>;
+    }
+    if (key === 'id') {
+      const eventSlug = generateEventSlug({...row, project: row.projectSlug});
+      const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
+      return (
+        <NoOverflow {...commonProps}>
+          <Link to={eventTarget} onClick={onClose}>
+            {getShortEventId(row.id)}
+          </Link>
+        </NoOverflow>
+      );
+    }
+    if (key === 'replayId') {
+      const replayTarget =
+        row['transaction.duration'] !== null &&
+        replayLinkGenerator(
+          organization,
+          {
+            replayId: row.replayId,
+            id: row.id,
+            'transaction.duration': row['transaction.duration'],
+            timestamp: row.timestamp,
+          },
+          undefined
+        );
+
+      return row.replayId && replayTarget ? (
+        <AlignCenter {...commonProps}>
+          <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
+        </AlignCenter>
+      ) : (
+        <AlignCenter {...commonProps}>{' \u2014 '}</AlignCenter>
+      );
+    }
+    if (key === 'profile.id') {
+      if (!row.projectSlug || !defined(row['profile.id'])) {
+        return <AlignCenter {...commonProps}>{' \u2014 '}</AlignCenter>;
+      }
+      const target = generateProfileFlamechartRoute({
+        orgSlug: organization.slug,
+        projectSlug: row.projectSlug,
+        profileId: String(row['profile.id']),
+      });
+
+      return (
+        <NoOverflow>
+          <Link to={target} onClick={onClose}>
+            {getShortEventId(row['profile.id'])}
+          </Link>
+        </NoOverflow>
+      );
+    }
+    if (key === 'transaction.duration') {
+      return <AlignCenter {...commonProps}>{getFormattedDuration(row[key])}</AlignCenter>;
+    }
+    return <AlignCenter {...commonProps}>{row[key]}</AlignCenter>;
+  };
+
+  // Chart props
+  const samplesScatterPlotSeries: Series[] = tableData.map(({timestamp, score, id}) => {
+    const color = theme[PERFORMANCE_SCORE_COLORS[scoreToStatus(score)].normal];
+    return {
+      data: [
+        {
+          name: timestamp,
+          value: score,
+        },
+      ],
+      symbol: 'roundRect',
+      color,
+      symbolSize: id === highlightedSampleId ? 16 : 12,
+      seriesName: id.substring(0, 8),
+    };
+  });
+
+  const chartSubTitle = pageFilters.selection.datetime.period
+    ? t('Last %s', pageFilters.selection.datetime.period)
+    : t('Last period');
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const debounceSetHighlightedSpanId = useCallback(
+    debounce(id => {
+      setHighlightedSampleId(id);
+    }, 10),
+    []
+  );
+
+  const handleChartHighlight: EChartHighlightHandler = e => {
+    const {seriesIndex} = e.batch[0];
+    const isSample = seriesIndex >= 1;
+
+    if (isSample) {
+      const sampleData = samplesScatterPlotSeries?.[seriesIndex - 1]?.data[0];
+      const {name: timestamp, value: score} = sampleData;
+      const sample = tableData.find(s => s.timestamp === timestamp && s.score === score);
+      if (sample) {
+        debounceSetHighlightedSpanId(sample.id);
+      }
+    }
+    if (!isSample) {
+      debounceSetHighlightedSpanId(undefined);
+    }
+  };
+
+  const handleChartClick: EChartClickHandler = e => {
+    const isSample = e?.componentSubType === 'scatter';
+    if (isSample) {
+      const [timestamp, score] = e.value as [string, number];
+      const sample = tableData.find(s => s.timestamp === timestamp && s.score === score);
+      if (sample) {
+        const eventSlug = generateEventSlug({...sample, project: sample.projectSlug});
+        const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
+        browserHistory.push(eventTarget);
+      }
+    }
+  };
+
+  const chartIsLoading =
+    chartSeriesDataIsLoading || isSamplesTabledDataLoading || isRefetching;
+
+  return (
+    <PageErrorProvider>
+      {tag && (
+        <DetailPanel detailKey={tag?.key} onClose={onClose}>
+          <Fragment>
+            <WebVitalTagsDetailHeader
+              value="TBD"
+              tag={tag}
+              projectScore={projectScore}
+              isProjectScoreCalculated={!projectDataLoading}
+            />
+            <ChartPanel title={t('Performance Score')} subtitle={chartSubTitle}>
+              <Chart
+                height={180}
+                onClick={handleChartClick}
+                onHighlight={handleChartHighlight}
+                aggregateOutputFormat="integer"
+                data={[
+                  {
+                    data: chartIsLoading ? [] : chartSeriesData.total,
+                    seriesName: 'performance score',
+                  },
+                ]}
+                loading={chartIsLoading}
+                utc={false}
+                chartColors={[AVG_COLOR, 'black']}
+                scatterPlot={
+                  isSamplesTabledDataLoading || isRefetching
+                    ? undefined
+                    : samplesScatterPlotSeries
+                }
+                isLineChart
+                definedAxisTicks={4}
+              />
+            </ChartPanel>
+            <GridEditable
+              data={tableData}
+              isLoading={isSamplesTabledDataLoading || isRefetching}
+              columnOrder={columnOrder}
+              columnSortBy={[sort]}
+              grid={{
+                renderHeadCell,
+                renderBodyCell,
+              }}
+              location={location}
+            />
+            <Button onClick={() => refetch()}>{t('Try Different Samples')}</Button>
+          </Fragment>
+          <PageErrorAlert />
+        </DetailPanel>
+      )}
+    </PageErrorProvider>
+  );
+}
+
+const NoOverflow = styled('span')`
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const AlignCenter = styled('span')`
+  text-align: center;
+  width: 100%;
+`;

+ 14 - 3
static/app/views/performance/browser/webVitals/pageOverview.tsx

@@ -16,6 +16,7 @@ import {TabList, Tabs} from 'sentry/components/tabs';
 import {IconChevron} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {Tag} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {useLocation} from 'sentry/utils/useLocation';
@@ -32,6 +33,8 @@ import {WebVitals} from 'sentry/views/performance/browser/webVitals/utils/types'
 import {useProjectWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery';
 import {ModulePageProviders} from 'sentry/views/performance/database/modulePageProviders';
 
+import {PageOverviewWebVitalsTagDetailPanel} from './pageOverWebVitalsTagDetailPanel';
+
 export enum LandingDisplayField {
   OVERVIEW = 'overview',
   SPANS = 'spans',
@@ -75,8 +78,9 @@ export default function PageOverview() {
   // TODO: When visiting page overview from a specific webvital detail panel in the landing page,
   // we should automatically default this webvital state to the respective webvital so the detail
   // panel in this page opens automatically.
-  const [state, setState] = useState<{webVital: WebVitals | null}>({
+  const [state, setState] = useState<{webVital: WebVitals | null; tag?: Tag}>({
     webVital: null,
+    tag: undefined,
   });
 
   const {data: pageData, isLoading} = useProjectWebVitalsQuery({transaction});
@@ -180,22 +184,24 @@ export default function PageOverview() {
                 onClick={webVital => setState({...state, webVital})}
                 transaction={transaction}
               />
-              {/* TODO: Need to pass in a handler function to each tag list here to handle opening detail panel for tags */}
               <Flex>
                 <PageOverviewFeaturedTagsList
                   tag="browser.name"
                   title={t('Slowest Browsers')}
                   transaction={transaction}
+                  onClick={tag => setState({...state, tag})}
                 />
                 <PageOverviewFeaturedTagsList
                   tag="release"
                   title={t('Slowest Releases')}
                   transaction={transaction}
+                  onClick={tag => setState({...state, tag})}
                 />
                 <PageOverviewFeaturedTagsList
                   tag="geo.country_code"
                   title={t('Slowest Regions')}
                   transaction={transaction}
+                  onClick={tag => setState({...state, tag})}
                 />
               </Flex>
             </Layout.Main>
@@ -213,7 +219,12 @@ export default function PageOverview() {
             setState({...state, webVital: null});
           }}
         />
-        {/* TODO: Add the detail panel for tags here. Can copy foundation from PageOverviewWebVitalsDetailPanel above. */}
+        <PageOverviewWebVitalsTagDetailPanel
+          tag={state.tag}
+          onClose={() => {
+            setState({...state, tag: undefined});
+          }}
+        />
       </Tabs>
     </ModulePageProviders>
   );

+ 1 - 1
static/app/views/performance/browser/webVitals/pageOverviewWebVitalsDetailPanel.tsx

@@ -180,7 +180,7 @@ export function PageOverviewWebVitalsDetailPanel({
       return <AlignRight>{formattedValue}</AlignRight>;
     }
     if (key === 'id') {
-      const eventSlug = generateEventSlug({...row, project: project?.slug});
+      const eventSlug = generateEventSlug({...row, project: row.projectSlug});
       const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
       return (
         <NoOverflow>

+ 13 - 164
static/app/views/performance/browser/webVitals/performanceScoreChart.tsx

@@ -1,21 +1,17 @@
-import {useRef, useState} from 'react';
-import {css, useTheme} from '@emotion/react';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import useMouseTracking from 'sentry/utils/replays/hooks/useMouseTracking';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import {PerformanceScoreBreakdownChart} from 'sentry/views/performance/browser/webVitals/components/performanceScoreBreakdownChart';
-import PerformanceScoreRing from 'sentry/views/performance/browser/webVitals/components/performanceScoreRing';
-import {
-  PERFORMANCE_SCORE_WEIGHTS,
-  ProjectScore,
-} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
+import {ProjectScore} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
 import {WebVitals} from 'sentry/views/performance/browser/webVitals/utils/types';
 
+import OverallProgressRing from './overallProgressRing';
+
 type Props = {
   isProjectScoreLoading?: boolean;
   projectScore?: ProjectScore;
@@ -23,15 +19,7 @@ type Props = {
   webVital?: WebVitals | null;
 };
 
-const {
-  lcp: LCP_WEIGHT,
-  fcp: FCP_WEIGHT,
-  fid: FID_WEIGHT,
-  cls: CLS_WEIGHT,
-  ttfb: TTFB_WEIGHT,
-} = PERFORMANCE_SCORE_WEIGHTS;
-
-const ORDER = ['lcp', 'fcp', 'fid', 'cls', 'ttfb'];
+export const ORDER = ['lcp', 'fcp', 'fid', 'cls', 'ttfb'];
 
 export function PerformanceScoreChart({
   projectScore,
@@ -64,95 +52,20 @@ export function PerformanceScoreChart({
   const period = pageFilters.selection.datetime.period;
   const performanceScoreSubtext = (period && DEFAULT_RELATIVE_PERIODS[period]) ?? '';
 
-  const [mousePosition, setMousePosition] = useState({x: 0, y: 0});
-  const elem = useRef<HTMLDivElement>(null);
-  const mouseTrackingProps = useMouseTracking({
-    elem,
-    onPositionChange: args => {
-      if (args) {
-        const {left, top} = args;
-        setMousePosition({x: left, y: top});
-      }
-    },
-  });
-  const [webVitalTooltip, setWebVitalTooltip] = useState<WebVitals | null>(null);
-
   return (
     <Flex>
       <PerformanceScoreLabelContainer>
         <PerformanceScoreLabel>{t('Performance Score')}</PerformanceScoreLabel>
         <PerformanceScoreSubtext>{performanceScoreSubtext}</PerformanceScoreSubtext>
         {!isProjectScoreLoading && projectScore && (
-          <ProgressRingContainer ref={elem} {...mouseTrackingProps}>
-            {webVitalTooltip && (
-              <PerformanceScoreRingTooltip x={mousePosition.x} y={mousePosition.y}>
-                <TooltipRow>
-                  <span>
-                    <Dot color={ringBackgroundColors[ORDER.indexOf(webVitalTooltip)]} />
-                    {webVitalTooltip.toUpperCase()} {t('Opportunity')}
-                  </span>
-                  <TooltipValue>
-                    {100 - projectScore[`${webVitalTooltip}Score`]}
-                  </TooltipValue>
-                </TooltipRow>
-                <TooltipRow>
-                  <span>
-                    <Dot color={ringSegmentColors[ORDER.indexOf(webVitalTooltip)]} />
-                    {webVitalTooltip.toUpperCase()} {t('Score')}
-                  </span>
-                  <TooltipValue>{projectScore[`${webVitalTooltip}Score`]}</TooltipValue>
-                </TooltipRow>
-                <PerformanceScoreRingTooltipArrow />
-              </PerformanceScoreRingTooltip>
-            )}
-            <svg height={180} width={220}>
-              <ProgressRingText x={160} y={30}>
-                LCP
-              </ProgressRingText>
-              <ProgressRingText x={175} y={140}>
-                FCP
-              </ProgressRingText>
-              <ProgressRingText x={20} y={140}>
-                FID
-              </ProgressRingText>
-              <ProgressRingText x={10} y={60}>
-                CLS
-              </ProgressRingText>
-              <ProgressRingText x={50} y={20}>
-                TTFB
-              </ProgressRingText>
-              <PerformanceScoreRing
-                values={[
-                  projectScore.lcpScore * LCP_WEIGHT * 0.01,
-                  projectScore.fcpScore * FCP_WEIGHT * 0.01,
-                  projectScore.fidScore * FID_WEIGHT * 0.01,
-                  projectScore.clsScore * CLS_WEIGHT * 0.01,
-                  projectScore.ttfbScore * TTFB_WEIGHT * 0.01,
-                ]}
-                maxValues={[LCP_WEIGHT, FCP_WEIGHT, FID_WEIGHT, CLS_WEIGHT, TTFB_WEIGHT]}
-                text={score}
-                size={140}
-                barWidth={14}
-                textCss={() => css`
-                  font-size: 32px;
-                  font-weight: bold;
-                  color: ${theme.textColor};
-                `}
-                segmentColors={ringSegmentColors}
-                backgroundColors={ringBackgroundColors}
-                x={40}
-                y={20}
-                onHoverActions={[
-                  () => setWebVitalTooltip('lcp'),
-                  () => setWebVitalTooltip('fcp'),
-                  () => setWebVitalTooltip('fid'),
-                  () => setWebVitalTooltip('cls'),
-                  () => setWebVitalTooltip('ttfb'),
-                ]}
-                onUnhover={() => setWebVitalTooltip(null)}
-              />
-            </svg>
-          </ProgressRingContainer>
+          <OverallProgressRing
+            projectScore={projectScore}
+            text={score}
+            width={220}
+            height={180}
+            ringBackgroundColors={ringBackgroundColors}
+            ringSegmentColors={ringSegmentColors}
+          />
         )}
         {!isProjectScoreLoading && !projectScore && (
           <EmptyStateWarning>
@@ -197,67 +110,3 @@ const PerformanceScoreSubtext = styled('div')`
   color: ${p => p.theme.gray300};
   margin-bottom: ${space(1)};
 `;
-
-const ProgressRingContainer = styled('div')``;
-
-const ProgressRingText = styled('text')`
-  font-size: ${p => p.theme.fontSizeMedium};
-  fill: ${p => p.theme.textColor};
-  font-weight: bold;
-`;
-
-// Hover element on mouse
-const PerformanceScoreRingTooltip = styled('div')<{x: number; y: number}>`
-  position: absolute;
-  background: ${p => p.theme.backgroundElevated};
-  border-radius: ${p => p.theme.borderRadius};
-  border: 1px solid ${p => p.theme.gray200};
-  transform: translate3d(${p => p.x - 100}px, ${p => p.y - 74}px, 0px);
-  padding: ${space(1)} ${space(2)};
-  width: 200px;
-  height: 60px;
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-`;
-
-const PerformanceScoreRingTooltipArrow = styled('div')`
-  top: 100%;
-  left: 50%;
-  position: absolute;
-  pointer-events: none;
-  border-left: 8px solid transparent;
-  border-right: 8px solid transparent;
-  border-top: 8px solid ${p => p.theme.backgroundElevated};
-  margin-left: -8px;
-  &:before {
-    border-left: 8px solid transparent;
-    border-right: 8px solid transparent;
-    border-top: 8px solid ${p => p.theme.translucentBorder};
-    content: '';
-    display: block;
-    position: absolute;
-    top: -7px;
-    left: -8px;
-    z-index: -1;
-  }
-`;
-
-const Dot = styled('span')<{color: string}>`
-  display: inline-block;
-  margin-right: ${space(0.5)};
-  border-radius: 10px;
-  width: 10px;
-  height: 10px;
-  background-color: ${p => p.color};
-`;
-
-const TooltipRow = styled('div')`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-`;
-
-const TooltipValue = styled('span')`
-  color: ${p => p.theme.gray300};
-`;

+ 1 - 0
static/app/views/performance/browser/webVitals/utils/types.tsx

@@ -19,6 +19,7 @@ export type TransactionSampleRow = {
   'measurements.lcp': number | null;
   'measurements.ttfb': number | null;
   'profile.id': string;
+  projectSlug: string;
   replayId: string;
   timestamp: string;
   transaction: string;

+ 6 - 2
static/app/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery.tsx

@@ -1,3 +1,4 @@
+import {Tag} from 'sentry/types';
 import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
 import EventView from 'sentry/utils/discover/eventView';
 import {DiscoverDatasets} from 'sentry/utils/discover/types';
@@ -6,10 +7,11 @@ import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 
 type Props = {
+  tag?: Tag;
   transaction?: string;
 };
 
-export const useProjectWebVitalsQuery = ({transaction}: Props = {}) => {
+export const useProjectWebVitalsQuery = ({transaction, tag}: Props = {}) => {
   const organization = useOrganization();
   const pageFilters = usePageFilters();
   const location = useLocation();
@@ -29,7 +31,9 @@ export const useProjectWebVitalsQuery = ({transaction}: Props = {}) => {
       ],
       name: 'Web Vitals',
       query:
-        'transaction.op:pageload' + (transaction ? ` transaction:"${transaction}"` : ''),
+        'transaction.op:pageload' +
+        (transaction ? ` transaction:"${transaction}"` : '') +
+        (tag ? ` ${tag.key}:"${tag.name}"` : ''),
       version: 2,
       dataset: DiscoverDatasets.METRICS,
     },

+ 6 - 2
static/app/views/performance/browser/webVitals/utils/useProjectWebVitalsTimeseriesQuery.tsx

@@ -1,4 +1,5 @@
 import {getInterval} from 'sentry/components/charts/utils';
+import {Tag} from 'sentry/types';
 import {SeriesDataUnit} from 'sentry/types/echarts';
 import EventView, {MetaType} from 'sentry/utils/discover/eventView';
 import {
@@ -12,10 +13,11 @@ import usePageFilters from 'sentry/utils/usePageFilters';
 import {calculatePerformanceScore} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
 
 type Props = {
+  tag?: Tag;
   transaction?: string;
 };
 
-export const useProjectWebVitalsTimeseriesQuery = ({transaction}: Props) => {
+export const useProjectWebVitalsTimeseriesQuery = ({transaction, tag}: Props) => {
   const pageFilters = usePageFilters();
   const location = useLocation();
   const organization = useOrganization();
@@ -31,7 +33,9 @@ export const useProjectWebVitalsTimeseriesQuery = ({transaction}: Props) => {
       ],
       name: 'Web Vitals',
       query:
-        'transaction.op:pageload' + (transaction ? ` transaction:"${transaction}"` : ''),
+        'transaction.op:pageload' +
+        (transaction ? ` transaction:"${transaction}"` : '') +
+        (tag ? ` ${tag.key}:"${tag.name}"` : ''),
       version: 2,
       fields: [],
       interval: getInterval(pageFilters.selection.datetime, 'low'),

Some files were not shown because too many files changed in this diff