Browse Source

feat(ddm): Links to release and transaction + summary and focus polishes (#57002)

Matej Minar 1 year ago
parent
commit
1911a68a37
2 changed files with 147 additions and 55 deletions
  1. 26 20
      static/app/views/ddm/metricsExplorer.tsx
  2. 121 35
      static/app/views/ddm/summaryTable.tsx

+ 26 - 20
static/app/views/ddm/metricsExplorer.tsx

@@ -1,6 +1,8 @@
 import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
 import {Theme} from '@emotion/react';
 import styled from '@emotion/styled';
+import colorFn from 'color';
+import type {LineSeriesOption} from 'echarts';
 import moment from 'moment';
 
 import Alert from 'sentry/components/alert';
@@ -291,26 +293,18 @@ function MetricsExplorerDisplayOuter(props?: DisplayProps) {
 function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) {
   const router = useRouter();
   const {data, isLoading, isError} = useMetricsData(metricsDataProps);
-  const hiddenSeries = decodeList(router.location.query.hiddenSeries);
+  const focusedSeries = router.location.query.focusedSeries;
+  const [hoveredLegend, setHoveredLegend] = useState('');
 
   const toggleSeriesVisibility = (seriesName: string) => {
-    if (hiddenSeries.includes(seriesName)) {
-      router.push({
-        ...router.location,
-        query: {
-          ...router.location.query,
-          hiddenSeries: hiddenSeries.filter(s => s !== seriesName),
-        },
-      });
-    } else {
-      router.push({
-        ...router.location,
-        query: {
-          ...router.location.query,
-          hiddenSeries: [...hiddenSeries, seriesName],
-        },
-      });
-    }
+    setHoveredLegend('');
+    router.push({
+      ...router.location,
+      query: {
+        ...router.location.query,
+        focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
+      },
+    });
   };
 
   if (!data) {
@@ -330,6 +324,8 @@ function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps
     return {
       values: Object.values(g.series)[0],
       name: getSeriesName(g, data.groups.length === 1, metricsDataProps.groupBy),
+      transaction: g.by.transaction,
+      release: g.by.release,
     };
   });
 
@@ -338,12 +334,19 @@ function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps
   const chartSeries = series.map((item, i) => ({
     seriesName: item.name,
     unit,
-    color: colors[i],
-    hidden: hiddenSeries.includes(item.name),
+    color: colorFn(colors[i])
+      .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
+      .string(),
+    hidden: focusedSeries && focusedSeries !== item.name,
     data: item.values.map((value, index) => ({
       name: sorted.intervals[index],
       value,
     })),
+    transaction: item.transaction as string | undefined,
+    release: item.release as string | undefined,
+    emphasis: {
+      focus: 'series',
+    } as LineSeriesOption['emphasis'],
   }));
 
   return (
@@ -360,6 +363,7 @@ function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps
         series={chartSeries}
         operation={metricsDataProps.op}
         onClick={toggleSeriesVisibility}
+        setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
       />
     </DisplayWrapper>
   );
@@ -435,6 +439,8 @@ export type Series = {
   seriesName: string;
   unit: string;
   hidden?: boolean;
+  release?: string;
+  transaction?: string;
 };
 
 type ChartProps = {

+ 121 - 35
static/app/views/ddm/summaryTable.tsx

@@ -1,47 +1,127 @@
 import {Fragment} from 'react';
 import styled from '@emotion/styled';
+import colorFn from 'color';
 
+import {LinkButton} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconLightning, IconReleases} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {getUtcDateString} from 'sentry/utils/dates';
 import {formatMetricsUsingUnitAndOp, getNameFromMRI} from 'sentry/utils/metrics';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useRouter from 'sentry/utils/useRouter';
 import {Series} from 'sentry/views/ddm/metricsExplorer';
+import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
 
 export function SummaryTable({
   series,
   operation,
   onClick,
+  setHoveredLegend,
 }: {
   onClick: (seriesName: string) => void;
   series: Series[];
+  setHoveredLegend: React.Dispatch<React.SetStateAction<string>> | undefined;
   operation?: string;
 }) {
+  const {selection} = usePageFilters();
+  const router = useRouter();
+  const {slug} = useOrganization();
+  const hasActions = series.some(s => s.release || s.transaction);
+  const {start, end, statsPeriod, project, environment} = router.location.query;
+
   return (
-    <SummaryTableWrapper>
+    <SummaryTableWrapper hasActions={hasActions}>
       <HeaderCell />
       <HeaderCell>{t('Name')}</HeaderCell>
-      <HeaderCell>{t('Avg')}</HeaderCell>
-      <HeaderCell>{t('Min')}</HeaderCell>
-      <HeaderCell>{t('Max')}</HeaderCell>
-      <HeaderCell>{t('Sum')}</HeaderCell>
+      <HeaderCell right>{t('Avg')}</HeaderCell>
+      <HeaderCell right>{t('Min')}</HeaderCell>
+      <HeaderCell right>{t('Max')}</HeaderCell>
+      <HeaderCell right>{t('Sum')}</HeaderCell>
+      {hasActions && <HeaderCell right>{t('Actions')}</HeaderCell>}
 
       {series
         .sort((a, b) => a.seriesName.localeCompare(b.seriesName))
-        .map(({seriesName, color, hidden, unit, data}) => {
+        .map(({seriesName, color, hidden, unit, data, transaction, release}) => {
           const {avg, min, max, sum} = getValues(data);
 
           return (
             <Fragment key={seriesName}>
-              <FlexCell onClick={() => onClick(seriesName)} hidden={hidden}>
-                <ColorDot color={color} />
-              </FlexCell>
-              <Cell onClick={() => onClick(seriesName)}>
-                {getNameFromMRI(seriesName)}
-              </Cell>
-              {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
-              <Cell>{formatMetricsUsingUnitAndOp(avg, unit, operation)}</Cell>
-              <Cell>{formatMetricsUsingUnitAndOp(min, unit, operation)}</Cell>
-              <Cell>{formatMetricsUsingUnitAndOp(max, unit, operation)}</Cell>
-              <Cell>{formatMetricsUsingUnitAndOp(sum, unit, operation)}</Cell>
+              <CellWrapper
+                onClick={() => onClick(seriesName)}
+                onMouseEnter={() => setHoveredLegend?.(seriesName)}
+                onMouseLeave={() => setHoveredLegend?.('')}
+              >
+                <Cell>
+                  <ColorDot color={color} hiddenn={!!hidden} />
+                </Cell>
+                <Cell>{getNameFromMRI(seriesName)}</Cell>
+                {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
+                <Cell right>{formatMetricsUsingUnitAndOp(avg, unit, operation)}</Cell>
+                <Cell right>{formatMetricsUsingUnitAndOp(min, unit, operation)}</Cell>
+                <Cell right>{formatMetricsUsingUnitAndOp(max, unit, operation)}</Cell>
+                <Cell right>{formatMetricsUsingUnitAndOp(sum, unit, operation)}</Cell>
+              </CellWrapper>
+              {hasActions && (
+                <Cell right>
+                  <ButtonBar gap={0.5}>
+                    {transaction && (
+                      <div>
+                        <Tooltip title={t('Open Transaction Summary')}>
+                          <LinkButton
+                            to={transactionSummaryRouteWithQuery({
+                              orgSlug: slug,
+                              transaction,
+                              projectID: selection.projects.map(p => String(p)),
+                              query: {
+                                query: '',
+                                environment: selection.environments,
+                                start: selection.datetime.start
+                                  ? getUtcDateString(selection.datetime.start)
+                                  : undefined,
+                                end: selection.datetime.end
+                                  ? getUtcDateString(selection.datetime.end)
+                                  : undefined,
+                                statsPeriod: selection.datetime.period,
+                              },
+                            })}
+                            size="xs"
+                          >
+                            <IconLightning size="xs" />
+                          </LinkButton>
+                        </Tooltip>
+                      </div>
+                    )}
+
+                    {release && (
+                      <div>
+                        <Tooltip title={t('Open Release Details')}>
+                          <LinkButton
+                            to={{
+                              pathname: `/organizations/${slug}/releases/${encodeURIComponent(
+                                release
+                              )}/`,
+                              query: {
+                                start,
+                                end,
+                                pageStatsPeriod: statsPeriod,
+                                project,
+                                environment,
+                              },
+                            }}
+                            size="xs"
+                          >
+                            <IconReleases size="xs" />
+                          </LinkButton>
+                        </Tooltip>
+                      </div>
+                    )}
+                  </ButtonBar>
+                </Cell>
+              )}
             </Fragment>
           );
         })}
@@ -74,13 +154,14 @@ function getValues(seriesData: Series['data']) {
 
 // TODO(ddm): PanelTable component proved to be a bit too opinionated for this use case,
 // so we're using a custom styled component instead. Figure out what we want to do here
-const SummaryTableWrapper = styled(`div`)`
+const SummaryTableWrapper = styled(`div`)<{hasActions: boolean}>`
   display: grid;
-  grid-template-columns: 0.5fr 8fr 1fr 1fr 1fr 1fr;
+  grid-template-columns: ${p =>
+    p.hasActions ? '24px 8fr 1fr 1fr 1fr 1fr 1fr' : '24px 8fr 1fr 1fr 1fr 1fr'};
 `;
 
 // TODO(ddm): This is a copy of PanelTableHeader, try to figure out how to reuse it
-const HeaderCell = styled('div')`
+const HeaderCell = styled('div')<{right?: boolean}>`
   color: ${p => p.theme.subText};
   font-size: ${p => p.theme.fontSizeSmall};
   font-weight: 600;
@@ -90,28 +171,33 @@ const HeaderCell = styled('div')`
   display: flex;
   flex-direction: column;
   justify-content: center;
-
-  padding: ${space(0.5)};
-`;
-
-const Cell = styled('div')`
-  padding: ${space(0.25)};
-
-  :hover {
-    cursor: ${p => (p.onClick ? 'pointer' : 'default')};
-  }
+  text-align: ${p => (p.right ? 'right' : 'left')};
+  padding: ${space(0.5)} ${space(1)};
 `;
 
-const FlexCell = styled(Cell)`
+const Cell = styled('div')<{right?: boolean}>`
   display: flex;
-  justify-content: center;
+  padding: ${space(0.25)} ${space(1)};
   align-items: center;
-  opacity: ${p => (p.hidden ? 0.5 : 1)};
+  justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
 `;
 
-const ColorDot = styled(`div`)`
-  background-color: ${p => p.color};
+const ColorDot = styled(`div`)<{color: string; hiddenn: boolean}>`
+  background-color: ${p =>
+    colorFn(p.color)
+      .alpha(p.hiddenn ? 0.3 : 1)
+      .string()};
   border-radius: 50%;
   width: ${space(1)};
   height: ${space(1)};
 `;
+
+const CellWrapper = styled('div')`
+  display: contents;
+  &:hover {
+    cursor: pointer;
+    ${Cell} {
+      background-color: ${p => p.theme.bodyBackground};
+    }
+  }
+`;