123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- import {PureComponent} from 'react';
- import color from 'color';
- import type {TooltipComponentFormatterCallbackParams} from 'echarts';
- import debounce from 'lodash/debounce';
- import {extrapolatedAreaStyle} from 'sentry/components/alerts/onDemandMetricAlert';
- import {AreaChart} from 'sentry/components/charts/areaChart';
- import Graphic from 'sentry/components/charts/components/graphic';
- import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip';
- import type {LineChartSeries} from 'sentry/components/charts/lineChart';
- import LineSeries from 'sentry/components/charts/series/lineSeries';
- import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
- import {CHART_PALETTE} from 'sentry/constants/chartPalette';
- import {space} from 'sentry/styles/space';
- import type {PageFilters} from 'sentry/types';
- import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
- import theme from 'sentry/utils/theme';
- import {
- ALERT_CHART_MIN_MAX_BUFFER,
- alertAxisFormatter,
- alertTooltipValueFormatter,
- isSessionAggregate,
- shouldScaleAlertChart,
- } from 'sentry/views/alerts/utils';
- import {getChangeStatus} from 'sentry/views/alerts/utils/getChangeStatus';
- import type {MetricRule, Trigger} from '../../types';
- import {AlertRuleThresholdType, AlertRuleTriggerType} from '../../types';
- type DefaultProps = {
- comparisonData: Series[];
- comparisonMarkLines: LineChartSeries[];
- data: Series[];
- };
- type Props = DefaultProps & {
- aggregate: string;
- hideThresholdLines: boolean;
- resolveThreshold: MetricRule['resolveThreshold'];
- thresholdType: MetricRule['thresholdType'];
- triggers: Trigger[];
- comparisonSeriesName?: string;
- isExtrapolatedData?: boolean;
- maxValue?: number;
- minValue?: number;
- minutesThresholdToDisplaySeconds?: number;
- } & Partial<PageFilters['datetime']>;
- type State = {
- height: number;
- width: number;
- yAxisMax: number | null;
- yAxisMin: number | null;
- };
- const CHART_GRID = {
- left: space(2),
- right: space(2),
- top: space(4),
- bottom: space(2),
- };
- // Colors to use for trigger thresholds
- const COLOR = {
- RESOLUTION_FILL: color(theme.green200).alpha(0.1).rgb().string(),
- CRITICAL_FILL: color(theme.red300).alpha(0.25).rgb().string(),
- WARNING_FILL: color(theme.yellow200).alpha(0.1).rgb().string(),
- };
- /**
- * This chart displays shaded regions that represent different Trigger thresholds in a
- * Metric Alert rule.
- */
- export default class ThresholdsChart extends PureComponent<Props, State> {
- static defaultProps: DefaultProps = {
- data: [],
- comparisonData: [],
- comparisonMarkLines: [],
- };
- state: State = {
- width: -1,
- height: -1,
- yAxisMax: null,
- yAxisMin: null,
- };
- componentDidMount() {
- this.handleUpdateChartAxis();
- }
- componentDidUpdate(prevProps: Props) {
- if (
- this.props.triggers !== prevProps.triggers ||
- this.props.data !== prevProps.data ||
- this.props.comparisonData !== prevProps.comparisonData ||
- this.props.comparisonMarkLines !== prevProps.comparisonMarkLines
- ) {
- this.handleUpdateChartAxis();
- }
- }
- ref: null | ReactEchartsRef = null;
- // If we have ref to chart and data, try to update chart axis so that
- // alertThreshold or resolveThreshold is visible in chart
- handleUpdateChartAxis = () => {
- const {triggers, resolveThreshold, hideThresholdLines} = this.props;
- const chartRef = this.ref?.getEchartsInstance?.();
- if (hideThresholdLines) {
- return;
- }
- if (chartRef) {
- const thresholds = [
- resolveThreshold || null,
- ...triggers.map(t => t.alertThreshold || null),
- ].filter(threshold => threshold !== null) as number[];
- this.updateChartAxis(Math.min(...thresholds), Math.max(...thresholds));
- }
- };
- /**
- * Updates the chart so that yAxis is within bounds of our max value
- */
- updateChartAxis = debounce((minThreshold: number, maxThreshold: number) => {
- const {minValue, maxValue, aggregate} = this.props;
- const shouldScale = shouldScaleAlertChart(aggregate);
- let yAxisMax =
- shouldScale && maxValue
- ? this.clampMaxValue(Math.ceil(maxValue * ALERT_CHART_MIN_MAX_BUFFER))
- : null;
- let yAxisMin =
- shouldScale && minValue ? Math.floor(minValue / ALERT_CHART_MIN_MAX_BUFFER) : 0;
- if (typeof maxValue === 'number' && maxThreshold > maxValue) {
- yAxisMax = maxThreshold;
- }
- if (typeof minValue === 'number' && minThreshold < minValue) {
- yAxisMin = Math.floor(minThreshold / ALERT_CHART_MIN_MAX_BUFFER);
- }
- // We need to force update after we set a new yAxis min/max because `convertToPixel`
- // can return a negative position (probably because yAxisMin/yAxisMax is not synced with chart yet)
- this.setState({yAxisMax, yAxisMin}, this.forceUpdate);
- }, 150);
- /**
- * Syncs component state with the chart's width/heights
- */
- updateDimensions = () => {
- const chartRef = this.ref?.getEchartsInstance?.();
- if (!chartRef || !chartRef.getWidth?.()) {
- return;
- }
- const width = chartRef.getWidth();
- const height = chartRef.getHeight();
- if (width !== this.state.width || height !== this.state.height) {
- this.setState({
- width,
- height,
- });
- }
- };
- handleRef = (ref: ReactEchartsRef): void => {
- // When chart initially renders, we want to update state with its width, as well as initialize starting
- // locations (on y axis) for the draggable lines
- if (ref && !this.ref) {
- this.ref = ref;
- this.updateDimensions();
- this.handleUpdateChartAxis();
- }
- if (!ref) {
- this.ref = null;
- }
- };
- /**
- * Draws the boundary lines and shaded areas for the chart.
- *
- * May need to refactor so that they are aware of other trigger thresholds.
- *
- * e.g. draw warning from threshold -> critical threshold instead of the entire height of chart
- */
- getThresholdLine = (
- trigger: Trigger,
- type: 'alertThreshold' | 'resolveThreshold',
- isResolution: boolean
- ) => {
- const {thresholdType, resolveThreshold, maxValue, hideThresholdLines} = this.props;
- const position =
- type === 'alertThreshold'
- ? this.getChartPixelForThreshold(trigger[type])
- : this.getChartPixelForThreshold(resolveThreshold);
- const isInverted = thresholdType === AlertRuleThresholdType.BELOW;
- const chartRef = this.ref?.getEchartsInstance?.();
- if (
- typeof position !== 'number' ||
- isNaN(position) ||
- !this.state.height ||
- !chartRef ||
- hideThresholdLines
- ) {
- return [];
- }
- const yAxisPixelPosition = chartRef.convertToPixel(
- {yAxisIndex: 0},
- `${this.state.yAxisMin}`
- );
- const yAxisPosition = typeof yAxisPixelPosition === 'number' ? yAxisPixelPosition : 0;
- // As the yAxis gets larger we want to start our line/area further to the right
- // Handle case where the graph max is 1 and includes decimals
- const yAxisMax =
- (Math.round(Math.max(maxValue ?? 1, this.state.yAxisMax ?? 1)) * 100) / 100;
- const yAxisSize = 15 + (yAxisMax <= 1 ? 15 : `${yAxisMax ?? ''}`.length * 8);
- // Shave off the right margin and yAxisSize from the width to get the actual area we want to render content in
- const graphAreaWidth =
- this.state.width - parseInt(CHART_GRID.right.slice(0, -2), 10) - yAxisSize;
- // Distance from the top of the chart to save for the legend
- const legendPadding = 20;
- // Shave off the left margin
- const graphAreaMargin = 7;
- const isCritical = trigger.label === AlertRuleTriggerType.CRITICAL;
- const LINE_STYLE = {
- stroke: isResolution ? theme.green300 : isCritical ? theme.red300 : theme.yellow300,
- lineDash: [2],
- };
- return [
- // This line is used as a "border" for the shaded region
- // and represents the threshold value.
- {
- type: 'line',
- // Resolution is considered "off" if it is -1
- invisible: position === null,
- draggable: false,
- position: [yAxisSize, position],
- shape: {y1: 1, y2: 1, x1: graphAreaMargin, x2: graphAreaWidth},
- style: LINE_STYLE,
- silent: true,
- z: 100,
- },
- // Shaded area for incident/resolutions to show user when they can expect to be alerted
- // (or when they will be considered as resolved)
- //
- // Resolution is considered "off" if it is -1
- ...(position !== null
- ? [
- {
- type: 'rect',
- draggable: false,
- silent: true,
- position:
- isResolution !== isInverted
- ? [yAxisSize + graphAreaMargin, position + 1]
- : [yAxisSize + graphAreaMargin, legendPadding],
- shape: {
- width: graphAreaWidth - graphAreaMargin,
- height:
- isResolution !== isInverted
- ? yAxisPosition - position
- : position - legendPadding,
- },
- style: {
- fill: isResolution
- ? COLOR.RESOLUTION_FILL
- : isCritical
- ? COLOR.CRITICAL_FILL
- : COLOR.WARNING_FILL,
- },
- // This needs to be below the draggable line
- z: 100,
- },
- ]
- : []),
- ];
- };
- getChartPixelForThreshold = (threshold: number | '' | null) => {
- const chartRef = this.ref?.getEchartsInstance?.();
- return (
- threshold !== '' &&
- chartRef &&
- chartRef.convertToPixel({yAxisIndex: 0}, `${threshold}`)
- );
- };
- clampMaxValue(value: number) {
- // When we apply top buffer to the crash free percentage (99.7% * 1.03), it
- // can cross 100%, so we clamp it
- if (isSessionAggregate(this.props.aggregate) && value > 100) {
- return 100;
- }
- return value;
- }
- render() {
- const {
- data,
- triggers,
- period,
- aggregate,
- comparisonData,
- comparisonSeriesName,
- comparisonMarkLines,
- minutesThresholdToDisplaySeconds,
- thresholdType,
- } = this.props;
- const dataWithoutRecentBucket = data?.map(({data: eventData, ...restOfData}) => {
- if (this.props.isExtrapolatedData) {
- return {
- ...restOfData,
- data: eventData.slice(0, -1),
- areaStyle: extrapolatedAreaStyle,
- };
- }
- return {
- ...restOfData,
- data: eventData.slice(0, -1),
- };
- });
- const comparisonDataWithoutRecentBucket = comparisonData?.map(
- ({data: eventData, ...restOfData}) => ({
- ...restOfData,
- data: eventData.slice(0, -1),
- })
- );
- const chartOptions = {
- tooltip: {
- // use the main aggregate for all series (main, min, max, avg, comparison)
- // to format all values similarly
- valueFormatter: (value: number) =>
- alertTooltipValueFormatter(value, aggregate, aggregate),
- formatAxisLabel: (
- value: number,
- isTimestamp: boolean,
- utc: boolean,
- showTimeInTooltip: boolean,
- addSecondsToTimeFormat: boolean,
- bucketSize: number | undefined,
- seriesParamsOrParam: TooltipComponentFormatterCallbackParams
- ) => {
- const date = defaultFormatAxisLabel(
- value,
- isTimestamp,
- utc,
- showTimeInTooltip,
- addSecondsToTimeFormat,
- bucketSize
- );
- const seriesParams = Array.isArray(seriesParamsOrParam)
- ? seriesParamsOrParam
- : [seriesParamsOrParam];
- const pointY = (
- seriesParams.length > 1 ? seriesParams[0].data[1] : undefined
- ) as number | undefined;
- const comparisonSeries =
- seriesParams.length > 1
- ? seriesParams.find(({seriesName: _sn}) => _sn === comparisonSeriesName)
- : undefined;
- const comparisonPointY = comparisonSeries?.data[1] as number | undefined;
- if (
- comparisonPointY === undefined ||
- pointY === undefined ||
- comparisonPointY === 0
- ) {
- return `<span>${date}</span>`;
- }
- const changePercentage = ((pointY - comparisonPointY) * 100) / comparisonPointY;
- const changeStatus = getChangeStatus(changePercentage, thresholdType, triggers);
- const changeStatusColor =
- changeStatus === AlertRuleTriggerType.CRITICAL
- ? theme.red300
- : changeStatus === AlertRuleTriggerType.WARNING
- ? theme.yellow300
- : theme.green300;
- return `<span>${date}<span style="color:${changeStatusColor};margin-left:10px;">
- ${Math.sign(changePercentage) === 1 ? '+' : '-'}${Math.abs(
- changePercentage
- ).toFixed(2)}%</span></span>`;
- },
- },
- yAxis: {
- min: this.state.yAxisMin ?? undefined,
- max: this.state.yAxisMax ?? undefined,
- axisLabel: {
- formatter: (value: number) =>
- alertAxisFormatter(value, data[0].seriesName, aggregate),
- },
- },
- };
- return (
- <AreaChart
- isGroupedByDate
- showTimeInTooltip
- minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
- period={DEFAULT_STATS_PERIOD || period}
- forwardedRef={this.handleRef}
- grid={CHART_GRID}
- {...chartOptions}
- graphic={Graphic({
- elements: triggers.flatMap((trigger: Trigger) => [
- ...this.getThresholdLine(trigger, 'alertThreshold', false),
- ...this.getThresholdLine(trigger, 'resolveThreshold', true),
- ]),
- })}
- colors={CHART_PALETTE[0]}
- series={[...dataWithoutRecentBucket, ...comparisonMarkLines]}
- additionalSeries={comparisonDataWithoutRecentBucket.map(
- ({data: _data, ...otherSeriesProps}) =>
- LineSeries({
- name: comparisonSeriesName,
- data: _data.map(({name, value}) => [name, value]),
- lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
- itemStyle: {color: theme.gray200},
- animation: false,
- animationThreshold: 1,
- animationDuration: 0,
- ...otherSeriesProps,
- })
- )}
- onFinished={() => {
- // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
- // any graphics related to the triggers (e.g. the threshold areas + boundaries)
- this.updateDimensions();
- }}
- />
- );
- }
- }
|