mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
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();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|