123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748 |
- import * as React from 'react';
- import {withRouter, WithRouterProps} from 'react-router';
- import styled from '@emotion/styled';
- import color from 'color';
- 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 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 {getCrashFreeRateSeries} from 'app/utils/sessions';
- import theme from 'app/utils/theme';
- import {alertDetailsLink} from 'app/views/alerts/details';
- 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: 'Status Area',
- 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
- ) {
- 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 [
- `<div class="tooltip-series"><div>`,
- `<span class="tooltip-label">${marker} <strong>${t('Alert')} #${
- incident.identifier
- }</strong></span>${seriesName} ${dataPoint?.value?.toLocaleString()}`,
- `</div></div>`,
- `<div class="tooltip-date">${time}</div>`,
- `<div class="tooltip-arrow"></div>`,
- ].join('');
- },
- };
- return series;
- }
- class MetricChart extends React.PureComponent<Props, State> {
- 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
- ).toFixed(2);
- const criticalPercent = (100 * Math.min(criticalDuration / totalDuration, 1)).toFixed(
- 2
- );
- const warningPercent = (100 * Math.min(warningDuration / totalDuration, 1)).toFixed(
- 2
- );
- return (
- <ChartActions>
- <ChartSummary>
- <SummaryText>{t('SUMMARY')}</SummaryText>
- <SummaryStats>
- <StatItem>
- <IconCheckmark color="green300" isCircled />
- <StatCount>{resolvedPercent}%</StatCount>
- </StatItem>
- <StatItem>
- <IconWarning color="yellow300" />
- <StatCount>{warningPercent}%</StatCount>
- </StatItem>
- <StatItem>
- <IconFire color="red300" />
- <StatCount>{criticalPercent}%</StatCount>
- </StatItem>
- </SummaryStats>
- </ChartSummary>
- {!isSessionAggregate(rule.aggregate) && (
- <Feature features={['discover-basic']}>
- <Button size="small" {...props}>
- {buttonText}
- </Button>
- </Feature>
- )}
- </ChartActions>
- );
- }
- renderChart(loading: boolean, timeseriesData?: Series[]) {
- const {
- router,
- selectedIncident,
- interval,
- handleZoom,
- filter,
- 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 => point.name >= incidentStartDate
- );
- series.push(
- createIncidentSeries(
- router,
- organization,
- incidentColor,
- incidentStartDate,
- incident,
- incidentStartValue,
- series[0].seriesName
- )
- );
- 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 (warningTrigger?.alertThreshold) {
- const {alertThreshold} = warningTrigger;
- const warningThresholdLine = createThresholdSeries(theme.yellow300, alertThreshold);
- series.push(warningThresholdLine);
- maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
- }
- if (criticalTrigger?.alertThreshold) {
- const {alertThreshold} = criticalTrigger;
- const criticalThresholdLine = createThresholdSeries(theme.red300, alertThreshold);
- series.push(criticalThresholdLine);
- maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
- }
- return (
- <ChartPanel>
- <StyledPanelBody withPadding>
- <ChartHeader>
- <ChartTitle>
- {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]}
- </ChartTitle>
- {filter}
- </ChartHeader>
- <ChartZoom
- router={router}
- start={start}
- end={end}
- onZoom={zoomArgs => handleZoom(zoomArgs.start, zoomArgs.end)}
- >
- {zoomRenderProps => (
- <LineChart
- {...zoomRenderProps}
- isGroupedByDate
- showTimeInTooltip
- forwardedRef={this.handleRef}
- grid={{
- left: 0,
- right: space(2),
- top: space(2),
- bottom: 0,
- }}
- yAxis={{
- axisLabel: {
- formatter: (value: number) =>
- alertAxisFormatter(
- value,
- timeseriesData[0].seriesName,
- rule.aggregate
- ),
- },
- max: maxThresholdValue > maxSeriesValue ? maxThresholdValue : undefined,
- min: minChartValue || undefined,
- }}
- series={[...series, ...areaSeries]}
- 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 title = isModified
- ? `<strong>${t('Alert Rule Modified')}</strong>`
- : `${marker} <strong>${seriesName}</strong>`;
- const value = isModified
- ? `${seriesName} ${pointYFormatted}`
- : pointYFormatted;
- return [
- `<div class="tooltip-series"><div>`,
- `<span class="tooltip-label">${title}</span>${value}`,
- `</div></div>`,
- `<div class="tooltip-date">${startTime} — ${endTime}</div>`,
- `<div class="tooltip-arrow"></div>`,
- ].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();
- }}
- />
- )}
- </ChartZoom>
- </StyledPanelBody>
- {this.renderChartActions(totalDuration, criticalDuration, warningDuration)}
- </ChartPanel>
- );
- }
- renderEmpty() {
- return (
- <ChartPanel>
- <PanelBody withPadding>
- <Placeholder height="200px" />
- </PanelBody>
- </ChartPanel>
- );
- }
- 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 ? (
- <SessionsRequest
- api={api}
- organization={organization}
- project={projects.filter(p => 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]
- ),
- },
- ])
- }
- </SessionsRequest>
- ) : (
- <EventsRequest
- api={api}
- organization={organization}
- query={query}
- environment={environment ? [environment] : undefined}
- project={(projects as Project[])
- .filter(p => p && p.slug)
- .map(project => Number(project.id))}
- interval={interval}
- start={viableStartDate}
- end={viableEndDate}
- yAxis={aggregate}
- includePrevious={false}
- currentSeriesName={aggregate}
- partial={false}
- referrer="api.alerts.alert-rule-chart"
- >
- {({loading, timeseriesData}) => this.renderChart(loading, timeseriesData)}
- </EventsRequest>
- );
- }
- }
- 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};
- `;
|