Browse Source

feat(dashboards): metric table widget (#65871)

Ogi 1 year ago
parent
commit
0bd26a9749

+ 2 - 5
static/app/components/modals/metricWidgetViewerModal.tsx

@@ -16,10 +16,7 @@ import {convertToDashboardWidget, toDisplayType} from 'sentry/utils/metrics/dash
 import type {MetricQueryWidgetParams} from 'sentry/utils/metrics/types';
 import type {MetricsQueryApiRequestQuery} from 'sentry/utils/metrics/useMetricsQuery';
 import usePageFilters from 'sentry/utils/usePageFilters';
-import {
-  getMetricQueries,
-  toMetricDisplayType,
-} from 'sentry/views/dashboards/metrics/utils';
+import {getMetricQueries} from 'sentry/views/dashboards/metrics/utils';
 import {getQuerySymbol} from 'sentry/views/ddm/querySymbol';
 import {MetricDetails} from 'sentry/views/ddm/widgetDetails';
 import {OrganizationContext} from 'sentry/views/organizationContext';
@@ -48,7 +45,7 @@ function MetricWidgetViewerModal({
     [metricWidgetQueries]
   );
 
-  const [displayType, setDisplayType] = useState(toMetricDisplayType(widget.displayType));
+  const [displayType, setDisplayType] = useState(widget.displayType);
   const [editedTitle, setEditedTitle] = useState<string>(widget.title);
   // If user renamed the widget, dislay that title, otherwise display the MQL
   const titleToDisplay = editedTitle === '' ? widgetMQL : editedTitle;

+ 15 - 2
static/app/components/modals/metricWidgetViewerModal/header.tsx

@@ -3,13 +3,26 @@ import styled from '@emotion/styled';
 
 import {Button} from 'sentry/components/button';
 import Input from 'sentry/components/input';
+import {Tooltip} from 'sentry/components/tooltip';
 import {IconCheckmark, IconEdit} from 'sentry/icons';
 import {space} from 'sentry/styles/space';
 import {WidgetDescription} from 'sentry/views/dashboards/widgetCard';
 
-import {Tooltip} from '../../tooltip';
+type Props = {
+  displayValue: string;
+  placeholder: string;
+  value: string;
+  description?: string;
+  onSubmit?: (value: string) => void;
+};
 
-export function WidgetTitle({value, displayValue, placeholder, description, onSubmit}) {
+export function WidgetTitle({
+  value,
+  displayValue,
+  placeholder,
+  description,
+  onSubmit,
+}: Props) {
   const [isEditingTitle, setIsEditingTitle] = useState(false);
   const [title, setTitle] = useState<string>(value);
 

+ 45 - 23
static/app/components/modals/metricWidgetViewerModal/queries.tsx

@@ -14,29 +14,42 @@ import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useRouter from 'sentry/utils/useRouter';
 import {getCreateAlert} from 'sentry/views/ddm/metricQueryContextMenu';
-import {Query} from 'sentry/views/ddm/queries';
+import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
 
-// TODO: add types
-export function Queries({metricWidgetQueries, handleChange, addQuery, removeQuery}) {
+interface Props {
+  addQuery: () => void;
+  handleChange: (data: Partial<MetricsQuery>, index: number) => void;
+  metricWidgetQueries: MetricsQuery[];
+  removeQuery: (index: number) => void;
+}
+
+export function Queries({
+  metricWidgetQueries,
+  handleChange,
+  addQuery,
+  removeQuery,
+}: Props) {
   const {selection} = usePageFilters();
 
   return (
     <QueriesWrapper>
       {metricWidgetQueries.map((query, index) => (
-        <Query
-          key={index}
-          widget={query}
-          projects={selection.projects}
-          onChange={data => handleChange(data, index)}
-          contextMenu={
-            <ContextMenu
-              removeQuery={removeQuery}
-              queryIndex={index}
-              canRemoveQuery={metricWidgetQueries.length > 1}
-              metricsQuery={query}
-            />
-          }
-        />
+        <QueryWrapper key={index}>
+          <QueryBuilder
+            // @ts-expect-error TODO: adjust query builder type
+            onChange={data => handleChange(data, index)}
+            metricsQuery={query}
+            // @ts-expect-error TODO: remove display type from query builder
+            displayType={'line'}
+            projects={selection.projects}
+          />
+          <ContextMenu
+            canRemoveQuery={metricWidgetQueries.length > 1}
+            removeQuery={removeQuery}
+            queryIndex={index}
+            metricsQuery={query}
+          />
+        </QueryWrapper>
       ))}
       <Button size="sm" icon={<IconAdd isCircled />} onClick={addQuery}>
         {t('Add query')}
@@ -45,17 +58,19 @@ export function Queries({metricWidgetQueries, handleChange, addQuery, removeQuer
   );
 }
 
+interface ContextMenuProps {
+  canRemoveQuery: boolean;
+  metricsQuery: MetricsQuery;
+  queryIndex: number;
+  removeQuery: (index: number) => void;
+}
+
 function ContextMenu({
   metricsQuery,
   removeQuery,
   canRemoveQuery,
   queryIndex,
-}: {
-  canRemoveQuery: boolean;
-  metricsQuery: MetricsQuery;
-  queryIndex: number;
-  removeQuery: (index: number) => void;
-}) {
+}: ContextMenuProps) {
   const organization = useOrganization();
   const router = useRouter();
 
@@ -119,3 +134,10 @@ function ContextMenu({
 const QueriesWrapper = styled('div')`
   padding-bottom: ${space(2)};
 `;
+
+const QueryWrapper = styled('div')`
+  display: grid;
+  gap: ${space(1)};
+  padding-bottom: ${space(1)};
+  grid-template-columns: 1fr max-content;
+`;

+ 128 - 69
static/app/components/modals/metricWidgetViewerModal/visualization.tsx

@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
 
 import Alert from 'sentry/components/alert';
@@ -8,24 +8,35 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Tooltip} from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import type {ReactEchartsRef} from 'sentry/types/echarts';
+import type {MetricsQueryApiResponse} from 'sentry/types';
 import {getWidgetTitle} from 'sentry/utils/metrics';
-import {
-  DEFAULT_SORT_STATE,
-  metricDisplayTypeOptions,
-} from 'sentry/utils/metrics/constants';
+import {DEFAULT_SORT_STATE} from 'sentry/utils/metrics/constants';
 import type {FocusedMetricsSeries, SortState} from 'sentry/utils/metrics/types';
-import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
+import {
+  type MetricsQueryApiRequestQuery,
+  useMetricsQuery,
+} from 'sentry/utils/metrics/useMetricsQuery';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
+import {getTableData, MetricTable} from 'sentry/views/dashboards/metrics/table';
+import {toMetricDisplayType} from 'sentry/views/dashboards/metrics/utils';
+import {DisplayType} from 'sentry/views/dashboards/types';
+import {displayTypes} from 'sentry/views/dashboards/widgetBuilder/utils';
 import {getIngestionSeriesId, MetricChart} from 'sentry/views/ddm/chart/chart';
 import {SummaryTable} from 'sentry/views/ddm/summaryTable';
+import {useSeriesHover} from 'sentry/views/ddm/useSeriesHover';
 import {createChartPalette} from 'sentry/views/ddm/utils/metricsChartPalette';
 import {getChartTimeseries} from 'sentry/views/ddm/widget';
 
-import {DASHBOARD_CHART_GROUP} from '../../../views/dashboards/dashboard';
-import {DisplayType} from '../../../views/dashboards/types';
-
-function useFocusedSeries({timeseriesData, queries, onChange}) {
+function useFocusedSeries({
+  timeseriesData,
+  queries,
+  onChange,
+}: {
+  queries: MetricsQueryApiRequestQuery[];
+  timeseriesData: MetricsQueryApiResponse | null;
+  onChange?: () => void;
+}) {
   const [focusedSeries, setFocusedSeries] = useState<FocusedMetricsSeries[]>([]);
 
   const chartSeries = useMemo(() => {
@@ -89,35 +100,27 @@ function useFocusedSeries({timeseriesData, queries, onChange}) {
   };
 }
 
-function useHoverSeries() {
-  const chartRef = useRef<ReactEchartsRef>(null);
+const supportedDisplayTypes = Object.keys(displayTypes)
+  .filter(d => d !== DisplayType.BIG_NUMBER)
+  .map(value => ({
+    label: displayTypes[value],
+    value,
+  }));
 
-  const setHoveredSeries = useCallback((seriesId: string) => {
-    if (!chartRef.current) {
-      return;
-    }
-    const echartsInstance = chartRef.current.getEchartsInstance();
-    echartsInstance.dispatchAction({
-      type: 'highlight',
-      seriesId: [seriesId, getIngestionSeriesId(seriesId)],
-    });
-  }, []);
-
-  const resetHoveredSeries = useCallback(() => {
-    setHoveredSeries('');
-  }, [setHoveredSeries]);
-
-  return {
-    chartRef,
-    setHoveredSeries,
-    resetHoveredSeries,
-  };
+interface MetricVisualizationProps {
+  displayType: DisplayType;
+  onDisplayTypeChange: (displayType: DisplayType) => void;
+  queries: MetricsQueryApiRequestQuery[];
 }
 
-// TODO: add types
-export function MetricVisualization({queries, displayType, onDisplayTypeChange}) {
+export function MetricVisualization({
+  queries,
+  displayType,
+  onDisplayTypeChange,
+}: MetricVisualizationProps) {
   const {selection} = usePageFilters();
-  const [tableSort, setTableSort] = useState<SortState>(DEFAULT_SORT_STATE);
+
+  const isTable = displayType === DisplayType.TABLE;
 
   const {
     data: timeseriesData,
@@ -128,17 +131,9 @@ export function MetricVisualization({queries, displayType, onDisplayTypeChange})
     intervalLadder: displayType === DisplayType.BAR ? 'bar' : 'dashboard',
   });
 
-  const {chartRef, setHoveredSeries, resetHoveredSeries} = useHoverSeries();
-
-  const {chartSeries, toggleSeriesVisibility, setSeriesVisibility} = useFocusedSeries({
-    timeseriesData,
-    queries,
-    onChange: resetHoveredSeries,
-  });
-
   const widgetMQL = useMemo(() => getWidgetTitle(queries), [queries]);
 
-  if (!chartSeries || !timeseriesData || isError) {
+  if (!timeseriesData || isError) {
     return (
       <StyledMetricChartContainer>
         {isLoading && <LoadingIndicator />}
@@ -165,42 +160,109 @@ export function MetricVisualization({queries, displayType, onDisplayTypeChange})
           </StyledTooltip>
         </WidgetTitle>
         <CompactSelect
-          size="xs"
+          size="sm"
           triggerProps={{prefix: t('Visualization')}}
           value={displayType}
-          options={metricDisplayTypeOptions}
-          onChange={({value}) => onDisplayTypeChange(value) as DisplayType}
+          options={supportedDisplayTypes}
+          onChange={({value}) => onDisplayTypeChange(value as DisplayType)}
         />
       </ViualizationHeader>
-      <StyledMetricChartContainer>
-        <TransparentLoadingMask visible={isLoading} />
-        <MetricChart
-          ref={chartRef}
-          series={chartSeries}
+      {!isTable ? (
+        <MetricChartVisualization
+          isLoading={isLoading}
+          timeseriesData={timeseriesData}
+          queries={queries}
           displayType={displayType}
-          group={DASHBOARD_CHART_GROUP}
-          height={200}
         />
-        <SummaryTable
-          series={chartSeries}
-          onSortChange={setTableSort}
-          sort={tableSort}
-          onRowClick={setSeriesVisibility}
-          onColorDotClick={toggleSeriesVisibility}
-          onRowHover={setHoveredSeries}
+      ) : (
+        <MetricTableVisualization
+          isLoading={isLoading}
+          timeseriesData={timeseriesData}
+          queries={queries}
         />
-      </StyledMetricChartContainer>
+      )}
     </StyledOuterContainer>
   );
 }
 
+interface MetricTableVisualizationProps {
+  isLoading: boolean;
+  queries: MetricsQueryApiRequestQuery[];
+  timeseriesData: MetricsQueryApiResponse;
+}
+
+function MetricTableVisualization({
+  timeseriesData,
+  queries,
+  isLoading,
+}: MetricTableVisualizationProps) {
+  const tableData = useMemo(() => {
+    return getTableData(timeseriesData, queries);
+  }, [timeseriesData, queries]);
+
+  return (
+    <Fragment>
+      <TransparentLoadingMask visible={isLoading} />
+      <MetricTable isLoading={isLoading} data={tableData} />
+    </Fragment>
+  );
+}
+
+interface MetricChartVisualizationProps extends MetricTableVisualizationProps {
+  displayType: DisplayType;
+}
+
+function MetricChartVisualization({
+  timeseriesData,
+  queries,
+  displayType,
+  isLoading,
+}: MetricChartVisualizationProps) {
+  const {chartRef, setHoveredSeries} = useSeriesHover();
+
+  const handleHoverSeries = useCallback(
+    (seriesId: string) => {
+      setHoveredSeries([seriesId, getIngestionSeriesId(seriesId)]);
+    },
+    [setHoveredSeries]
+  );
+
+  const {chartSeries, toggleSeriesVisibility, setSeriesVisibility} = useFocusedSeries({
+    timeseriesData,
+    queries,
+    onChange: () => handleHoverSeries(''),
+  });
+  const [tableSort, setTableSort] = useState<SortState>(DEFAULT_SORT_STATE);
+
+  return (
+    <Fragment>
+      <TransparentLoadingMask visible={isLoading} />
+      <MetricChart
+        ref={chartRef}
+        series={chartSeries}
+        displayType={toMetricDisplayType(displayType)}
+        group={DASHBOARD_CHART_GROUP}
+        height={200}
+      />
+      <SummaryTable
+        series={chartSeries}
+        onSortChange={setTableSort}
+        sort={tableSort}
+        onRowClick={setSeriesVisibility}
+        onColorDotClick={toggleSeriesVisibility}
+        onRowHover={handleHoverSeries}
+      />
+    </Fragment>
+  );
+}
+
 const StyledOuterContainer = styled('div')`
-  border: 1px solid ${p => p.theme.border};
-  border-radius: ${p => p.theme.borderRadius};
+  display: flex;
+  flex-direction: column;
+  gap: ${space(3)};
 `;
 
 const StyledMetricChartContainer = styled('div')`
-  padding: ${space(2)};
   gap: ${space(3)};
   display: flex;
   flex-direction: column;
@@ -213,9 +275,6 @@ const ViualizationHeader = styled('div')`
   justify-content: space-between;
   align-items: center;
   gap: ${space(1)};
-  padding-left: ${space(2)};
-  padding-top: ${space(1.5)};
-  padding-right: ${space(2)};
 `;
 
 const WidgetTitle = styled('div')`

+ 63 - 0
static/app/views/dashboards/metrics/chart.tsx

@@ -0,0 +1,63 @@
+import {useMemo, useRef} from 'react';
+import styled from '@emotion/styled';
+
+import TransitionChart from 'sentry/components/charts/transitionChart';
+import {space} from 'sentry/styles/space';
+import type {MetricsQueryApiResponse} from 'sentry/types';
+import type {ReactEchartsRef} from 'sentry/types/echarts';
+import type {MetricDisplayType} from 'sentry/utils/metrics/types';
+import type {MetricsQueryApiRequestQuery} from 'sentry/utils/metrics/useMetricsQuery';
+import {LoadingScreen} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer';
+import {MetricChart} from 'sentry/views/ddm/chart/chart';
+import {createChartPalette} from 'sentry/views/ddm/utils/metricsChartPalette';
+import {getChartTimeseries} from 'sentry/views/ddm/widget';
+
+import {DASHBOARD_CHART_GROUP} from '../dashboard';
+
+type MetricChartContainerProps = {
+  displayType: MetricDisplayType;
+  isLoading: boolean;
+  metricQueries: MetricsQueryApiRequestQuery[];
+  chartHeight?: number;
+  timeseriesData?: MetricsQueryApiResponse;
+};
+
+export function MetricChartContainer({
+  timeseriesData,
+  isLoading,
+  metricQueries,
+  chartHeight,
+  displayType,
+}: MetricChartContainerProps) {
+  const chartRef = useRef<ReactEchartsRef>(null);
+
+  const chartSeries = useMemo(() => {
+    return timeseriesData
+      ? getChartTimeseries(timeseriesData, metricQueries, {
+          getChartPalette: createChartPalette,
+        })
+      : [];
+  }, [timeseriesData, metricQueries]);
+
+  return (
+    <MetricWidgetChartWrapper>
+      <TransitionChart loading={isLoading} reloading={isLoading}>
+        <LoadingScreen loading={isLoading} />
+        <MetricChart
+          ref={chartRef}
+          series={chartSeries}
+          displayType={displayType}
+          group={DASHBOARD_CHART_GROUP}
+          height={chartHeight}
+        />
+      </TransitionChart>
+    </MetricWidgetChartWrapper>
+  );
+}
+
+const MetricWidgetChartWrapper = styled('div')`
+  height: 100%;
+  width: 100%;
+  padding: ${space(3)};
+  padding-top: ${space(2)};
+`;

+ 145 - 0
static/app/views/dashboards/metrics/table.spec.tsx

@@ -0,0 +1,145 @@
+import {getTableData} from 'sentry/views/dashboards/metrics/table';
+
+const queries = [
+  {
+    name: 'a',
+    mri: 'd:custom/sentry.event_manager.save@second',
+    op: 'p50',
+    groupBy: ['consumer_group', 'event_type'],
+  },
+  {
+    name: 'b',
+    mri: 'd:custom/sentry.event_manager.save_attachments@second',
+    op: 'p90',
+    groupBy: ['event_type'],
+  },
+];
+
+const data = [
+  [
+    {
+      by: {
+        consumer_group: '',
+        event_type: '',
+      },
+      series: [],
+      totals: 0.3751704159949441,
+    },
+    {
+      by: {
+        consumer_group: '',
+        event_type: 'error',
+      },
+      series: [],
+      totals: 0.13256912349970662,
+    },
+    {
+      by: {
+        consumer_group: 'ingest-occurrences-0',
+        event_type: '',
+      },
+      series: [],
+      totals: 0.11766651156358421,
+    },
+    {
+      by: {
+        consumer_group: '',
+        event_type: 'transaction',
+      },
+      series: [],
+      totals: 0.11107462100335397,
+    },
+    {
+      by: {
+        consumer_group: '',
+        event_type: 'default',
+      },
+      series: [],
+      totals: 0.10583872749703004,
+    },
+    {
+      by: {
+        consumer_group: '',
+        event_type: 'csp',
+      },
+      series: [],
+      totals: 0.1013268940441776,
+    },
+    {
+      by: {
+        consumer_group: '',
+        event_type: 'nel',
+      },
+      series: [],
+      totals: 0.06116106499985108,
+    },
+  ],
+  [
+    {
+      by: {
+        event_type: '',
+      },
+      series: [],
+      totals: 0.000006055769335944205,
+    },
+    {
+      by: {
+        event_type: 'default',
+      },
+      series: [],
+      totals: 0.000004693902155850083,
+    },
+    {
+      by: {
+        event_type: 'error',
+      },
+      series: [],
+      totals: 0.0000046898378059268,
+    },
+    {
+      by: {
+        event_type: 'csp',
+      },
+      series: [],
+      totals: 0.000004462950164452195,
+    },
+    {
+      by: {
+        event_type: 'nel',
+      },
+      series: [],
+      totals: 0.000004437007009983063,
+    },
+  ],
+];
+
+describe('getTableSeries', () => {
+  it('should return table series', () => {
+    // @ts-expect-error
+    const result = getTableData({data, meta: []}, queries);
+
+    expect(result.headers).toEqual([
+      {name: 'consumer_group', type: 'tag'},
+      {name: 'event_type', type: 'tag'},
+      {name: 'p50(d:custom/sentry.event_manager.save@second)', type: 'field'},
+      {name: 'p90(d:custom/sentry.event_manager.save_attachments@second)', type: 'field'},
+    ]);
+
+    expect(result.rows.length).toEqual(7);
+
+    const ingestRow = result.rows.find(
+      row => row.consumer_group === 'ingest-occurrences-0'
+    )!;
+
+    expect(ingestRow['p50(d:custom/sentry.event_manager.save@second)']).toBeDefined();
+    expect(
+      ingestRow['p90(d:custom/sentry.event_manager.save_attachments@second)']
+    ).toBeUndefined();
+
+    const defaultRow = result.rows.find(row => row.event_type === 'default')!;
+    expect(defaultRow['p50(d:custom/sentry.event_manager.save@second)']).toBeDefined();
+    expect(
+      defaultRow['p90(d:custom/sentry.event_manager.save_attachments@second)']
+    ).toBeDefined();
+  });
+});

+ 215 - 0
static/app/views/dashboards/metrics/table.tsx

@@ -0,0 +1,215 @@
+import {Fragment, useMemo} from 'react';
+import styled from '@emotion/styled';
+
+import PanelTable, {PanelTableHeader} from 'sentry/components/panels/panelTable';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {MetricsQueryApiResponse} from 'sentry/types';
+import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
+import {formatMRIField, MRIToField, parseMRI} from 'sentry/utils/metrics/mri';
+import {
+  isMetricFormula,
+  type MetricsQueryApiQueryParams,
+  type MetricsQueryApiRequestQuery,
+} from 'sentry/utils/metrics/useMetricsQuery';
+import {LoadingScreen} from 'sentry/views/starfish/components/chart';
+
+interface MetricTableContainerProps {
+  isLoading: boolean;
+  metricQueries: MetricsQueryApiRequestQuery[];
+  timeseriesData?: MetricsQueryApiResponse;
+}
+
+export function MetricTableContainer({
+  timeseriesData,
+  metricQueries,
+  isLoading,
+}: MetricTableContainerProps) {
+  const tableData = useMemo(() => {
+    return timeseriesData ? getTableData(timeseriesData, metricQueries) : undefined;
+  }, [timeseriesData, metricQueries]);
+
+  if (!tableData) {
+    return null;
+  }
+
+  return (
+    <Fragment>
+      <LoadingScreen loading={isLoading} />
+      <MetricTable isLoading={isLoading} data={tableData} borderless />
+    </Fragment>
+  );
+}
+
+interface MetricTableProps {
+  data: {
+    headers: {name: string; type: string}[];
+    rows: any[];
+  };
+  isLoading: boolean;
+  borderless?: boolean;
+}
+
+export function MetricTable({isLoading, data, borderless}: MetricTableProps) {
+  function renderRow(row: any, index: number) {
+    return data.headers.map((column, columnIndex) => {
+      const key = `${index}-${columnIndex}:${column}`;
+      const value = row[column.name];
+      if (!value) {
+        return (
+          <TableCell type={column.type} key={key} noValue>
+            {column.type === 'field' ? 'n/a' : '(none)'}
+          </TableCell>
+        );
+      }
+      return (
+        <TableCell type={column.type} key={key}>
+          {value}
+        </TableCell>
+      );
+    });
+  }
+
+  return (
+    <StyledPanelTable
+      borderless={borderless}
+      headers={data.headers.map((column, index) => {
+        const header = formatMRIField(column.name);
+        return (
+          <HeaderCell key={index} type={column.type}>
+            <Tooltip title={header}>{header}</Tooltip>
+          </HeaderCell>
+        );
+      })}
+      stickyHeaders
+      isLoading={isLoading}
+      emptyMessage={t('No results')}
+    >
+      {data.rows.map(renderRow)}
+    </StyledPanelTable>
+  );
+}
+
+const equalGroupBys = (a: Record<string, any>, b: Record<string, any>) => {
+  return JSON.stringify(a) === JSON.stringify(b);
+};
+
+const getEmptyGroup = (tags: string[]) =>
+  tags.reduce((acc, tag) => {
+    acc[tag] = '';
+    return acc;
+  }, {});
+
+function getGroupByCombos(
+  queries: MetricsQueryApiRequestQuery[],
+  results: MetricsQueryApiResponse['data']
+): Record<string, string>[] {
+  const groupBys = Array.from(new Set(queries.flatMap(query => query.groupBy ?? [])));
+  const emptyBy = getEmptyGroup(groupBys);
+
+  const allCombos = results.flatMap(group => {
+    return group.map(entry => ({...emptyBy, ...entry.by}));
+  });
+
+  const uniqueCombos = allCombos.filter(
+    (combo, index, self) => index === self.findIndex(other => equalGroupBys(other, combo))
+  );
+
+  return uniqueCombos;
+}
+type Row = Record<string, string | undefined>;
+
+interface TableData {
+  headers: {name: string; type: string}[];
+  rows: Row[];
+}
+
+export function getTableData(
+  data: MetricsQueryApiResponse,
+  queries: MetricsQueryApiQueryParams[]
+): TableData {
+  const filteredQueries = queries.filter(
+    query => !isMetricFormula(query)
+  ) as MetricsQueryApiRequestQuery[];
+
+  const fields = filteredQueries.map(query => MRIToField(query.mri, query.op));
+  const tags = [...new Set(filteredQueries.flatMap(query => query.groupBy ?? []))];
+
+  const normalizedResults = filteredQueries.map((query, index) => {
+    const queryResults = data.data[index];
+    const metaUnit = data.meta[index]?.[1]?.unit;
+    const normalizedGroupResults = queryResults.map(group => {
+      return {
+        by: {...getEmptyGroup(tags), ...group.by},
+        totals: formatMetricsUsingUnitAndOp(
+          group.totals,
+          // TODO(ogi): switch to using the meta unit when it's available
+          metaUnit ?? parseMRI(query.mri)?.unit!,
+          query.op
+        ),
+      };
+    });
+
+    const key = MRIToField(query.mri, query.op);
+    return {field: key, results: normalizedGroupResults};
+  }, {});
+
+  const groupByCombos = getGroupByCombos(filteredQueries, data.data);
+
+  const rows: Row[] = groupByCombos.map(combo => {
+    const row: Row = {...combo};
+
+    normalizedResults.forEach(({field, results}) => {
+      const entry = results.find(e => equalGroupBys(e.by, combo));
+      row[field] = entry?.totals;
+    });
+
+    return row;
+  });
+
+  const tableData = {
+    headers: [
+      ...tags.map(tagName => ({name: tagName, type: 'tag'})),
+      ...fields.map(f => ({name: f, type: 'field'})),
+    ],
+    rows,
+  };
+
+  return tableData;
+}
+
+const Cell = styled('div')<{type?: string}>`
+  text-align: ${p => (p.type === 'field' ? 'right' : 'left')};
+`;
+
+const StyledPanelTable = styled(PanelTable)<{borderless?: boolean}>`
+  position: relative;
+  display: grid;
+  overflow: auto;
+  margin: 0;
+  margin-top: ${space(1.5)};
+  border-radius: ${p => p.theme.borderRadius};
+  font-size: ${p => p.theme.fontSizeMedium};
+  box-shadow: none;
+
+  ${p =>
+    p.borderless &&
+    `border-radius: 0 0 ${p.theme.borderRadius} ${p.theme.borderRadius};
+    border-left: 0;
+    border-right: 0;
+    border-bottom: 0;`}
+
+  ${PanelTableHeader} {
+    height: min-content;
+  }
+`;
+
+const HeaderCell = styled(Cell)`
+  padding: 0 ${space(0.5)};
+`;
+
+export const TableCell = styled(Cell)<{noValue?: boolean}>`
+  padding: ${space(1)} ${space(3)};
+  ${p => p.noValue && `color: ${p.theme.gray300};`}
+`;

+ 54 - 77
static/app/views/dashboards/metrics/widgetCard.tsx

@@ -1,24 +1,20 @@
-import {Fragment, useMemo, useRef} from 'react';
+import {Fragment, useMemo} from 'react';
 import type {InjectedRouter} from 'react-router';
 import styled from '@emotion/styled';
 import type {Location} from 'history';
 
 import ErrorPanel from 'sentry/components/charts/errorPanel';
 import {HeaderTitle} from 'sentry/components/charts/styles';
-import TransitionChart from 'sentry/components/charts/transitionChart';
 import EmptyMessage from 'sentry/components/emptyMessage';
 import TextOverflow from 'sentry/components/textOverflow';
 import {IconSearch, IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {Organization, PageFilters} from 'sentry/types';
-import type {ReactEchartsRef} from 'sentry/types/echarts';
 import {getWidgetTitle} from 'sentry/utils/metrics';
-import {
-  type MetricsQueryApiRequestQuery,
-  useMetricsQuery,
-} from 'sentry/utils/metrics/useMetricsQuery';
-import {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
+import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
+import {MetricChartContainer} from 'sentry/views/dashboards/metrics/chart';
+import {MetricTableContainer} from 'sentry/views/dashboards/metrics/table';
 import {
   getMetricQueries,
   toMetricDisplayType,
@@ -29,10 +25,6 @@ import {WidgetCardPanel, WidgetTitleRow} from 'sentry/views/dashboards/widgetCar
 import {DashboardsMEPContext} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
 import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
 import WidgetCardContextMenu from 'sentry/views/dashboards/widgetCard/widgetCardContextMenu';
-import {MetricChart} from 'sentry/views/ddm/chart/chart';
-import {createChartPalette} from 'sentry/views/ddm/utils/metricsChartPalette';
-import {getChartTimeseries} from 'sentry/views/ddm/widget';
-import {LoadingScreen} from 'sentry/views/starfish/components/chart';
 
 type Props = {
   isEditingDashboard: boolean;
@@ -71,6 +63,30 @@ export function MetricWidgetCard({
 
   const widgetMQL = useMemo(() => getWidgetTitle(metricQueries), [metricQueries]);
 
+  const isTable = widget.displayType === DisplayType.TABLE;
+
+  const {
+    data: timeseriesData,
+    isLoading,
+    isError,
+    error,
+  } = useMetricsQuery(metricQueries, selection, {
+    intervalLadder: widget.displayType === DisplayType.BAR ? 'bar' : 'dashboard',
+  });
+
+  if (isError) {
+    const errorMessage =
+      error?.responseJSON?.detail?.toString() || t('Error while fetching metrics data');
+    return (
+      <Fragment>
+        {renderErrorMessage?.(errorMessage)}
+        <ErrorPanel>
+          <IconWarning color="gray500" size="lg" />
+        </ErrorPanel>
+      </Fragment>
+    );
+  }
+
   return (
     <DashboardsMEPContext.Provider
       value={{
@@ -113,55 +129,37 @@ export function MetricWidgetCard({
             )}
           </ContextMenuWrapper>
         </WidgetHeaderWrapper>
-
-        <MetricWidgetChartContainer
-          metricQueries={metricQueries}
-          selection={selection}
+        <WidgetCardBody
+          isError={isError}
+          noData={timeseriesData?.data.length === 0}
           renderErrorMessage={renderErrorMessage}
-          chartHeight={!showContextMenu ? 200 : undefined}
-          displayType={widget.displayType}
-        />
+          error={error}
+        >
+          {!isTable ? (
+            <MetricChartContainer
+              timeseriesData={timeseriesData}
+              isLoading={isLoading}
+              metricQueries={metricQueries}
+              displayType={toMetricDisplayType(widget.displayType)}
+              chartHeight={!showContextMenu ? 200 : undefined}
+            />
+          ) : (
+            <MetricTableContainer
+              metricQueries={metricQueries}
+              timeseriesData={timeseriesData}
+              isLoading={isLoading}
+            />
+          )}
+        </WidgetCardBody>
+
         {isEditingDashboard && <Toolbar onDelete={onDelete} onDuplicate={onDuplicate} />}
       </WidgetCardPanel>
     </DashboardsMEPContext.Provider>
   );
 }
 
-type MetricWidgetChartContainerProps = {
-  displayType: DisplayType;
-  metricQueries: MetricsQueryApiRequestQuery[];
-  selection: PageFilters;
-  chartHeight?: number;
-  renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
-};
-
-export function MetricWidgetChartContainer({
-  selection,
-  renderErrorMessage,
-  metricQueries,
-  chartHeight,
-  displayType,
-}: MetricWidgetChartContainerProps) {
-  const {
-    data: timeseriesData,
-    isLoading,
-    isError,
-    error,
-  } = useMetricsQuery(metricQueries, selection, {
-    intervalLadder: displayType === DisplayType.BAR ? 'bar' : 'dashboard',
-  });
-
-  const chartRef = useRef<ReactEchartsRef>(null);
-
-  const chartSeries = useMemo(() => {
-    return timeseriesData
-      ? getChartTimeseries(timeseriesData, metricQueries, {
-          getChartPalette: createChartPalette,
-        })
-      : [];
-  }, [timeseriesData, metricQueries]);
-
-  if (isError && !timeseriesData) {
+function WidgetCardBody({children, isError, noData, renderErrorMessage, error}) {
+  if (isError) {
     const errorMessage =
       error?.responseJSON?.detail?.toString() || t('Error while fetching metrics data');
     return (
@@ -174,7 +172,7 @@ export function MetricWidgetChartContainer({
     );
   }
 
-  if (timeseriesData?.data.length === 0) {
+  if (noData) {
     return (
       <EmptyMessage
         icon={<IconSearch size="xxl" />}
@@ -183,21 +181,7 @@ export function MetricWidgetChartContainer({
       />
     );
   }
-
-  return (
-    <MetricWidgetChartWrapper>
-      <TransitionChart loading={isLoading} reloading={isLoading}>
-        <LoadingScreen loading={isLoading} />
-        <MetricChart
-          ref={chartRef}
-          series={chartSeries}
-          displayType={toMetricDisplayType(displayType)}
-          group={DASHBOARD_CHART_GROUP}
-          height={chartHeight}
-        />
-      </TransitionChart>
-    </MetricWidgetChartWrapper>
-  );
+  return children;
 }
 
 const WidgetHeaderWrapper = styled('div')`
@@ -224,10 +208,3 @@ const WidgetTitle = styled(HeaderTitle)`
   ${p => p.theme.overflowEllipsis};
   font-weight: normal;
 `;
-
-const MetricWidgetChartWrapper = styled('div')`
-  height: 100%;
-  width: 100%;
-  padding: ${space(3)};
-  padding-top: ${space(2)};
-`;