Browse Source

feat(ddm): Multiple widget support (#57282)

Ogi 1 year ago
parent
commit
a8db4d0aa7

+ 2 - 34
static/app/views/ddm/ddm.tsx

@@ -1,7 +1,6 @@
 import styled from '@emotion/styled';
 
 import ButtonBar from 'sentry/components/buttonBar';
-import {CompactSelect} from 'sentry/components/compactSelect';
 import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
 import FeatureBadge from 'sentry/components/featureBadge';
 import {FeatureFeedback} from 'sentry/components/featureFeedback';
@@ -14,14 +13,11 @@ import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionT
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {defaultMetricDisplayType, MetricDisplayType} from 'sentry/utils/metrics';
 import useOrganization from 'sentry/utils/useOrganization';
-import useRouter from 'sentry/utils/useRouter';
-import MetricsExplorer from 'sentry/views/ddm/metricsExplorer';
+import MetricDashboard from 'sentry/views/ddm/metricWidget';
 
 function DDM() {
   const organization = useOrganization();
-  const router = useRouter();
 
   return (
     <SentryDocumentTitle title={t('DDM')} orgSlug={organization.slug}>
@@ -52,36 +48,8 @@ function DDM() {
                   <EnvironmentPageFilter />
                   <DatePageFilter />
                 </PageFilterBar>
-                <CompactSelect
-                  triggerProps={{prefix: t('Display')}}
-                  value={router.location.query.display ?? defaultMetricDisplayType}
-                  options={[
-                    {
-                      value: MetricDisplayType.LINE,
-                      label: t('Line Chart'),
-                    },
-                    {
-                      value: MetricDisplayType.AREA,
-                      label: t('Area Chart'),
-                    },
-                    {
-                      value: MetricDisplayType.BAR,
-                      label: t('Bar Chart'),
-                    },
-                  ]}
-                  onChange={({value}) => {
-                    router.push({
-                      ...router.location,
-                      query: {
-                        ...router.location.query,
-                        cursor: undefined,
-                        display: value,
-                      },
-                    });
-                  }}
-                />
               </PaddedContainer>
-              <MetricsExplorer />
+              <MetricDashboard />
             </Layout.Main>
           </Layout.Body>
         </Layout.Page>

+ 247 - 0
static/app/views/ddm/metricQueryBuilder.tsx

@@ -0,0 +1,247 @@
+import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {CompactSelect} from 'sentry/components/compactSelect';
+import SearchBar from 'sentry/components/events/searchBar';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import Tag from 'sentry/components/tag';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {MetricsTag, SavedSearchType, TagCollection} from 'sentry/types';
+import {
+  defaultMetricDisplayType,
+  getReadableMetricType,
+  getUseCaseFromMri,
+  isAllowedOp,
+  MetricDisplayType,
+  MetricsQuery,
+  useMetricsMeta,
+  useMetricsTags,
+} from 'sentry/utils/metrics';
+import useApi from 'sentry/utils/useApi';
+import useKeyPress from 'sentry/utils/useKeyPress';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {MetricWidgetProps} from 'sentry/views/ddm/metricWidget';
+
+type QueryBuilderProps = {
+  displayType: MetricDisplayType; // TODO(ddm): move display type out of the query builder
+  metricsQuery: MetricsQuery;
+  onChange: (data: Partial<MetricWidgetProps>) => void;
+  projects: number[];
+  powerUserMode?: boolean;
+};
+
+export function QueryBuilder({
+  metricsQuery,
+  projects,
+  displayType,
+  powerUserMode,
+  onChange,
+}: QueryBuilderProps) {
+  const meta = useMetricsMeta(projects);
+  const mriModeKeyPressed = useKeyPress('`', undefined, true);
+  const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names
+
+  useEffect(() => {
+    if (mriModeKeyPressed && !powerUserMode) {
+      setMriMode(!mriMode);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [mriModeKeyPressed, powerUserMode]);
+
+  const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects);
+
+  if (!meta) {
+    return null;
+  }
+
+  return (
+    <QueryBuilderWrapper>
+      <QueryBuilderRow>
+        <WrapPageFilterBar>
+          <CompactSelect
+            searchable
+            triggerProps={{prefix: t('Metric'), size: 'sm'}}
+            options={Object.values(meta)
+              .filter(metric =>
+                mriMode
+                  ? true
+                  : metric.mri.includes(':custom/') || metric.mri === metricsQuery.mri
+              )
+              .map(metric => ({
+                label: mriMode ? metric.mri : metric.name,
+                value: metric.mri,
+                trailingItems: mriMode ? undefined : (
+                  <Fragment>
+                    <Tag tooltipText={t('Type')}>
+                      {getReadableMetricType(metric.type)}
+                    </Tag>
+                    <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
+                  </Fragment>
+                ),
+              }))}
+            value={metricsQuery.mri}
+            onChange={option => {
+              const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
+              const selectedOp = availableOps.includes(metricsQuery.op ?? '')
+                ? metricsQuery.op
+                : availableOps[0];
+              onChange({
+                mri: option.value,
+                op: selectedOp,
+                groupBy: undefined,
+                focusedSeries: undefined,
+              });
+            }}
+          />
+          <CompactSelect
+            triggerProps={{prefix: t('Operation'), size: 'sm'}}
+            options={
+              meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
+                label: op,
+                value: op,
+              })) ?? []
+            }
+            disabled={!metricsQuery.mri}
+            value={metricsQuery.op}
+            onChange={option =>
+              onChange({
+                op: option.value,
+              })
+            }
+          />
+          <CompactSelect
+            multiple
+            triggerProps={{prefix: t('Group by'), size: 'sm'}}
+            options={tags.map(tag => ({
+              label: tag.key,
+              value: tag.key,
+            }))}
+            disabled={!metricsQuery.mri}
+            value={metricsQuery.groupBy}
+            onChange={options =>
+              onChange({
+                groupBy: options.map(o => o.value),
+                focusedSeries: undefined,
+              })
+            }
+          />
+          <CompactSelect
+            triggerProps={{prefix: t('Display'), size: 'sm'}}
+            value={displayType ?? defaultMetricDisplayType}
+            options={[
+              {
+                value: MetricDisplayType.LINE,
+                label: t('Line'),
+              },
+              {
+                value: MetricDisplayType.AREA,
+                label: t('Area'),
+              },
+              {
+                value: MetricDisplayType.BAR,
+                label: t('Bar'),
+              },
+            ]}
+            onChange={({value}) => {
+              onChange({displayType: value});
+            }}
+          />
+        </WrapPageFilterBar>
+      </QueryBuilderRow>
+      <QueryBuilderRow>
+        <MetricSearchBar
+          tags={tags}
+          mri={metricsQuery.mri}
+          disabled={!metricsQuery.mri}
+          onChange={query => onChange({query})}
+          query={metricsQuery.query}
+        />
+      </QueryBuilderRow>
+    </QueryBuilderWrapper>
+  );
+}
+
+type MetricSearchBarProps = {
+  mri: string;
+  onChange: (value: string) => void;
+  tags: MetricsTag[];
+  disabled?: boolean;
+  query?: string;
+};
+
+function MetricSearchBar({tags, mri, disabled, onChange, query}: MetricSearchBarProps) {
+  const org = useOrganization();
+  const api = useApi();
+  const {selection} = usePageFilters();
+
+  const supportedTags: TagCollection = useMemo(
+    () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
+    [tags]
+  );
+
+  // TODO(ddm): try to use useApiQuery here
+  const getTagValues = useCallback(
+    async tag => {
+      const tagsValues = await api.requestPromise(
+        `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
+        {
+          query: {
+            metric: mri,
+            useCase: getUseCaseFromMri(mri),
+            project: selection.projects,
+          },
+        }
+      );
+
+      return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value);
+    },
+    [api, mri, org.slug, selection.projects]
+  );
+
+  const handleChange = useCallback(
+    (value: string, {validSearch} = {validSearch: true}) => {
+      if (validSearch) {
+        onChange(value);
+      }
+    },
+    [onChange]
+  );
+
+  return (
+    <WideSearchBar
+      disabled={disabled}
+      maxMenuHeight={220}
+      organization={org}
+      onGetTagValues={getTagValues}
+      supportedTags={supportedTags}
+      onClose={handleChange}
+      onSearch={handleChange}
+      placeholder={t('Filter by tags')}
+      defaultQuery={query}
+      savedSearchType={SavedSearchType.METRIC}
+    />
+  );
+}
+
+const QueryBuilderWrapper = styled('div')`
+  display: flex;
+  flex-direction: column;
+`;
+
+const QueryBuilderRow = styled('div')`
+  padding: ${space(1)};
+  padding-bottom: 0;
+`;
+
+const WideSearchBar = styled(SearchBar)`
+  width: 100%;
+  opacity: ${p => (p.disabled ? '0.6' : '1')};
+`;
+
+const WrapPageFilterBar = styled(PageFilterBar)`
+  max-width: max-content;
+  height: auto;
+  flex-wrap: wrap;
+`;

+ 534 - 0
static/app/views/ddm/metricWidget.tsx

@@ -0,0 +1,534 @@
+import {Fragment, useEffect, 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';
+import {Button} from 'sentry/components/button';
+import {AreaChart} from 'sentry/components/charts/areaChart';
+import {BarChart} from 'sentry/components/charts/barChart';
+import ChartZoom from 'sentry/components/charts/chartZoom';
+import Legend from 'sentry/components/charts/components/legend';
+import {LineChart} from 'sentry/components/charts/lineChart';
+import ReleaseSeries from 'sentry/components/charts/releaseSeries';
+import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
+import EmptyMessage from 'sentry/components/emptyMessage';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import {IconAdd, IconSearch} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {PageFilters} from 'sentry/types';
+import {
+  defaultMetricDisplayType,
+  formatMetricsUsingUnitAndOp,
+  getNameFromMRI,
+  getUnitFromMRI,
+  MetricDisplayType,
+  MetricsData,
+  MetricsDataProps,
+  MetricsQuery,
+  updateQuery,
+  useMetricsData,
+} from 'sentry/utils/metrics';
+import {decodeList} from 'sentry/utils/queryString';
+import theme from 'sentry/utils/theme';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useRouter from 'sentry/utils/useRouter';
+import {QueryBuilder} from 'sentry/views/ddm/metricQueryBuilder';
+import {SummaryTable} from 'sentry/views/ddm/summaryTable';
+
+const emptyWidget = {
+  mri: '',
+  op: undefined,
+  query: '',
+  groupBy: [],
+  displayType: defaultMetricDisplayType,
+};
+
+export type MetricWidgetDisplayConfig = {
+  displayType: MetricDisplayType;
+  onChange: (data: Partial<MetricWidgetProps>) => void;
+  position: number;
+  focusedSeries?: string;
+  powerUserMode?: boolean;
+  showSummaryTable?: boolean;
+};
+
+export type MetricWidgetProps = MetricsQuery & MetricWidgetDisplayConfig;
+
+function useMetricWidgets() {
+  const router = useRouter();
+
+  const currentWidgets = JSON.parse(
+    router.location.query.widgets ?? JSON.stringify([emptyWidget])
+  );
+
+  const widgets: MetricWidgetProps[] = currentWidgets.map(
+    (widget: MetricWidgetProps, i) => {
+      return {
+        mri: widget.mri,
+        op: widget.op,
+        query: widget.query,
+        groupBy: decodeList(widget.groupBy),
+        displayType: widget.displayType ?? defaultMetricDisplayType,
+        focusedSeries: widget.focusedSeries,
+        showSummaryTable: widget.showSummaryTable ?? true, // temporary default
+        position: widget.position ?? i,
+        powerUserMode: widget.powerUserMode,
+      };
+    }
+  );
+
+  const onChange = (position: number, data: Partial<MetricWidgetProps>) => {
+    currentWidgets[position] = {...currentWidgets[position], ...data};
+
+    updateQuery(router, {
+      widgets: JSON.stringify(currentWidgets),
+    });
+  };
+
+  const addWidget = () => {
+    currentWidgets.push({...emptyWidget, position: currentWidgets.length});
+
+    updateQuery(router, {
+      widgets: JSON.stringify(currentWidgets),
+    });
+  };
+
+  return {
+    widgets,
+    onChange,
+    addWidget,
+  };
+}
+
+// function useMetricWidget(position: number) {
+//   const {widgets, onChange} = useMetricWidgets();
+
+//   return {
+//     widget: widgets[position],
+//     onChange: (data: Partial<MetricWidgetProps>) => onChange(position, data),
+//   };
+// }
+
+function MetricDashboard() {
+  const {widgets, onChange, addWidget} = useMetricWidgets();
+  const {selection} = usePageFilters();
+
+  const Wrapper =
+    widgets.length === 1 ? StyledSingleWidgetWrapper : StyledMetricDashboard;
+
+  return (
+    <Wrapper>
+      {widgets.map(widget => (
+        <MetricWidget
+          key={widget.position}
+          widget={{
+            ...widget,
+            onChange: data => {
+              onChange(widget.position, data);
+            },
+          }}
+          datetime={selection.datetime}
+          projects={selection.projects}
+          environments={selection.environments}
+        />
+      ))}
+      <AddWidgetPanel onClick={addWidget}>
+        <Button priority="primary" icon={<IconAdd isCircled />}>
+          Add widget
+        </Button>
+      </AddWidgetPanel>
+    </Wrapper>
+  );
+}
+
+// TODO(ddm): reuse from types/metrics.tsx
+type Group = {
+  by: Record<string, unknown>;
+  series: Record<string, number[]>;
+  totals: Record<string, number>;
+};
+
+type DisplayProps = MetricWidgetProps & MetricsDataProps;
+
+export function MetricWidget({
+  widget,
+  datetime,
+  projects,
+  environments,
+}: {
+  datetime: PageFilters['datetime'];
+  environments: PageFilters['environments'];
+  projects: PageFilters['projects'];
+  widget: MetricWidgetProps;
+}) {
+  return (
+    <MetricWidgetPanel key={widget.position}>
+      <PanelBody>
+        <QueryBuilder
+          metricsQuery={{
+            mri: widget.mri,
+            query: widget.query,
+            op: widget.op,
+            groupBy: widget.groupBy,
+          }}
+          projects={projects}
+          displayType={widget.displayType}
+          onChange={widget.onChange}
+          powerUserMode={widget.powerUserMode}
+        />
+        <MetricWidgetBody
+          datetime={datetime}
+          projects={projects}
+          environments={environments}
+          {...widget}
+        />
+      </PanelBody>
+    </MetricWidgetPanel>
+  );
+}
+
+function MetricWidgetBody(props?: DisplayProps) {
+  if (!props?.mri) {
+    return (
+      <StyledMetricWidgetBody>
+        <EmptyMessage
+          icon={<IconSearch size="xxl" />}
+          title={t('Nothing to show!')}
+          description={t('Choose a metric to display data.')}
+        />
+      </StyledMetricWidgetBody>
+    );
+  }
+
+  return <MetricWidgetBodyInner {...props} />;
+}
+
+function MetricWidgetBodyInner({
+  onChange,
+  displayType,
+  focusedSeries,
+  ...metricsDataProps
+}: DisplayProps) {
+  const {data, isLoading, isError, error} = useMetricsData(metricsDataProps);
+
+  const [dataToBeRendered, setDataToBeRendered] = useState<MetricsData | undefined>(
+    undefined
+  );
+
+  const [hoveredLegend, setHoveredLegend] = useState('');
+
+  useEffect(() => {
+    if (data) {
+      setDataToBeRendered(data);
+    }
+  }, [data]);
+
+  const toggleSeriesVisibility = (seriesName: string) => {
+    setHoveredLegend('');
+    onChange({
+      focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
+    });
+  };
+
+  if (!dataToBeRendered || isError) {
+    return (
+      <StyledMetricWidgetBody>
+        {isLoading && <LoadingIndicator />}
+        {isError && (
+          <Alert type="error">
+            {error?.responseJSON?.detail || t('Error while fetching metrics data')}
+          </Alert>
+        )}
+      </StyledMetricWidgetBody>
+    );
+  }
+
+  // TODO(ddm): we should move this into the useMetricsData hook
+  const sorted = sortData(dataToBeRendered);
+  const unit = getUnitFromMRI(Object.keys(dataToBeRendered.groups[0]?.series ?? {})[0]); // this assumes that all series have the same unit
+
+  const series = sorted.groups.map(g => {
+    return {
+      values: Object.values(g.series)[0],
+      name: getSeriesName(
+        g,
+        dataToBeRendered.groups.length === 1,
+        metricsDataProps.groupBy
+      ),
+      transaction: g.by.transaction,
+      release: g.by.release,
+    };
+  });
+
+  const colors = theme.charts.getColorPalette(series.length);
+
+  const chartSeries = series.map((item, i) => ({
+    seriesName: item.name,
+    unit,
+    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'],
+  })) as Series[];
+
+  return (
+    <StyledMetricWidgetBody>
+      <TransparentLoadingMask visible={isLoading} />
+      <MetricChart
+        series={chartSeries}
+        displayType={displayType}
+        operation={metricsDataProps.op}
+        projects={metricsDataProps.projects}
+        environments={metricsDataProps.environments}
+        {...normalizeChartTimeParams(sorted)}
+      />
+      {metricsDataProps.showSummaryTable && (
+        <SummaryTable
+          series={chartSeries}
+          operation={metricsDataProps.op}
+          onClick={toggleSeriesVisibility}
+          setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
+        />
+      )}
+    </StyledMetricWidgetBody>
+  );
+}
+
+function getSeriesName(
+  group: Group,
+  isOnlyGroup = false,
+  groupBy: MetricsDataProps['groupBy']
+) {
+  if (isOnlyGroup && !groupBy?.length) {
+    return Object.keys(group.series)?.[0] ?? '(none)';
+  }
+
+  return Object.entries(group.by)
+    .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`)
+    .join(', ');
+}
+
+function sortData(data: MetricsData): MetricsData {
+  if (!data.groups.length) {
+    return data;
+  }
+
+  const key = Object.keys(data.groups[0].totals)[0];
+
+  const sortedGroups = data.groups.sort((a, b) =>
+    a.totals[key] < b.totals[key] ? 1 : -1
+  );
+
+  return {
+    ...data,
+    groups: sortedGroups,
+  };
+}
+
+function normalizeChartTimeParams(data: MetricsData) {
+  const {
+    start,
+    end,
+    utc: utcString,
+    statsPeriod,
+  } = normalizeDateTimeParams(data, {
+    allowEmptyPeriod: true,
+    allowAbsoluteDatetime: true,
+    allowAbsolutePageDatetime: true,
+  });
+
+  const utc = utcString === 'true';
+
+  if (start && end) {
+    return utc
+      ? {
+          start: moment.utc(start).format(),
+          end: moment.utc(end).format(),
+          utc,
+        }
+      : {
+          start: moment(start).utc().format(),
+          end: moment(end).utc().format(),
+          utc,
+        };
+  }
+
+  return {
+    period: statsPeriod ?? '90d',
+  };
+}
+
+export type Series = {
+  color: string;
+  data: {name: string; value: number}[];
+  seriesName: string;
+  unit: string;
+  hidden?: boolean;
+  release?: string;
+  transaction?: string;
+};
+
+type ChartProps = {
+  displayType: MetricDisplayType;
+  environments: PageFilters['environments'];
+  projects: PageFilters['projects'];
+  series: Series[];
+  end?: string;
+  operation?: string;
+  period?: string;
+  start?: string;
+  utc?: boolean;
+};
+
+function MetricChart({
+  series,
+  displayType,
+  start,
+  end,
+  period,
+  utc,
+  operation,
+  projects,
+  environments,
+}: ChartProps) {
+  const unit = series[0]?.unit;
+
+  const seriesToShow = series.filter(s => !s.hidden);
+
+  const chartProps = {
+    isGroupedByDate: true,
+    height: 300,
+    colors: seriesToShow.map(s => s.color),
+    grid: {top: 20, bottom: 20, left: 20, right: 20},
+    tooltip: {
+      valueFormatter: (value: number) => {
+        return formatMetricsUsingUnitAndOp(value, unit, operation);
+      },
+      nameFormatter: mri => getNameFromMRI(mri),
+    },
+    yAxis: {
+      axisLabel: {
+        formatter: (value: number) => {
+          return formatMetricsUsingUnitAndOp(value, unit, operation);
+        },
+      },
+    },
+  };
+
+  return (
+    <Fragment>
+      <ChartZoom period={period} start={start} end={end} utc={utc}>
+        {zoomRenderProps => (
+          <ReleaseSeries
+            utc={utc}
+            period={period}
+            start={zoomRenderProps.start!}
+            end={zoomRenderProps.end!}
+            projects={projects}
+            environments={environments}
+            preserveQueryParams
+          >
+            {({releaseSeries}) => {
+              const legend = releaseSeries[0]?.markLine?.data?.length
+                ? Legend({
+                    itemGap: 20,
+                    top: 0,
+                    right: 20,
+                    data: releaseSeries.map(s => s.seriesName),
+                    theme: theme as Theme,
+                  })
+                : undefined;
+              return displayType === MetricDisplayType.LINE ? (
+                <LineChart
+                  series={[...seriesToShow, ...releaseSeries]}
+                  legend={legend}
+                  {...chartProps}
+                  {...zoomRenderProps}
+                />
+              ) : displayType === MetricDisplayType.AREA ? (
+                <AreaChart
+                  series={[...seriesToShow, ...releaseSeries]}
+                  legend={legend}
+                  {...chartProps}
+                  {...zoomRenderProps}
+                />
+              ) : (
+                <BarChart
+                  stacked
+                  series={[...seriesToShow, ...releaseSeries]}
+                  legend={legend}
+                  {...chartProps}
+                  {...zoomRenderProps}
+                />
+              );
+            }}
+          </ReleaseSeries>
+        )}
+      </ChartZoom>
+    </Fragment>
+  );
+}
+
+const minWidgetWidth = 400;
+
+const MetricWidgetPanel = styled(Panel)`
+  padding-bottom: 0;
+  margin-bottom: 0;
+  min-width: ${minWidgetWidth};
+`;
+
+const StyledMetricWidgetBody = styled('div')`
+  padding: ${space(1)};
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+`;
+
+const StyledMetricDashboard = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(3, minmax(${minWidgetWidth}px, 1fr));
+  gap: ${space(2)};
+  @media (max-width: ${props => props.theme.breakpoints.xxlarge}) {
+    grid-template-columns: repeat(2, minmax(${minWidgetWidth}px, 1fr));
+  }
+  @media (max-width: ${props => props.theme.breakpoints.xlarge}) {
+    grid-template-columns: repeat(1, minmax(${minWidgetWidth}px, 1fr));
+  }
+`;
+
+const StyledSingleWidgetWrapper = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
+`;
+
+const AddWidgetPanel = styled(MetricWidgetPanel)`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  padding: ${space(4)};
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  &:hover {
+    background-color: ${p => p.theme.backgroundSecondary};
+    cursor: pointer;
+  }
+`;
+
+export default MetricDashboard;

+ 32 - 573
static/app/views/ddm/metricsExplorer.tsx

@@ -1,579 +1,38 @@
-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';
-import {AreaChart} from 'sentry/components/charts/areaChart';
-import {BarChart} from 'sentry/components/charts/barChart';
-import ChartZoom from 'sentry/components/charts/chartZoom';
-import Legend from 'sentry/components/charts/components/legend';
-import {LineChart} from 'sentry/components/charts/lineChart';
-import ReleaseSeries from 'sentry/components/charts/releaseSeries';
-import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
-import {CompactSelect} from 'sentry/components/compactSelect';
-import EmptyMessage from 'sentry/components/emptyMessage';
-import SearchBar from 'sentry/components/events/searchBar';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
-import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
-import Panel from 'sentry/components/panels/panel';
-import PanelBody from 'sentry/components/panels/panelBody';
-import Tag from 'sentry/components/tag';
-import {IconSearch} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {MetricsTag, PageFilters, SavedSearchType, TagCollection} from 'sentry/types';
-import {
-  defaultMetricDisplayType,
-  formatMetricsUsingUnitAndOp,
-  getNameFromMRI,
-  getReadableMetricType,
-  getUnitFromMRI,
-  getUseCaseFromMri,
-  isAllowedOp,
-  MetricDisplayType,
-  MetricsData,
-  MetricsDataProps,
-  MetricsQuery,
-  updateQuery,
-  useMetricsData,
-  useMetricsMeta,
-  useMetricsTags,
-} from 'sentry/utils/metrics';
-import {decodeList} from 'sentry/utils/queryString';
-import theme from 'sentry/utils/theme';
-import useApi from 'sentry/utils/useApi';
-import useKeyPress from 'sentry/utils/useKeyPress';
-import useOrganization from 'sentry/utils/useOrganization';
-import usePageFilters from 'sentry/utils/usePageFilters';
-import useRouter from 'sentry/utils/useRouter';
-import {SummaryTable} from 'sentry/views/ddm/summaryTable';
-
-function MetricsExplorer() {
-  const {selection} = usePageFilters();
-
-  const router = useRouter();
-
-  const metricsQuery: MetricsQuery = {
-    mri: router.location.query.mri,
-    op: router.location.query.op,
-    query: router.location.query.query,
-    groupBy: decodeList(router.location.query.groupBy),
-  };
-
-  return (
-    <MetricsExplorerPanel>
-      <PanelBody>
-        <QueryBuilder metricsQuery={metricsQuery} />
-        <MetricsExplorerDisplayOuter
-          displayType={router.location.query.display ?? defaultMetricDisplayType}
-          datetime={selection.datetime}
-          projects={selection.projects}
-          environments={selection.environments}
-          {...metricsQuery}
-        />
-      </PanelBody>
-    </MetricsExplorerPanel>
-  );
-}
-
-type QueryBuilderProps = {
-  metricsQuery: MetricsQuery;
-};
-
-function QueryBuilder({metricsQuery}: QueryBuilderProps) {
-  const router = useRouter();
-  const {selection} = usePageFilters();
-  const meta = useMetricsMeta(selection.projects);
-  const mriModeKeyPressed = useKeyPress('`', undefined, true);
-  const [mriMode, setMriMode] = useState(false); // power user mode that shows raw MRI instead of metrics names
-
-  useEffect(() => {
-    if (mriModeKeyPressed) {
-      setMriMode(!mriMode);
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [mriModeKeyPressed]);
-
-  const {data: tags = []} = useMetricsTags(metricsQuery.mri, selection.projects);
-
-  if (!meta) {
-    return null;
-  }
-
-  return (
-    <QueryBuilderWrapper>
-      <QueryBuilderRow>
-        <PageFilterBar condensed>
-          <CompactSelect
-            searchable
-            triggerProps={{prefix: t('Metric'), size: 'sm'}}
-            options={Object.values(meta)
-              .filter(metric =>
-                mriMode
-                  ? true
-                  : metric.mri.includes(':custom/') || metric.mri === metricsQuery.mri
-              )
-              .map(metric => ({
-                label: mriMode ? metric.mri : metric.name,
-                value: metric.mri,
-                trailingItems: mriMode ? undefined : (
-                  <Fragment>
-                    <Tag tooltipText={t('Type')}>
-                      {getReadableMetricType(metric.type)}
-                    </Tag>
-                    <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
-                  </Fragment>
-                ),
-              }))}
-            value={metricsQuery.mri}
-            onChange={option => {
-              const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
-              const selectedOp = availableOps.includes(metricsQuery.op ?? '')
-                ? metricsQuery.op
-                : availableOps[0];
-              updateQuery(router, {
-                mri: option.value,
-                op: selectedOp,
-                groupBy: undefined,
-                focusedSeries: undefined,
-              });
-            }}
-          />
-          <CompactSelect
-            triggerProps={{prefix: t('Operation'), size: 'sm'}}
-            options={
-              meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
-                label: op,
-                value: op,
-              })) ?? []
-            }
-            disabled={!metricsQuery.mri}
-            value={metricsQuery.op}
-            onChange={option =>
-              updateQuery(router, {
-                op: option.value,
-              })
-            }
-          />
-          <CompactSelect
-            multiple
-            triggerProps={{prefix: t('Group by'), size: 'sm'}}
-            options={tags.map(tag => ({
-              label: tag.key,
-              value: tag.key,
-            }))}
-            disabled={!metricsQuery.mri}
-            value={metricsQuery.groupBy}
-            onChange={options =>
-              updateQuery(router, {
-                groupBy: options.map(o => o.value),
-                focusedSeries: undefined,
-              })
-            }
-          />
-        </PageFilterBar>
-      </QueryBuilderRow>
-      <QueryBuilderRow>
-        <MetricSearchBar
-          tags={tags}
-          mri={metricsQuery.mri}
-          disabled={!metricsQuery.mri}
-          onChange={query => updateQuery(router, {query})}
-          query={metricsQuery.query}
-        />
-      </QueryBuilderRow>
-    </QueryBuilderWrapper>
-  );
-}
-
-type MetricSearchBarProps = {
-  mri: string;
-  onChange: (value: string) => void;
-  tags: MetricsTag[];
-  disabled?: boolean;
-  query?: string;
-};
-
-function MetricSearchBar({tags, mri, disabled, onChange, query}: MetricSearchBarProps) {
-  const org = useOrganization();
-  const api = useApi();
-  const {selection} = usePageFilters();
-
-  const supportedTags: TagCollection = useMemo(
-    () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
-    [tags]
-  );
-
-  // TODO(ogi) try to use useApiQuery here
-  const getTagValues = useCallback(
-    async tag => {
-      const tagsValues = await api.requestPromise(
-        `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
-        {
-          query: {
-            metric: mri,
-            useCase: getUseCaseFromMri(mri),
-            project: selection.projects,
-          },
-        }
-      );
-
-      return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value);
-    },
-    [api, mri, org.slug, selection.projects]
-  );
-
-  const handleChange = useCallback(
-    (value: string, {validSearch} = {validSearch: true}) => {
-      if (validSearch) {
-        onChange(value);
-      }
-    },
-    [onChange]
-  );
-
-  return (
-    <WideSearchBar
-      disabled={disabled}
-      maxMenuHeight={220}
-      organization={org}
-      onGetTagValues={getTagValues}
-      supportedTags={supportedTags}
-      onClose={handleChange}
-      onSearch={handleChange}
-      placeholder={t('Filter by tags')}
-      defaultQuery={query}
-      savedSearchType={SavedSearchType.METRIC}
-    />
-  );
-}
-
-const QueryBuilderWrapper = styled('div')`
-  display: flex;
-  flex-direction: column;
-`;
-
-const QueryBuilderRow = styled('div')`
-  padding: ${space(1)};
-  padding-bottom: 0;
-`;
-
-const WideSearchBar = styled(SearchBar)`
-  width: 100%;
-  opacity: ${p => (p.disabled ? '0.6' : '1')};
-`;
-
-// TODO(ddm): reuse from types/metrics.tsx
-type Group = {
-  by: Record<string, unknown>;
-  series: Record<string, number[]>;
-  totals: Record<string, number>;
-};
-
-type DisplayProps = MetricsDataProps & {
-  displayType: MetricDisplayType;
-};
-
-function MetricsExplorerDisplayOuter(props?: DisplayProps) {
-  if (!props?.mri) {
-    return (
-      <DisplayWrapper>
-        <EmptyMessage
-          icon={<IconSearch size="xxl" />}
-          title={t('Nothing to show!')}
-          description={t('Choose a metric to display data.')}
-        />
-      </DisplayWrapper>
-    );
-  }
-  return <MetricsExplorerDisplay {...props} />;
-}
-
-function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) {
-  const router = useRouter();
-  const {data, isLoading, isError, error} = useMetricsData(metricsDataProps);
-  const [dataToBeRendered, setDataToBeRendered] = useState<MetricsData | undefined>(
-    undefined
-  );
-  const focusedSeries = router.location.query.focusedSeries;
-  const [hoveredLegend, setHoveredLegend] = useState('');
-
-  useEffect(() => {
-    if (data) {
-      setDataToBeRendered(data);
-    }
-  }, [data]);
-
-  const toggleSeriesVisibility = (seriesName: string) => {
-    setHoveredLegend('');
-    router.push({
-      ...router.location,
-      query: {
-        ...router.location.query,
-        focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
-      },
-    });
-  };
-
-  if (!dataToBeRendered || isError) {
-    return (
-      <DisplayWrapper>
-        {isLoading && <LoadingIndicator />}
-        {isError && (
-          <Alert type="error">
-            {error?.responseJSON?.detail || t('Error while fetching metrics data')}
-          </Alert>
-        )}
-      </DisplayWrapper>
-    );
-  }
-
-  // TODO(ddm): we should move this into the useMetricsData hook
-  const sorted = sortData(dataToBeRendered);
-  const unit = getUnitFromMRI(Object.keys(dataToBeRendered.groups[0]?.series ?? {})[0]); // this assumes that all series have the same unit
-
-  const series = sorted.groups.map(g => {
-    return {
-      values: Object.values(g.series)[0],
-      name: getSeriesName(
-        g,
-        dataToBeRendered.groups.length === 1,
-        metricsDataProps.groupBy
-      ),
-      transaction: g.by.transaction,
-      release: g.by.release,
-    };
+import {useState} from 'react';
+
+import {MetricDisplayType} from 'sentry/utils/metrics';
+import {MetricWidget, MetricWidgetProps} from 'sentry/views/ddm/metricWidget';
+
+// TODO(ddm): move this to admin
+export default function MetricsExplorer() {
+  const [widget, setWidget] = useState<MetricWidgetProps>({
+    mri: '',
+    op: undefined,
+    query: '',
+    groupBy: [],
+    displayType: MetricDisplayType.LINE,
+    position: 0,
+    powerUserMode: true,
+    showSummaryTable: true,
+    onChange: () => {},
   });
 
-  const colors = theme.charts.getColorPalette(series.length);
-
-  const chartSeries = series.map((item, i) => ({
-    seriesName: item.name,
-    unit,
-    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 (
-    <DisplayWrapper>
-      <TransparentLoadingMask visible={isLoading} />
-      <Chart
-        series={chartSeries}
-        displayType={displayType}
-        operation={metricsDataProps.op}
-        projects={metricsDataProps.projects}
-        environments={metricsDataProps.environments}
-        {...normalizeChartTimeParams(sorted)}
-      />
-      <SummaryTable
-        series={chartSeries}
-        operation={metricsDataProps.op}
-        onClick={toggleSeriesVisibility}
-        setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
-      />
-    </DisplayWrapper>
-  );
-}
-
-function getSeriesName(
-  group: Group,
-  isOnlyGroup = false,
-  groupBy: MetricsDataProps['groupBy']
-) {
-  if (isOnlyGroup && !groupBy?.length) {
-    return Object.keys(group.series)?.[0] ?? '(none)';
-  }
-
-  return Object.entries(group.by)
-    .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`)
-    .join(', ');
-}
-
-function sortData(data: MetricsData): MetricsData {
-  if (!data.groups.length) {
-    return data;
-  }
-
-  const key = Object.keys(data.groups[0].totals)[0];
-
-  const sortedGroups = data.groups.sort((a, b) =>
-    a.totals[key] < b.totals[key] ? 1 : -1
-  );
-
-  return {
-    ...data,
-    groups: sortedGroups,
-  };
-}
-
-function normalizeChartTimeParams(data: MetricsData) {
-  const {
-    start,
-    end,
-    utc: utcString,
-    statsPeriod,
-  } = normalizeDateTimeParams(data, {
-    allowEmptyPeriod: true,
-    allowAbsoluteDatetime: true,
-    allowAbsolutePageDatetime: true,
-  });
-
-  const utc = utcString === 'true';
-
-  if (start && end) {
-    return utc
-      ? {
-          start: moment.utc(start).format(),
-          end: moment.utc(end).format(),
-          utc,
-        }
-      : {
-          start: moment(start).utc().format(),
-          end: moment(end).utc().format(),
-          utc,
-        };
-  }
-
-  return {
-    period: statsPeriod ?? '90d',
-  };
-}
-
-export type Series = {
-  color: string;
-  data: {name: string; value: number}[];
-  seriesName: string;
-  unit: string;
-  hidden?: boolean;
-  release?: string;
-  transaction?: string;
-};
-
-type ChartProps = {
-  displayType: MetricDisplayType;
-  environments: PageFilters['environments'];
-  projects: PageFilters['projects'];
-  series: Series[];
-  end?: string;
-  operation?: string;
-  period?: string;
-  start?: string;
-  utc?: boolean;
-};
-
-function Chart({
-  series,
-  displayType,
-  start,
-  end,
-  period,
-  utc,
-  operation,
-  projects,
-  environments,
-}: ChartProps) {
-  const unit = series[0]?.unit;
-
-  const seriesToShow = series.filter(s => !s.hidden);
-
-  const chartProps = {
-    isGroupedByDate: true,
-    height: 300,
-    colors: seriesToShow.map(s => s.color),
-    grid: {top: 20, bottom: 20, left: 20, right: 20},
-    tooltip: {
-      valueFormatter: (value: number) => {
-        return formatMetricsUsingUnitAndOp(value, unit, operation);
-      },
-      nameFormatter: mri => getNameFromMRI(mri),
-    },
-    yAxis: {
-      axisLabel: {
-        formatter: (value: number) => {
-          return formatMetricsUsingUnitAndOp(value, unit, operation);
+    <MetricWidget
+      widget={{
+        ...widget,
+        onChange: data => {
+          setWidget(curr => ({...curr, ...data}));
         },
-      },
-    },
-  };
-
-  return (
-    <Fragment>
-      <ChartZoom period={period} start={start} end={end} utc={utc}>
-        {zoomRenderProps => (
-          <ReleaseSeries
-            utc={utc}
-            period={period}
-            start={zoomRenderProps.start!}
-            end={zoomRenderProps.end!}
-            projects={projects}
-            environments={environments}
-            preserveQueryParams
-          >
-            {({releaseSeries}) => {
-              const legend = releaseSeries[0]?.markLine?.data?.length
-                ? Legend({
-                    itemGap: 20,
-                    top: 0,
-                    right: 20,
-                    data: releaseSeries.map(s => s.seriesName),
-                    theme: theme as Theme,
-                  })
-                : undefined;
-              return displayType === MetricDisplayType.LINE ? (
-                <LineChart
-                  series={[...seriesToShow, ...releaseSeries]}
-                  legend={legend}
-                  {...chartProps}
-                  {...zoomRenderProps}
-                />
-              ) : displayType === MetricDisplayType.AREA ? (
-                <AreaChart
-                  series={[...seriesToShow, ...releaseSeries]}
-                  legend={legend}
-                  {...chartProps}
-                  {...zoomRenderProps}
-                />
-              ) : (
-                <BarChart
-                  stacked
-                  series={[...seriesToShow, ...releaseSeries]}
-                  legend={legend}
-                  {...chartProps}
-                  {...zoomRenderProps}
-                />
-              );
-            }}
-          </ReleaseSeries>
-        )}
-      </ChartZoom>
-    </Fragment>
+      }}
+      datetime={{
+        start: null,
+        end: null,
+        period: '7d',
+        utc: false,
+      }}
+      projects={[]}
+      environments={[]}
+    />
   );
 }
-
-const MetricsExplorerPanel = styled(Panel)`
-  padding-bottom: 0;
-`;
-
-const DisplayWrapper = styled('div')`
-  padding: ${space(1)};
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-`;
-
-export default MetricsExplorer;

+ 9 - 3
static/app/views/ddm/summaryTable.tsx

@@ -13,7 +13,7 @@ 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 {Series} from 'sentry/views/ddm/metricWidget';
 import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
 
 export function SummaryTable({
@@ -89,7 +89,7 @@ export function SummaryTable({
                 <Cell>
                   <ColorDot color={color} isHidden={!!hidden} />
                 </Cell>
-                <Cell>{getNameFromMRI(seriesName)}</Cell>
+                <TextOverflowCell>{getNameFromMRI(seriesName)}</TextOverflowCell>
                 {/* 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>
@@ -181,6 +181,12 @@ const Cell = styled('div')<{right?: boolean}>`
   justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
 `;
 
+const TextOverflowCell = styled(Cell)`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`;
+
 const ColorDot = styled(`div`)<{color: string; isHidden: boolean}>`
   background-color: ${p =>
     p.isHidden ? 'transparent' : colorFn(p.color).alpha(1).string()};
@@ -194,7 +200,7 @@ const CellWrapper = styled('div')`
   display: contents;
   &:hover {
     cursor: pointer;
-    ${Cell} {
+    ${Cell}, ${TextOverflowCell} {
       background-color: ${p => p.theme.bodyBackground};
     }
   }