import * as React from 'react';
import {withRouter, WithRouterProps} from 'react-router';
import styled from '@emotion/styled';
import color from 'color';
import capitalize from 'lodash/capitalize';
import moment from 'moment';
import momentTimezone from 'moment-timezone';
import {Client} from 'app/api';
import Feature from 'app/components/acl/feature';
import Button from 'app/components/button';
import ChartZoom from 'app/components/charts/chartZoom';
import Graphic from 'app/components/charts/components/graphic';
import MarkArea from 'app/components/charts/components/markArea';
import MarkLine from 'app/components/charts/components/markLine';
import EventsRequest from 'app/components/charts/eventsRequest';
import LineChart, {LineChartSeries} from 'app/components/charts/lineChart';
import LineSeries from 'app/components/charts/series/lineSeries';
import SessionsRequest from 'app/components/charts/sessionsRequest';
import {SectionHeading} from 'app/components/charts/styles';
import {
parseStatsPeriod,
StatsPeriodType,
} from 'app/components/organizations/globalSelectionHeader/getParams';
import {Panel, PanelBody, PanelFooter} from 'app/components/panels';
import Placeholder from 'app/components/placeholder';
import {IconCheckmark, IconFire, IconWarning} from 'app/icons';
import {t} from 'app/locale';
import ConfigStore from 'app/stores/configStore';
import space from 'app/styles/space';
import {AvatarProject, DateString, Organization, Project} from 'app/types';
import {ReactEchartsRef, Series} from 'app/types/echarts';
import {getUtcDateString} from 'app/utils/dates';
import getDynamicText from 'app/utils/getDynamicText';
import {
getCrashFreeRateSeries,
MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
} from 'app/utils/sessions';
import theme from 'app/utils/theme';
import {checkChangeStatus} from 'app/views/alerts/changeAlerts/comparisonMarklines';
import {alertDetailsLink} from 'app/views/alerts/details';
import {COMPARISON_DELTA_OPTIONS} from 'app/views/alerts/incidentRules/constants';
import {makeDefaultCta} from 'app/views/alerts/incidentRules/incidentRulePresets';
import {Dataset, IncidentRule} from 'app/views/alerts/incidentRules/types';
import {AlertWizardAlertNames} from 'app/views/alerts/wizard/options';
import {getAlertTypeFromAggregateDataset} from 'app/views/alerts/wizard/utils';
import {Incident, IncidentActivityType, IncidentStatus} from '../../types';
import {
ALERT_CHART_MIN_MAX_BUFFER,
alertAxisFormatter,
alertTooltipValueFormatter,
isSessionAggregate,
SESSION_AGGREGATE_TO_FIELD,
shouldScaleAlertChart,
} from '../../utils';
import {TimePeriodType} from './constants';
const X_AXIS_BOUNDARY_GAP = 20;
const VERTICAL_PADDING = 22;
type Props = WithRouterProps & {
api: Client;
rule: IncidentRule;
incidents?: Incident[];
timePeriod: TimePeriodType;
selectedIncident?: Incident | null;
organization: Organization;
projects: Project[] | AvatarProject[];
interval: string;
filter: React.ReactNode;
query: string;
orgId: string;
handleZoom: (start: DateString, end: DateString) => void;
};
type State = {
width: number;
height: number;
};
function formatTooltipDate(date: moment.MomentInput, format: string): string {
const {
options: {timezone},
} = ConfigStore.get('user');
return momentTimezone.tz(date, timezone).format(format);
}
function createThresholdSeries(lineColor: string, threshold: number): LineChartSeries {
return {
seriesName: 'Threshold Line',
type: 'line',
markLine: MarkLine({
silent: true,
lineStyle: {color: lineColor, type: 'dashed', width: 1},
data: [{yAxis: threshold} as any],
label: {
show: false,
},
}),
data: [],
};
}
function createStatusAreaSeries(
lineColor: string,
startTime: number,
endTime: number,
yPosition: number
): LineChartSeries {
return {
seriesName: '',
type: 'line',
markLine: MarkLine({
silent: true,
lineStyle: {color: lineColor, type: 'solid', width: 4},
data: [[{coord: [startTime, yPosition]}, {coord: [endTime, yPosition]}] as any],
}),
data: [],
};
}
function createIncidentSeries(
router: Props['router'],
organization: Organization,
lineColor: string,
incidentTimestamp: number,
incident: Incident,
dataPoint?: LineChartSeries['data'][0],
seriesName?: string,
aggregate?: string
) {
const series = {
seriesName: 'Incident Line',
type: 'line',
markLine: MarkLine({
silent: false,
lineStyle: {color: lineColor, type: 'solid'},
data: [
{
xAxis: incidentTimestamp,
onClick: () => {
router.push({
pathname: alertDetailsLink(organization, incident),
query: {alert: incident.identifier},
});
},
},
] as any,
label: {
show: incident.identifier,
position: 'insideEndBottom',
formatter: incident.identifier,
color: lineColor,
fontSize: 10,
fontFamily: 'Rubik',
} as any,
}),
data: [],
};
// tooltip conflicts with MarkLine types
(series.markLine as any).tooltip = {
trigger: 'item',
alwaysShowContent: true,
formatter: ({value, marker}) => {
const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
return [
`
`,
`${time}
`,
``,
].join('');
},
};
return series;
}
class MetricChart extends React.PureComponent {
state = {
width: -1,
height: -1,
};
ref: null | ReactEchartsRef = null;
/**
* Syncs component state with the chart's width/heights
*/
updateDimensions = () => {
const chartRef = this.ref?.getEchartsInstance?.();
if (!chartRef) {
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 => {
if (ref && !this.ref) {
this.ref = ref;
this.updateDimensions();
}
if (!ref) {
this.ref = null;
}
};
getRuleChangeThresholdElements = (data: LineChartSeries[]): any[] => {
const {height, width} = this.state;
const {dateModified} = this.props.rule || {};
if (!data.length || !data[0].data.length || !dateModified) {
return [];
}
const seriesData = data[0].data;
const seriesStart = moment(seriesData[0].name).valueOf();
const seriesEnd = moment(seriesData[seriesData.length - 1].name).valueOf();
const ruleChanged = moment(dateModified).valueOf();
if (ruleChanged < seriesStart) {
return [];
}
const chartWidth = width - X_AXIS_BOUNDARY_GAP;
const position =
X_AXIS_BOUNDARY_GAP +
Math.round((chartWidth * (ruleChanged - seriesStart)) / (seriesEnd - seriesStart));
return [
{
type: 'line',
draggable: false,
position: [position, 0],
shape: {y1: 0, y2: height - VERTICAL_PADDING, x1: 1, x2: 1},
style: {
stroke: theme.gray200,
},
},
{
type: 'rect',
draggable: false,
position: [X_AXIS_BOUNDARY_GAP, 0],
shape: {
// +1 makes the gray area go midway onto the dashed line above
width: position - X_AXIS_BOUNDARY_GAP + 1,
height: height - VERTICAL_PADDING,
},
style: {
fill: color(theme.gray100).alpha(0.42).rgb().string(),
},
},
];
};
renderChartActions(
totalDuration: number,
criticalDuration: number,
warningDuration: number
) {
const {rule, orgId, projects, timePeriod, query} = this.props;
const ctaOpts = {
orgSlug: orgId,
projects: projects as Project[],
rule,
eventType: query,
start: timePeriod.start,
end: timePeriod.end,
};
const {buttonText, ...props} = makeDefaultCta(ctaOpts);
const resolvedPercent =
(100 * Math.max(totalDuration - criticalDuration - warningDuration, 0)) /
totalDuration;
const criticalPercent = 100 * Math.min(criticalDuration / totalDuration, 1);
const warningPercent = 100 * Math.min(warningDuration / totalDuration, 1);
return (
{t('SUMMARY')}
{resolvedPercent ? resolvedPercent.toFixed(2) : 0}%
{warningPercent ? warningPercent.toFixed(2) : 0}%
{criticalPercent ? criticalPercent.toFixed(2) : 0}%
{!isSessionAggregate(rule.aggregate) && (
)}
);
}
renderChart(
loading: boolean,
timeseriesData?: Series[],
minutesThresholdToDisplaySeconds?: number,
comparisonTimeseriesData?: Series[]
) {
const {
router,
selectedIncident,
interval,
handleZoom,
filter,
query,
incidents,
rule,
organization,
timePeriod: {start, end},
} = this.props;
const {dateModified, timeWindow, aggregate} = rule;
if (loading || !timeseriesData) {
return this.renderEmpty();
}
const criticalTrigger = rule.triggers.find(({label}) => label === 'critical');
const warningTrigger = rule.triggers.find(({label}) => label === 'warning');
const series: LineChartSeries[] = [...timeseriesData];
const areaSeries: any[] = [];
// Ensure series data appears above incident lines
series[0].z = 100;
const dataArr = timeseriesData[0].data;
const maxSeriesValue = dataArr.reduce(
(currMax, coord) => Math.max(currMax, coord.value),
0
);
// find the lowest value between chart data points, warning threshold,
// critical threshold and then apply some breathing space
const minChartValue = shouldScaleAlertChart(aggregate)
? Math.floor(
Math.min(
dataArr.reduce((currMax, coord) => Math.min(currMax, coord.value), Infinity),
typeof warningTrigger?.alertThreshold === 'number'
? warningTrigger.alertThreshold
: Infinity,
typeof criticalTrigger?.alertThreshold === 'number'
? criticalTrigger.alertThreshold
: Infinity
) / ALERT_CHART_MIN_MAX_BUFFER
)
: 0;
const firstPoint = moment(dataArr[0]?.name).valueOf();
const lastPoint = moment(dataArr[dataArr.length - 1]?.name).valueOf();
const totalDuration = lastPoint - firstPoint;
let criticalDuration = 0;
let warningDuration = 0;
series.push(
createStatusAreaSeries(theme.green300, firstPoint, lastPoint, minChartValue)
);
if (incidents) {
// select incidents that fall within the graph range
const periodStart = moment.utc(firstPoint);
incidents
.filter(
incident =>
!incident.dateClosed || moment(incident.dateClosed).isAfter(periodStart)
)
.forEach(incident => {
const statusChanges = incident.activities
?.filter(
({type, value}) =>
type === IncidentActivityType.STATUS_CHANGE &&
value &&
[`${IncidentStatus.WARNING}`, `${IncidentStatus.CRITICAL}`].includes(
value
)
)
.sort(
(a, b) => moment(a.dateCreated).valueOf() - moment(b.dateCreated).valueOf()
);
const incidentEnd = incident.dateClosed ?? moment().valueOf();
const timeWindowMs = rule.timeWindow * 60 * 1000;
const incidentColor =
warningTrigger &&
statusChanges &&
!statusChanges.find(({value}) => value === `${IncidentStatus.CRITICAL}`)
? theme.yellow300
: theme.red300;
const incidentStartDate = moment(incident.dateStarted).valueOf();
const incidentCloseDate = incident.dateClosed
? moment(incident.dateClosed).valueOf()
: lastPoint;
const incidentStartValue = dataArr.find(
point => moment(point.name).valueOf() >= incidentStartDate
);
series.push(
createIncidentSeries(
router,
organization,
incidentColor,
incidentStartDate,
incident,
incidentStartValue,
series[0].seriesName,
aggregate
)
);
const areaStart = Math.max(moment(incident.dateStarted).valueOf(), firstPoint);
const areaEnd = Math.min(
statusChanges?.length && statusChanges[0].dateCreated
? moment(statusChanges[0].dateCreated).valueOf() - timeWindowMs
: moment(incidentEnd).valueOf(),
lastPoint
);
const areaColor = warningTrigger ? theme.yellow300 : theme.red300;
if (areaEnd > areaStart) {
series.push(
createStatusAreaSeries(areaColor, areaStart, areaEnd, minChartValue)
);
if (areaColor === theme.yellow300) {
warningDuration += Math.abs(areaEnd - areaStart);
} else {
criticalDuration += Math.abs(areaEnd - areaStart);
}
}
statusChanges?.forEach((activity, idx) => {
const statusAreaStart = Math.max(
moment(activity.dateCreated).valueOf() - timeWindowMs,
firstPoint
);
const statusAreaEnd = Math.min(
idx === statusChanges.length - 1
? moment(incidentEnd).valueOf()
: moment(statusChanges[idx + 1].dateCreated).valueOf() - timeWindowMs,
lastPoint
);
const statusAreaColor =
activity.value === `${IncidentStatus.CRITICAL}`
? theme.red300
: theme.yellow300;
if (statusAreaEnd > statusAreaStart) {
series.push(
createStatusAreaSeries(
statusAreaColor,
statusAreaStart,
statusAreaEnd,
minChartValue
)
);
if (statusAreaColor === theme.yellow300) {
warningDuration += Math.abs(statusAreaEnd - statusAreaStart);
} else {
criticalDuration += Math.abs(statusAreaEnd - statusAreaStart);
}
}
});
if (selectedIncident && incident.id === selectedIncident.id) {
const selectedIncidentColor =
incidentColor === theme.yellow300 ? theme.yellow100 : theme.red100;
areaSeries.push({
type: 'line',
markArea: MarkArea({
silent: true,
itemStyle: {
color: color(selectedIncidentColor).alpha(0.42).rgb().string(),
},
data: [[{xAxis: incidentStartDate}, {xAxis: incidentCloseDate}]] as any,
}),
data: [],
});
}
});
}
let maxThresholdValue = 0;
if (!rule.comparisonDelta && warningTrigger?.alertThreshold) {
const {alertThreshold} = warningTrigger;
const warningThresholdLine = createThresholdSeries(theme.yellow300, alertThreshold);
series.push(warningThresholdLine);
maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
}
if (!rule.comparisonDelta && criticalTrigger?.alertThreshold) {
const {alertThreshold} = criticalTrigger;
const criticalThresholdLine = createThresholdSeries(theme.red300, alertThreshold);
series.push(criticalThresholdLine);
maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
}
const comparisonSeriesName = capitalize(
COMPARISON_DELTA_OPTIONS.find(({value}) => value === rule.comparisonDelta)?.label ||
''
);
return (
{AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]}
{query ? filter : null}
{getDynamicText({
value: (
handleZoom(zoomArgs.start, zoomArgs.end)}
>
{zoomRenderProps => (
alertAxisFormatter(
value,
timeseriesData[0].seriesName,
rule.aggregate
),
},
max:
maxThresholdValue > maxSeriesValue
? maxThresholdValue
: undefined,
min: minChartValue || undefined,
}}
series={[...series, ...areaSeries]}
additionalSeries={[
...(comparisonTimeseriesData || []).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,
})
),
]}
graphic={Graphic({
elements: this.getRuleChangeThresholdElements(timeseriesData),
})}
tooltip={{
formatter: seriesParams => {
// seriesParams can be object instead of array
const pointSeries = Array.isArray(seriesParams)
? seriesParams
: [seriesParams];
const {marker, data: pointData, seriesName} = pointSeries[0];
const [pointX, pointY] = pointData as [number, number];
const pointYFormatted = alertTooltipValueFormatter(
pointY,
seriesName ?? '',
rule.aggregate
);
const isModified =
dateModified && pointX <= new Date(dateModified).getTime();
const startTime = formatTooltipDate(moment(pointX), 'MMM D LT');
const {period, periodLength} = parseStatsPeriod(interval) ?? {
periodLength: 'm',
period: `${timeWindow}`,
};
const endTime = formatTooltipDate(
moment(pointX).add(
parseInt(period, 10),
periodLength as StatsPeriodType
),
'MMM D LT'
);
const comparisonSeries =
pointSeries.length > 1
? pointSeries.find(
({seriesName: _sn}) => _sn === comparisonSeriesName
)
: undefined;
const comparisonPointY = comparisonSeries?.data[1] as
| number
| undefined;
const comparisonPointYFormatted =
comparisonPointY !== undefined
? alertTooltipValueFormatter(
comparisonPointY,
seriesName ?? '',
rule.aggregate
)
: undefined;
const changePercentage =
comparisonPointY === undefined
? undefined
: (comparisonPointY === 0 && pointY === 0
? 0
: (pointY - comparisonPointY) / comparisonPointY) * 100;
const changeStatus =
changePercentage === undefined
? undefined
: checkChangeStatus(
changePercentage,
rule.thresholdType,
rule.triggers
);
const changeStatusColor =
changeStatus === 'critical'
? theme.red300
: changeStatus === 'warning'
? theme.yellow300
: theme.green300;
return [
``,
``,
`${startTime} — ${endTime}`,
changePercentage !== undefined &&
`${
Math.sign(changePercentage) === 1
? '+'
: Math.sign(changePercentage) === -1
? '-'
: ''
}${
Math.abs(changePercentage) === Infinity
? `∞`
: Math.abs(changePercentage).toFixed(2)
}%`,
`
`,
``,
]
.filter(e => e)
.join('');
},
}}
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();
}}
/>
)}
),
fixed: ,
})}
{this.renderChartActions(totalDuration, criticalDuration, warningDuration)}
);
}
renderEmpty() {
return (
);
}
render() {
const {api, rule, organization, timePeriod, projects, interval, query} = this.props;
const {aggregate, timeWindow, environment, dataset} = rule;
// If the chart duration isn't as long as the rollup duration the events-stats
// endpoint will return an invalid timeseriesData data set
const viableStartDate = getUtcDateString(
moment.min(
moment.utc(timePeriod.start),
moment.utc(timePeriod.end).subtract(timeWindow, 'minutes')
)
);
const viableEndDate = getUtcDateString(
moment.utc(timePeriod.end).add(timeWindow, 'minutes')
);
return dataset === Dataset.SESSIONS ? (
p.id).map(p => Number(p.id))}
environment={environment ? [environment] : undefined}
start={viableStartDate}
end={viableEndDate}
query={query}
interval={interval}
field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
groupBy={['session.status']}
>
{({loading, response}) =>
this.renderChart(
loading,
[
{
seriesName:
AlertWizardAlertNames[
getAlertTypeFromAggregateDataset({
aggregate,
dataset: Dataset.SESSIONS,
})
],
data: getCrashFreeRateSeries(
response?.groups,
response?.intervals,
SESSION_AGGREGATE_TO_FIELD[aggregate]
),
},
],
MINUTES_THRESHOLD_TO_DISPLAY_SECONDS
)
}
) : (
p && p.slug)
.map(project => Number(project.id))}
interval={interval}
comparisonDelta={rule.comparisonDelta ? rule.comparisonDelta * 60 : undefined}
start={viableStartDate}
end={viableEndDate}
yAxis={aggregate}
includePrevious={false}
currentSeriesNames={[aggregate]}
partial={false}
referrer="api.alerts.alert-rule-chart"
>
{({loading, timeseriesData, comparisonTimeseriesData}) =>
this.renderChart(loading, timeseriesData, undefined, comparisonTimeseriesData)
}
);
}
}
export default withRouter(MetricChart);
const ChartPanel = styled(Panel)`
margin-top: ${space(2)};
`;
const ChartHeader = styled('div')`
margin-bottom: ${space(3)};
`;
const ChartTitle = styled('header')`
display: flex;
flex-direction: row;
`;
const ChartActions = styled(PanelFooter)`
display: flex;
justify-content: flex-end;
align-items: center;
padding: ${space(1)} 20px;
`;
const ChartSummary = styled('div')`
display: flex;
margin-right: auto;
`;
const SummaryText = styled(SectionHeading)`
flex: 1;
display: flex;
align-items: center;
margin: 0;
font-weight: bold;
font-size: ${p => p.theme.fontSizeSmall};
line-height: 1;
`;
const SummaryStats = styled('div')`
display: flex;
align-items: center;
margin: 0 ${space(2)};
`;
const StatItem = styled('div')`
display: flex;
align-items: center;
margin: 0 ${space(2)} 0 0;
`;
/* Override padding to make chart appear centered */
const StyledPanelBody = styled(PanelBody)`
padding-right: 6px;
`;
const StatCount = styled('span')`
margin-left: ${space(0.5)};
margin-top: ${space(0.25)};
color: ${p => p.theme.textColor};
`;