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) => 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) => { 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) => onChange(position, data), // }; // } function MetricDashboard() { const {widgets, onChange, addWidget} = useMetricWidgets(); const {selection} = usePageFilters(); const Wrapper = widgets.length === 1 ? StyledSingleWidgetWrapper : StyledMetricDashboard; return ( {widgets.map(widget => ( { onChange(widget.position, data); }, }} datetime={selection.datetime} projects={selection.projects} environments={selection.environments} /> ))} ); } // TODO(ddm): reuse from types/metrics.tsx type Group = { by: Record; series: Record; totals: Record; }; type DisplayProps = MetricWidgetProps & MetricsDataProps; export function MetricWidget({ widget, datetime, projects, environments, }: { datetime: PageFilters['datetime']; environments: PageFilters['environments']; projects: PageFilters['projects']; widget: MetricWidgetProps; }) { return ( ); } function MetricWidgetBody(props?: DisplayProps) { if (!props?.mri) { return ( } title={t('Nothing to show!')} description={t('Choose a metric to display data.')} /> ); } return ; } function MetricWidgetBodyInner({ onChange, displayType, focusedSeries, ...metricsDataProps }: DisplayProps) { const {data, isLoading, isError, error} = useMetricsData(metricsDataProps); const [dataToBeRendered, setDataToBeRendered] = useState( 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 ( {isLoading && } {isError && ( {error?.responseJSON?.detail || t('Error while fetching metrics data')} )} ); } // 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 ( {metricsDataProps.showSummaryTable && ( )} ); } 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 ( {zoomRenderProps => ( {({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 ? ( ) : displayType === MetricDisplayType.AREA ? ( ) : ( ); }} )} ); } 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;