123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681 |
- import type React from 'react';
- import {Component} from 'react';
- import type {Theme} from '@emotion/react';
- import {withTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import type {DataZoomComponentOption, LegendComponentOption} from 'echarts';
- import type {Location} from 'history';
- import isEqual from 'lodash/isEqual';
- import omit from 'lodash/omit';
- import {AreaChart} from 'sentry/components/charts/areaChart';
- import {BarChart} from 'sentry/components/charts/barChart';
- import ChartZoom from 'sentry/components/charts/chartZoom';
- import ErrorPanel from 'sentry/components/charts/errorPanel';
- import {LineChart} from 'sentry/components/charts/lineChart';
- import ReleaseSeries from 'sentry/components/charts/releaseSeries';
- import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
- import TransitionChart from 'sentry/components/charts/transitionChart';
- import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
- import {getSeriesSelection, isChartHovered} from 'sentry/components/charts/utils';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import type {PlaceholderProps} from 'sentry/components/placeholder';
- import Placeholder from 'sentry/components/placeholder';
- import {IconWarning} from 'sentry/icons';
- import {space} from 'sentry/styles/space';
- import type {PageFilters} from 'sentry/types/core';
- import type {
- EChartDataZoomHandler,
- EChartEventHandler,
- ReactEchartsRef,
- } from 'sentry/types/echarts';
- import type {Organization} from 'sentry/types/organization';
- import {defined} from 'sentry/utils';
- import {
- axisLabelFormatter,
- axisLabelFormatterUsingAggregateOutputType,
- getDurationUnit,
- tooltipFormatter,
- } from 'sentry/utils/discover/charts';
- import type {EventsMetaType} from 'sentry/utils/discover/eventView';
- import type {AggregationOutputType} from 'sentry/utils/discover/fields';
- import {
- aggregateOutputType,
- getAggregateArg,
- getEquation,
- getMeasurementSlug,
- isEquation,
- maybeEquationAlias,
- stripDerivedMetricsPrefix,
- stripEquationPrefix,
- } from 'sentry/utils/discover/fields';
- import getDynamicText from 'sentry/utils/getDynamicText';
- import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
- import {getBucketSize} from 'sentry/views/dashboards/widgetCard/utils';
- import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
- import {getFormatter} from '../../../components/charts/components/tooltip';
- import {getDatasetConfig} from '../datasetConfig/base';
- import type {Widget} from '../types';
- import {DisplayType} from '../types';
- import type WidgetLegendSelectionState from '../widgetLegendSelectionState';
- import {BigNumberWidgetVisualization} from '../widgets/bigNumberWidget/bigNumberWidgetVisualization';
- import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
- const OTHER = 'Other';
- const PERCENTAGE_DECIMAL_POINTS = 3;
- export const SLIDER_HEIGHT = 60;
- export type AugmentedEChartDataZoomHandler = (
- params: Parameters<EChartDataZoomHandler>[0] & {
- seriesEnd: string | number;
- seriesStart: string | number;
- },
- instance: Parameters<EChartDataZoomHandler>[1]
- ) => void;
- type TableResultProps = Pick<
- GenericWidgetQueriesChildrenProps,
- 'errorMessage' | 'loading' | 'tableResults'
- >;
- type WidgetCardChartProps = Pick<
- GenericWidgetQueriesChildrenProps,
- 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
- > & {
- location: Location;
- organization: Organization;
- selection: PageFilters;
- theme: Theme;
- widget: Widget;
- widgetLegendState: WidgetLegendSelectionState;
- chartGroup?: string;
- chartZoomOptions?: DataZoomComponentOption;
- expandNumbers?: boolean;
- isMobile?: boolean;
- legendOptions?: LegendComponentOption;
- noPadding?: boolean;
- onLegendSelectChanged?: EChartEventHandler<{
- name: string;
- selected: Record<string, boolean>;
- type: 'legendselectchanged';
- }>;
- onZoom?: AugmentedEChartDataZoomHandler;
- shouldResize?: boolean;
- showSlider?: boolean;
- timeseriesResultsTypes?: Record<string, AggregationOutputType>;
- windowWidth?: number;
- };
- class WidgetCardChart extends Component<WidgetCardChartProps> {
- shouldComponentUpdate(nextProps: WidgetCardChartProps): boolean {
- if (
- this.props.widget.displayType === DisplayType.BIG_NUMBER &&
- nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
- (this.props.windowWidth !== nextProps.windowWidth ||
- !isEqual(this.props.widget?.layout, nextProps.widget?.layout))
- ) {
- return true;
- }
- // Widget title changes should not update the WidgetCardChart component tree
- const currentProps = {
- ...omit(this.props, ['windowWidth']),
- widget: {
- ...this.props.widget,
- title: '',
- },
- };
- nextProps = {
- ...omit(nextProps, ['windowWidth']),
- widget: {
- ...nextProps.widget,
- title: '',
- },
- };
- return !isEqual(currentProps, nextProps);
- }
- tableResultComponent({
- loading,
- errorMessage,
- tableResults,
- }: TableResultProps): React.ReactNode {
- const {location, widget, selection} = this.props;
- if (errorMessage) {
- return (
- <StyledErrorPanel>
- <IconWarning color="gray500" size="lg" />
- </StyledErrorPanel>
- );
- }
- if (typeof tableResults === 'undefined') {
- // Align height to other charts.
- return <LoadingPlaceholder />;
- }
- const datasetConfig = getDatasetConfig(widget.widgetType);
- return tableResults.map((result, i) => {
- const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
- const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
- const eventView = eventViewFromWidget(widget.title, widget.queries[0], selection);
- return (
- <TableWrapper key={`table:${result.title}`}>
- <StyledSimpleTableChart
- eventView={eventView}
- fieldAliases={fieldAliases}
- location={location}
- fields={fields}
- title={tableResults.length > 1 ? result.title : ''}
- loading={loading}
- loader={<LoadingPlaceholder />}
- metadata={result.meta}
- data={result.data}
- stickyHeaders
- fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
- getCustomFieldRenderer={datasetConfig.getCustomFieldRenderer}
- />
- </TableWrapper>
- );
- });
- }
- bigNumberComponent({
- loading,
- errorMessage,
- tableResults,
- }: TableResultProps): React.ReactNode {
- if (errorMessage) {
- return (
- <StyledErrorPanel>
- <IconWarning color="gray500" size="lg" />
- </StyledErrorPanel>
- );
- }
- if (typeof tableResults === 'undefined' || loading) {
- return <BigNumber>{'\u2014'}</BigNumber>;
- }
- const {widget} = this.props;
- return tableResults.map((result, i) => {
- const tableMeta = {...result.meta};
- const fields = Object.keys(tableMeta?.fields ?? {});
- let field = fields[0];
- let selectedField = field;
- if (defined(widget.queries[0].selectedAggregate)) {
- const index = widget.queries[0].selectedAggregate;
- selectedField = widget.queries[0].aggregates[index];
- if (fields.includes(selectedField)) {
- field = selectedField;
- }
- }
- const data = result?.data;
- const meta = result?.meta as EventsMetaType;
- const value = data?.[0]?.[selectedField];
- if (
- !field ||
- !result.data?.length ||
- selectedField === 'equation|' ||
- selectedField === '' ||
- !defined(value) ||
- !Number.isFinite(value) ||
- Number.isNaN(value)
- ) {
- return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
- }
- return (
- <BigNumberWidgetVisualization
- key={i}
- field={field}
- value={value}
- meta={meta}
- thresholds={widget.thresholds ?? undefined}
- preferredPolarity="-"
- />
- );
- });
- }
- chartRef: ReactEchartsRef | null = null;
- handleRef = (chartRef: ReactEchartsRef): void => {
- if (chartRef && !this.chartRef) {
- this.chartRef = chartRef;
- // add chart to the group so that it has synced cursors
- const instance = chartRef.getEchartsInstance?.();
- if (instance && !instance.group && this.props.chartGroup) {
- instance.group = this.props.chartGroup;
- }
- }
- if (!chartRef) {
- this.chartRef = null;
- }
- };
- chartComponent(chartProps): React.ReactNode {
- const {widget} = this.props;
- const stacked = widget.queries[0]?.columns.length > 0;
- switch (widget.displayType) {
- case 'bar':
- return <BarChart {...chartProps} stacked={stacked} />;
- case 'area':
- case 'top_n':
- return <AreaChart stacked {...chartProps} />;
- case 'line':
- default:
- return <LineChart {...chartProps} />;
- }
- }
- render() {
- const {
- theme,
- tableResults,
- timeseriesResults,
- errorMessage,
- loading,
- widget,
- onZoom,
- legendOptions,
- showSlider,
- noPadding,
- chartZoomOptions,
- timeseriesResultsTypes,
- shouldResize,
- organization,
- } = this.props;
- if (widget.displayType === 'table') {
- return getDynamicText({
- value: (
- <TransitionChart loading={loading} reloading={loading}>
- <LoadingScreen loading={loading} />
- {this.tableResultComponent({tableResults, loading, errorMessage})}
- </TransitionChart>
- ),
- fixed: <Placeholder height="200px" testId="skeleton-ui" />,
- });
- }
- if (widget.displayType === 'big_number') {
- return (
- <TransitionChart loading={loading} reloading={loading}>
- <LoadingScreen loading={loading} />
- <BigNumberResizeWrapper>
- {this.bigNumberComponent({tableResults, loading, errorMessage})}
- </BigNumberResizeWrapper>
- </TransitionChart>
- );
- }
- if (errorMessage) {
- return (
- <StyledErrorPanel>
- <IconWarning color="gray500" size="lg" />
- </StyledErrorPanel>
- );
- }
- const {location, selection, onLegendSelectChanged, widgetLegendState} = this.props;
- const {start, end, period, utc} = selection.datetime;
- const {projects, environments} = selection;
- const legend = {
- left: 0,
- top: 0,
- selected: getSeriesSelection(location),
- formatter: (seriesName: string) => {
- seriesName = WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName);
- const arg = getAggregateArg(seriesName);
- if (arg !== null) {
- const slug = getMeasurementSlug(arg);
- if (slug !== null) {
- seriesName = slug.toUpperCase();
- }
- }
- if (maybeEquationAlias(seriesName)) {
- seriesName = stripEquationPrefix(seriesName);
- }
- return seriesName;
- },
- ...legendOptions,
- };
- const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
- const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
- // Check to see if all series output types are the same. If not, then default to number.
- const outputType =
- timeseriesResultsTypes && new Set(Object.values(timeseriesResultsTypes)).size === 1
- ? timeseriesResultsTypes[axisLabel]
- : 'number';
- const isDurationChart = outputType === 'duration';
- const durationUnit = isDurationChart
- ? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
- : undefined;
- const bucketSize = getBucketSize(timeseriesResults);
- const valueFormatter = (value: number, seriesName?: string) => {
- const decodedSeriesName = seriesName
- ? WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName)
- : seriesName;
- const aggregateName = decodedSeriesName?.split(':').pop()?.trim();
- if (aggregateName) {
- return timeseriesResultsTypes
- ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
- : tooltipFormatter(value, aggregateOutputType(aggregateName));
- }
- return tooltipFormatter(value, 'number');
- };
- const nameFormatter = (name: string) => {
- return WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(name);
- };
- const chartOptions = {
- autoHeightResize: shouldResize ?? true,
- grid: {
- left: 0,
- right: 4,
- top: '40px',
- bottom: showSlider ? SLIDER_HEIGHT : 0,
- },
- seriesOptions: {
- showSymbol: false,
- },
- tooltip: {
- trigger: 'axis',
- axisPointer: {
- type: 'cross',
- },
- formatter: (params, asyncTicket) => {
- const {chartGroup} = this.props;
- const isInGroup =
- chartGroup && chartGroup === this.chartRef?.getEchartsInstance().group;
- // tooltip is triggered whenever any chart in the group is hovered,
- // so we need to check if the mouse is actually over this chart
- if (isInGroup && !isChartHovered(this.chartRef)) {
- return '';
- }
- return getFormatter({
- valueFormatter,
- nameFormatter,
- isGroupedByDate: true,
- bucketSize,
- addSecondsToTimeFormat: false,
- showTimeInTooltip: true,
- })(params, asyncTicket);
- },
- },
- yAxis: {
- axisLabel: {
- color: theme.chartLabel,
- formatter: (value: number) => {
- if (timeseriesResultsTypes) {
- return axisLabelFormatterUsingAggregateOutputType(
- value,
- outputType,
- true,
- durationUnit,
- undefined,
- PERCENTAGE_DECIMAL_POINTS
- );
- }
- return axisLabelFormatter(
- value,
- aggregateOutputType(axisLabel),
- true,
- undefined,
- undefined,
- PERCENTAGE_DECIMAL_POINTS
- );
- },
- },
- axisPointer: {
- type: 'line',
- snap: false,
- lineStyle: {
- type: 'solid',
- width: 0.5,
- },
- label: {
- show: false,
- },
- },
- minInterval: durationUnit ?? 0,
- },
- xAxis: {
- axisPointer: {
- snap: true,
- },
- },
- };
- return (
- <ChartZoom
- period={period}
- start={start}
- end={end}
- utc={utc}
- showSlider={showSlider}
- chartZoomOptions={chartZoomOptions}
- >
- {zoomRenderProps => {
- if (errorMessage) {
- return (
- <StyledErrorPanel>
- <IconWarning color="gray500" size="lg" />
- </StyledErrorPanel>
- );
- }
- const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
- const shouldColorOther = timeseriesResults?.some(({seriesName}) =>
- seriesName?.match(otherRegex)
- );
- const colors = timeseriesResults
- ? theme.charts.getColorPalette(
- timeseriesResults.length - (shouldColorOther ? 3 : 2)
- )
- : [];
- // TODO(wmak): Need to change this when updating dashboards to support variable topEvents
- if (shouldColorOther) {
- colors[colors.length] = theme.chartOther;
- }
- // Create a list of series based on the order of the fields,
- const series = timeseriesResults
- ? timeseriesResults
- .map((values, i: number) => {
- let seriesName = '';
- if (values.seriesName !== undefined) {
- seriesName = isEquation(values.seriesName)
- ? getEquation(values.seriesName)
- : values.seriesName;
- }
- return {
- ...values,
- seriesName,
- color: colors[i],
- };
- })
- .filter(Boolean) // NOTE: `timeseriesResults` is a sparse array! We have to filter out the empty slots after the colors are assigned, since the colours are assigned based on sparse array index
- : [];
- const seriesStart = series[0]?.data[0]?.name;
- const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
- const forwardedRef = this.props.chartGroup ? this.handleRef : undefined;
- return organization.features.includes('dashboards-releases-on-charts') &&
- widgetLegendState.widgetRequiresLegendUnselection(widget) ? (
- <ReleaseSeries
- end={end}
- start={start}
- period={period}
- environments={environments}
- projects={projects}
- memoized
- >
- {({releaseSeries}) => {
- // make series name into seriesName:widgetId form for individual widget legend control
- // NOTE: e-charts legends control all charts that have the same series name so attaching
- // widget id will differentiate the charts allowing them to be controlled individually
- const modifiedReleaseSeriesResults =
- WidgetLegendNameEncoderDecoder.modifyTimeseriesNames(
- widget,
- releaseSeries
- );
- return (
- <TransitionChart loading={loading} reloading={loading}>
- <LoadingScreen loading={loading} />
- <ChartWrapper
- autoHeightResize={shouldResize ?? true}
- noPadding={noPadding}
- >
- {getDynamicText({
- value: this.chartComponent({
- ...zoomRenderProps,
- ...chartOptions,
- // Override default datazoom behaviour for updating Global Selection Header
- ...(onZoom
- ? {
- onDataZoom: (evt, chartProps) =>
- // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
- // callback with percentage instead of datetime values. Passing seriesStart
- // and seriesEnd allows calculating datetime values with percentage.
- onZoom({...evt, seriesStart, seriesEnd}, chartProps),
- }
- : {}),
- legend,
- series: [...series, ...(modifiedReleaseSeriesResults ?? [])],
- onLegendSelectChanged,
- forwardedRef,
- }),
- fixed: <Placeholder height="200px" testId="skeleton-ui" />,
- })}
- </ChartWrapper>
- </TransitionChart>
- );
- }}
- </ReleaseSeries>
- ) : (
- <TransitionChart loading={loading} reloading={loading}>
- <LoadingScreen loading={loading} />
- <ChartWrapper autoHeightResize={shouldResize ?? true} noPadding={noPadding}>
- {getDynamicText({
- value: this.chartComponent({
- ...zoomRenderProps,
- ...chartOptions,
- // Override default datazoom behaviour for updating Global Selection Header
- ...(onZoom
- ? {
- onDataZoom: (evt, chartProps) =>
- // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
- // callback with percentage instead of datetime values. Passing seriesStart
- // and seriesEnd allows calculating datetime values with percentage.
- onZoom({...evt, seriesStart, seriesEnd}, chartProps),
- }
- : {}),
- legend,
- series,
- onLegendSelectChanged,
- forwardedRef,
- }),
- fixed: <Placeholder height="200px" testId="skeleton-ui" />,
- })}
- </ChartWrapper>
- </TransitionChart>
- );
- }}
- </ChartZoom>
- );
- }
- }
- export default withTheme(WidgetCardChart);
- const StyledTransparentLoadingMask = styled(props => (
- <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
- ))`
- display: flex;
- justify-content: center;
- align-items: center;
- `;
- function LoadingScreen({loading}: {loading: boolean}) {
- if (!loading) {
- return null;
- }
- return (
- <StyledTransparentLoadingMask visible={loading}>
- <LoadingIndicator mini />
- </StyledTransparentLoadingMask>
- );
- }
- const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
- <Placeholder height="200px" className={className} />
- ))`
- background-color: ${p => p.theme.surface300};
- `;
- const BigNumberResizeWrapper = styled('div')`
- flex-grow: 1;
- overflow: hidden;
- position: relative;
- `;
- const BigNumber = styled('div')`
- line-height: 1;
- display: inline-flex;
- flex: 1;
- width: 100%;
- min-height: 0;
- font-size: 32px;
- color: ${p => p.theme.headingColor};
- padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
- * {
- text-align: left !important;
- }
- `;
- const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
- ${p => p.autoHeightResize && 'height: 100%;'}
- width: 100%;
- padding: ${p => (p.noPadding ? `0` : `0 ${space(2)} ${space(2)}`)};
- `;
- const TableWrapper = styled('div')`
- margin-top: ${space(1.5)};
- min-height: 0;
- border-bottom-left-radius: ${p => p.theme.borderRadius};
- border-bottom-right-radius: ${p => p.theme.borderRadius};
- `;
- const StyledSimpleTableChart = styled(SimpleTableChart)`
- overflow: auto;
- height: 100%;
- `;
- const StyledErrorPanel = styled(ErrorPanel)`
- padding: ${space(2)};
- `;
|