metricChart.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import color from 'color';
  5. import capitalize from 'lodash/capitalize';
  6. import moment from 'moment';
  7. import momentTimezone from 'moment-timezone';
  8. import {Client} from 'app/api';
  9. import Feature from 'app/components/acl/feature';
  10. import Button from 'app/components/button';
  11. import ChartZoom from 'app/components/charts/chartZoom';
  12. import Graphic from 'app/components/charts/components/graphic';
  13. import MarkArea from 'app/components/charts/components/markArea';
  14. import MarkLine from 'app/components/charts/components/markLine';
  15. import EventsRequest from 'app/components/charts/eventsRequest';
  16. import LineChart, {LineChartSeries} from 'app/components/charts/lineChart';
  17. import LineSeries from 'app/components/charts/series/lineSeries';
  18. import SessionsRequest from 'app/components/charts/sessionsRequest';
  19. import {SectionHeading} from 'app/components/charts/styles';
  20. import {
  21. parseStatsPeriod,
  22. StatsPeriodType,
  23. } from 'app/components/organizations/globalSelectionHeader/getParams';
  24. import {Panel, PanelBody, PanelFooter} from 'app/components/panels';
  25. import Placeholder from 'app/components/placeholder';
  26. import {IconCheckmark, IconFire, IconWarning} from 'app/icons';
  27. import {t} from 'app/locale';
  28. import ConfigStore from 'app/stores/configStore';
  29. import space from 'app/styles/space';
  30. import {AvatarProject, DateString, Organization, Project} from 'app/types';
  31. import {ReactEchartsRef, Series} from 'app/types/echarts';
  32. import {getUtcDateString} from 'app/utils/dates';
  33. import getDynamicText from 'app/utils/getDynamicText';
  34. import {
  35. getCrashFreeRateSeries,
  36. MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
  37. } from 'app/utils/sessions';
  38. import theme from 'app/utils/theme';
  39. import {checkChangeStatus} from 'app/views/alerts/changeAlerts/comparisonMarklines';
  40. import {alertDetailsLink} from 'app/views/alerts/details';
  41. import {COMPARISON_DELTA_OPTIONS} from 'app/views/alerts/incidentRules/constants';
  42. import {makeDefaultCta} from 'app/views/alerts/incidentRules/incidentRulePresets';
  43. import {Dataset, IncidentRule} from 'app/views/alerts/incidentRules/types';
  44. import {AlertWizardAlertNames} from 'app/views/alerts/wizard/options';
  45. import {getAlertTypeFromAggregateDataset} from 'app/views/alerts/wizard/utils';
  46. import {Incident, IncidentActivityType, IncidentStatus} from '../../types';
  47. import {
  48. ALERT_CHART_MIN_MAX_BUFFER,
  49. alertAxisFormatter,
  50. alertTooltipValueFormatter,
  51. isSessionAggregate,
  52. SESSION_AGGREGATE_TO_FIELD,
  53. shouldScaleAlertChart,
  54. } from '../../utils';
  55. import {TimePeriodType} from './constants';
  56. const X_AXIS_BOUNDARY_GAP = 20;
  57. const VERTICAL_PADDING = 22;
  58. type Props = WithRouterProps & {
  59. api: Client;
  60. rule: IncidentRule;
  61. incidents?: Incident[];
  62. timePeriod: TimePeriodType;
  63. selectedIncident?: Incident | null;
  64. organization: Organization;
  65. projects: Project[] | AvatarProject[];
  66. interval: string;
  67. filter: React.ReactNode;
  68. query: string;
  69. orgId: string;
  70. handleZoom: (start: DateString, end: DateString) => void;
  71. };
  72. type State = {
  73. width: number;
  74. height: number;
  75. };
  76. function formatTooltipDate(date: moment.MomentInput, format: string): string {
  77. const {
  78. options: {timezone},
  79. } = ConfigStore.get('user');
  80. return momentTimezone.tz(date, timezone).format(format);
  81. }
  82. function createThresholdSeries(lineColor: string, threshold: number): LineChartSeries {
  83. return {
  84. seriesName: 'Threshold Line',
  85. type: 'line',
  86. markLine: MarkLine({
  87. silent: true,
  88. lineStyle: {color: lineColor, type: 'dashed', width: 1},
  89. data: [{yAxis: threshold} as any],
  90. label: {
  91. show: false,
  92. },
  93. }),
  94. data: [],
  95. };
  96. }
  97. function createStatusAreaSeries(
  98. lineColor: string,
  99. startTime: number,
  100. endTime: number,
  101. yPosition: number
  102. ): LineChartSeries {
  103. return {
  104. seriesName: '',
  105. type: 'line',
  106. markLine: MarkLine({
  107. silent: true,
  108. lineStyle: {color: lineColor, type: 'solid', width: 4},
  109. data: [[{coord: [startTime, yPosition]}, {coord: [endTime, yPosition]}] as any],
  110. }),
  111. data: [],
  112. };
  113. }
  114. function createIncidentSeries(
  115. router: Props['router'],
  116. organization: Organization,
  117. lineColor: string,
  118. incidentTimestamp: number,
  119. incident: Incident,
  120. dataPoint?: LineChartSeries['data'][0],
  121. seriesName?: string,
  122. aggregate?: string
  123. ) {
  124. const series = {
  125. seriesName: 'Incident Line',
  126. type: 'line',
  127. markLine: MarkLine({
  128. silent: false,
  129. lineStyle: {color: lineColor, type: 'solid'},
  130. data: [
  131. {
  132. xAxis: incidentTimestamp,
  133. onClick: () => {
  134. router.push({
  135. pathname: alertDetailsLink(organization, incident),
  136. query: {alert: incident.identifier},
  137. });
  138. },
  139. },
  140. ] as any,
  141. label: {
  142. show: incident.identifier,
  143. position: 'insideEndBottom',
  144. formatter: incident.identifier,
  145. color: lineColor,
  146. fontSize: 10,
  147. fontFamily: 'Rubik',
  148. } as any,
  149. }),
  150. data: [],
  151. };
  152. // tooltip conflicts with MarkLine types
  153. (series.markLine as any).tooltip = {
  154. trigger: 'item',
  155. alwaysShowContent: true,
  156. formatter: ({value, marker}) => {
  157. const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
  158. return [
  159. `<div class="tooltip-series"><div>`,
  160. `<span class="tooltip-label">${marker} <strong>${t('Alert')} #${
  161. incident.identifier
  162. }</strong></span>${
  163. dataPoint?.value
  164. ? `${seriesName} ${alertTooltipValueFormatter(
  165. dataPoint.value,
  166. seriesName ?? '',
  167. aggregate ?? ''
  168. )}`
  169. : ''
  170. }`,
  171. `</div></div>`,
  172. `<div class="tooltip-date">${time}</div>`,
  173. `<div class="tooltip-arrow"></div>`,
  174. ].join('');
  175. },
  176. };
  177. return series;
  178. }
  179. class MetricChart extends React.PureComponent<Props, State> {
  180. state = {
  181. width: -1,
  182. height: -1,
  183. };
  184. ref: null | ReactEchartsRef = null;
  185. /**
  186. * Syncs component state with the chart's width/heights
  187. */
  188. updateDimensions = () => {
  189. const chartRef = this.ref?.getEchartsInstance?.();
  190. if (!chartRef) {
  191. return;
  192. }
  193. const width = chartRef.getWidth();
  194. const height = chartRef.getHeight();
  195. if (width !== this.state.width || height !== this.state.height) {
  196. this.setState({
  197. width,
  198. height,
  199. });
  200. }
  201. };
  202. handleRef = (ref: ReactEchartsRef): void => {
  203. if (ref && !this.ref) {
  204. this.ref = ref;
  205. this.updateDimensions();
  206. }
  207. if (!ref) {
  208. this.ref = null;
  209. }
  210. };
  211. getRuleChangeThresholdElements = (data: LineChartSeries[]): any[] => {
  212. const {height, width} = this.state;
  213. const {dateModified} = this.props.rule || {};
  214. if (!data.length || !data[0].data.length || !dateModified) {
  215. return [];
  216. }
  217. const seriesData = data[0].data;
  218. const seriesStart = moment(seriesData[0].name).valueOf();
  219. const seriesEnd = moment(seriesData[seriesData.length - 1].name).valueOf();
  220. const ruleChanged = moment(dateModified).valueOf();
  221. if (ruleChanged < seriesStart) {
  222. return [];
  223. }
  224. const chartWidth = width - X_AXIS_BOUNDARY_GAP;
  225. const position =
  226. X_AXIS_BOUNDARY_GAP +
  227. Math.round((chartWidth * (ruleChanged - seriesStart)) / (seriesEnd - seriesStart));
  228. return [
  229. {
  230. type: 'line',
  231. draggable: false,
  232. position: [position, 0],
  233. shape: {y1: 0, y2: height - VERTICAL_PADDING, x1: 1, x2: 1},
  234. style: {
  235. stroke: theme.gray200,
  236. },
  237. },
  238. {
  239. type: 'rect',
  240. draggable: false,
  241. position: [X_AXIS_BOUNDARY_GAP, 0],
  242. shape: {
  243. // +1 makes the gray area go midway onto the dashed line above
  244. width: position - X_AXIS_BOUNDARY_GAP + 1,
  245. height: height - VERTICAL_PADDING,
  246. },
  247. style: {
  248. fill: color(theme.gray100).alpha(0.42).rgb().string(),
  249. },
  250. },
  251. ];
  252. };
  253. renderChartActions(
  254. totalDuration: number,
  255. criticalDuration: number,
  256. warningDuration: number
  257. ) {
  258. const {rule, orgId, projects, timePeriod, query} = this.props;
  259. const ctaOpts = {
  260. orgSlug: orgId,
  261. projects: projects as Project[],
  262. rule,
  263. eventType: query,
  264. start: timePeriod.start,
  265. end: timePeriod.end,
  266. };
  267. const {buttonText, ...props} = makeDefaultCta(ctaOpts);
  268. const resolvedPercent =
  269. (100 * Math.max(totalDuration - criticalDuration - warningDuration, 0)) /
  270. totalDuration;
  271. const criticalPercent = 100 * Math.min(criticalDuration / totalDuration, 1);
  272. const warningPercent = 100 * Math.min(warningDuration / totalDuration, 1);
  273. return (
  274. <ChartActions>
  275. <ChartSummary>
  276. <SummaryText>{t('SUMMARY')}</SummaryText>
  277. <SummaryStats>
  278. <StatItem>
  279. <IconCheckmark color="green300" isCircled />
  280. <StatCount>{resolvedPercent ? resolvedPercent.toFixed(2) : 0}%</StatCount>
  281. </StatItem>
  282. <StatItem>
  283. <IconWarning color="yellow300" />
  284. <StatCount>{warningPercent ? warningPercent.toFixed(2) : 0}%</StatCount>
  285. </StatItem>
  286. <StatItem>
  287. <IconFire color="red300" />
  288. <StatCount>{criticalPercent ? criticalPercent.toFixed(2) : 0}%</StatCount>
  289. </StatItem>
  290. </SummaryStats>
  291. </ChartSummary>
  292. {!isSessionAggregate(rule.aggregate) && (
  293. <Feature features={['discover-basic']}>
  294. <Button size="small" {...props}>
  295. {buttonText}
  296. </Button>
  297. </Feature>
  298. )}
  299. </ChartActions>
  300. );
  301. }
  302. renderChart(
  303. loading: boolean,
  304. timeseriesData?: Series[],
  305. minutesThresholdToDisplaySeconds?: number,
  306. comparisonTimeseriesData?: Series[]
  307. ) {
  308. const {
  309. router,
  310. selectedIncident,
  311. interval,
  312. handleZoom,
  313. filter,
  314. query,
  315. incidents,
  316. rule,
  317. organization,
  318. timePeriod: {start, end},
  319. } = this.props;
  320. const {dateModified, timeWindow, aggregate} = rule;
  321. if (loading || !timeseriesData) {
  322. return this.renderEmpty();
  323. }
  324. const criticalTrigger = rule.triggers.find(({label}) => label === 'critical');
  325. const warningTrigger = rule.triggers.find(({label}) => label === 'warning');
  326. const series: LineChartSeries[] = [...timeseriesData];
  327. const areaSeries: any[] = [];
  328. // Ensure series data appears above incident lines
  329. series[0].z = 100;
  330. const dataArr = timeseriesData[0].data;
  331. const maxSeriesValue = dataArr.reduce(
  332. (currMax, coord) => Math.max(currMax, coord.value),
  333. 0
  334. );
  335. // find the lowest value between chart data points, warning threshold,
  336. // critical threshold and then apply some breathing space
  337. const minChartValue = shouldScaleAlertChart(aggregate)
  338. ? Math.floor(
  339. Math.min(
  340. dataArr.reduce((currMax, coord) => Math.min(currMax, coord.value), Infinity),
  341. typeof warningTrigger?.alertThreshold === 'number'
  342. ? warningTrigger.alertThreshold
  343. : Infinity,
  344. typeof criticalTrigger?.alertThreshold === 'number'
  345. ? criticalTrigger.alertThreshold
  346. : Infinity
  347. ) / ALERT_CHART_MIN_MAX_BUFFER
  348. )
  349. : 0;
  350. const firstPoint = moment(dataArr[0]?.name).valueOf();
  351. const lastPoint = moment(dataArr[dataArr.length - 1]?.name).valueOf();
  352. const totalDuration = lastPoint - firstPoint;
  353. let criticalDuration = 0;
  354. let warningDuration = 0;
  355. series.push(
  356. createStatusAreaSeries(theme.green300, firstPoint, lastPoint, minChartValue)
  357. );
  358. if (incidents) {
  359. // select incidents that fall within the graph range
  360. const periodStart = moment.utc(firstPoint);
  361. incidents
  362. .filter(
  363. incident =>
  364. !incident.dateClosed || moment(incident.dateClosed).isAfter(periodStart)
  365. )
  366. .forEach(incident => {
  367. const statusChanges = incident.activities
  368. ?.filter(
  369. ({type, value}) =>
  370. type === IncidentActivityType.STATUS_CHANGE &&
  371. value &&
  372. [`${IncidentStatus.WARNING}`, `${IncidentStatus.CRITICAL}`].includes(
  373. value
  374. )
  375. )
  376. .sort(
  377. (a, b) => moment(a.dateCreated).valueOf() - moment(b.dateCreated).valueOf()
  378. );
  379. const incidentEnd = incident.dateClosed ?? moment().valueOf();
  380. const timeWindowMs = rule.timeWindow * 60 * 1000;
  381. const incidentColor =
  382. warningTrigger &&
  383. statusChanges &&
  384. !statusChanges.find(({value}) => value === `${IncidentStatus.CRITICAL}`)
  385. ? theme.yellow300
  386. : theme.red300;
  387. const incidentStartDate = moment(incident.dateStarted).valueOf();
  388. const incidentCloseDate = incident.dateClosed
  389. ? moment(incident.dateClosed).valueOf()
  390. : lastPoint;
  391. const incidentStartValue = dataArr.find(
  392. point => moment(point.name).valueOf() >= incidentStartDate
  393. );
  394. series.push(
  395. createIncidentSeries(
  396. router,
  397. organization,
  398. incidentColor,
  399. incidentStartDate,
  400. incident,
  401. incidentStartValue,
  402. series[0].seriesName,
  403. aggregate
  404. )
  405. );
  406. const areaStart = Math.max(moment(incident.dateStarted).valueOf(), firstPoint);
  407. const areaEnd = Math.min(
  408. statusChanges?.length && statusChanges[0].dateCreated
  409. ? moment(statusChanges[0].dateCreated).valueOf() - timeWindowMs
  410. : moment(incidentEnd).valueOf(),
  411. lastPoint
  412. );
  413. const areaColor = warningTrigger ? theme.yellow300 : theme.red300;
  414. if (areaEnd > areaStart) {
  415. series.push(
  416. createStatusAreaSeries(areaColor, areaStart, areaEnd, minChartValue)
  417. );
  418. if (areaColor === theme.yellow300) {
  419. warningDuration += Math.abs(areaEnd - areaStart);
  420. } else {
  421. criticalDuration += Math.abs(areaEnd - areaStart);
  422. }
  423. }
  424. statusChanges?.forEach((activity, idx) => {
  425. const statusAreaStart = Math.max(
  426. moment(activity.dateCreated).valueOf() - timeWindowMs,
  427. firstPoint
  428. );
  429. const statusAreaEnd = Math.min(
  430. idx === statusChanges.length - 1
  431. ? moment(incidentEnd).valueOf()
  432. : moment(statusChanges[idx + 1].dateCreated).valueOf() - timeWindowMs,
  433. lastPoint
  434. );
  435. const statusAreaColor =
  436. activity.value === `${IncidentStatus.CRITICAL}`
  437. ? theme.red300
  438. : theme.yellow300;
  439. if (statusAreaEnd > statusAreaStart) {
  440. series.push(
  441. createStatusAreaSeries(
  442. statusAreaColor,
  443. statusAreaStart,
  444. statusAreaEnd,
  445. minChartValue
  446. )
  447. );
  448. if (statusAreaColor === theme.yellow300) {
  449. warningDuration += Math.abs(statusAreaEnd - statusAreaStart);
  450. } else {
  451. criticalDuration += Math.abs(statusAreaEnd - statusAreaStart);
  452. }
  453. }
  454. });
  455. if (selectedIncident && incident.id === selectedIncident.id) {
  456. const selectedIncidentColor =
  457. incidentColor === theme.yellow300 ? theme.yellow100 : theme.red100;
  458. areaSeries.push({
  459. type: 'line',
  460. markArea: MarkArea({
  461. silent: true,
  462. itemStyle: {
  463. color: color(selectedIncidentColor).alpha(0.42).rgb().string(),
  464. },
  465. data: [[{xAxis: incidentStartDate}, {xAxis: incidentCloseDate}]] as any,
  466. }),
  467. data: [],
  468. });
  469. }
  470. });
  471. }
  472. let maxThresholdValue = 0;
  473. if (!rule.comparisonDelta && warningTrigger?.alertThreshold) {
  474. const {alertThreshold} = warningTrigger;
  475. const warningThresholdLine = createThresholdSeries(theme.yellow300, alertThreshold);
  476. series.push(warningThresholdLine);
  477. maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
  478. }
  479. if (!rule.comparisonDelta && criticalTrigger?.alertThreshold) {
  480. const {alertThreshold} = criticalTrigger;
  481. const criticalThresholdLine = createThresholdSeries(theme.red300, alertThreshold);
  482. series.push(criticalThresholdLine);
  483. maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
  484. }
  485. const comparisonSeriesName = capitalize(
  486. COMPARISON_DELTA_OPTIONS.find(({value}) => value === rule.comparisonDelta)?.label ||
  487. ''
  488. );
  489. return (
  490. <ChartPanel>
  491. <StyledPanelBody withPadding>
  492. <ChartHeader>
  493. <ChartTitle>
  494. {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]}
  495. </ChartTitle>
  496. {query ? filter : null}
  497. </ChartHeader>
  498. {getDynamicText({
  499. value: (
  500. <ChartZoom
  501. router={router}
  502. start={start}
  503. end={end}
  504. onZoom={zoomArgs => handleZoom(zoomArgs.start, zoomArgs.end)}
  505. >
  506. {zoomRenderProps => (
  507. <LineChart
  508. {...zoomRenderProps}
  509. isGroupedByDate
  510. showTimeInTooltip
  511. minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
  512. forwardedRef={this.handleRef}
  513. grid={{
  514. left: space(0.25),
  515. right: space(2),
  516. top: space(2),
  517. bottom: 0,
  518. }}
  519. yAxis={{
  520. axisLabel: {
  521. formatter: (value: number) =>
  522. alertAxisFormatter(
  523. value,
  524. timeseriesData[0].seriesName,
  525. rule.aggregate
  526. ),
  527. },
  528. max:
  529. maxThresholdValue > maxSeriesValue
  530. ? maxThresholdValue
  531. : undefined,
  532. min: minChartValue || undefined,
  533. }}
  534. series={[...series, ...areaSeries]}
  535. additionalSeries={[
  536. ...(comparisonTimeseriesData || []).map(
  537. ({data: _data, ...otherSeriesProps}) =>
  538. LineSeries({
  539. name: comparisonSeriesName,
  540. data: _data.map(({name, value}) => [name, value]),
  541. lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
  542. itemStyle: {color: theme.gray200},
  543. animation: false,
  544. animationThreshold: 1,
  545. animationDuration: 0,
  546. ...otherSeriesProps,
  547. })
  548. ),
  549. ]}
  550. graphic={Graphic({
  551. elements: this.getRuleChangeThresholdElements(timeseriesData),
  552. })}
  553. tooltip={{
  554. formatter: seriesParams => {
  555. // seriesParams can be object instead of array
  556. const pointSeries = Array.isArray(seriesParams)
  557. ? seriesParams
  558. : [seriesParams];
  559. const {marker, data: pointData, seriesName} = pointSeries[0];
  560. const [pointX, pointY] = pointData as [number, number];
  561. const pointYFormatted = alertTooltipValueFormatter(
  562. pointY,
  563. seriesName ?? '',
  564. rule.aggregate
  565. );
  566. const isModified =
  567. dateModified && pointX <= new Date(dateModified).getTime();
  568. const startTime = formatTooltipDate(moment(pointX), 'MMM D LT');
  569. const {period, periodLength} = parseStatsPeriod(interval) ?? {
  570. periodLength: 'm',
  571. period: `${timeWindow}`,
  572. };
  573. const endTime = formatTooltipDate(
  574. moment(pointX).add(
  575. parseInt(period, 10),
  576. periodLength as StatsPeriodType
  577. ),
  578. 'MMM D LT'
  579. );
  580. const comparisonSeries =
  581. pointSeries.length > 1
  582. ? pointSeries.find(
  583. ({seriesName: _sn}) => _sn === comparisonSeriesName
  584. )
  585. : undefined;
  586. const comparisonPointY = comparisonSeries?.data[1] as
  587. | number
  588. | undefined;
  589. const comparisonPointYFormatted =
  590. comparisonPointY !== undefined
  591. ? alertTooltipValueFormatter(
  592. comparisonPointY,
  593. seriesName ?? '',
  594. rule.aggregate
  595. )
  596. : undefined;
  597. const changePercentage =
  598. comparisonPointY === undefined
  599. ? undefined
  600. : (comparisonPointY === 0 && pointY === 0
  601. ? 0
  602. : (pointY - comparisonPointY) / comparisonPointY) * 100;
  603. const changeStatus =
  604. changePercentage === undefined
  605. ? undefined
  606. : checkChangeStatus(
  607. changePercentage,
  608. rule.thresholdType,
  609. rule.triggers
  610. );
  611. const changeStatusColor =
  612. changeStatus === 'critical'
  613. ? theme.red300
  614. : changeStatus === 'warning'
  615. ? theme.yellow300
  616. : theme.green300;
  617. return [
  618. `<div class="tooltip-series">`,
  619. isModified &&
  620. `<div><span class="tooltip-label"><strong>${t(
  621. 'Alert Rule Modified'
  622. )}</strong></span></div>`,
  623. `<div><span class="tooltip-label">${marker} <strong>${seriesName}</strong></span>${pointYFormatted}</div>`,
  624. comparisonSeries &&
  625. `<div><span class="tooltip-label">${comparisonSeries.marker} <strong>${comparisonSeriesName}</strong></span>${comparisonPointYFormatted}</div>`,
  626. `</div>`,
  627. `<div class="tooltip-date">`,
  628. `<span>${startTime} &mdash; ${endTime}</span>`,
  629. changePercentage !== undefined &&
  630. `<span style="color:${changeStatusColor};margin-left:10px;">${
  631. Math.sign(changePercentage) === 1
  632. ? '+'
  633. : Math.sign(changePercentage) === -1
  634. ? '-'
  635. : ''
  636. }${
  637. Math.abs(changePercentage) === Infinity
  638. ? `&#8734`
  639. : Math.abs(changePercentage).toFixed(2)
  640. }%</span>`,
  641. `</div>`,
  642. `<div class="tooltip-arrow"></div>`,
  643. ]
  644. .filter(e => e)
  645. .join('');
  646. },
  647. }}
  648. onFinished={() => {
  649. // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
  650. // any graphics related to the triggers (e.g. the threshold areas + boundaries)
  651. this.updateDimensions();
  652. }}
  653. />
  654. )}
  655. </ChartZoom>
  656. ),
  657. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  658. })}
  659. </StyledPanelBody>
  660. {this.renderChartActions(totalDuration, criticalDuration, warningDuration)}
  661. </ChartPanel>
  662. );
  663. }
  664. renderEmpty() {
  665. return (
  666. <ChartPanel>
  667. <PanelBody withPadding>
  668. <Placeholder height="200px" />
  669. </PanelBody>
  670. </ChartPanel>
  671. );
  672. }
  673. render() {
  674. const {api, rule, organization, timePeriod, projects, interval, query} = this.props;
  675. const {aggregate, timeWindow, environment, dataset} = rule;
  676. // If the chart duration isn't as long as the rollup duration the events-stats
  677. // endpoint will return an invalid timeseriesData data set
  678. const viableStartDate = getUtcDateString(
  679. moment.min(
  680. moment.utc(timePeriod.start),
  681. moment.utc(timePeriod.end).subtract(timeWindow, 'minutes')
  682. )
  683. );
  684. const viableEndDate = getUtcDateString(
  685. moment.utc(timePeriod.end).add(timeWindow, 'minutes')
  686. );
  687. return dataset === Dataset.SESSIONS ? (
  688. <SessionsRequest
  689. api={api}
  690. organization={organization}
  691. project={projects.filter(p => p.id).map(p => Number(p.id))}
  692. environment={environment ? [environment] : undefined}
  693. start={viableStartDate}
  694. end={viableEndDate}
  695. query={query}
  696. interval={interval}
  697. field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
  698. groupBy={['session.status']}
  699. >
  700. {({loading, response}) =>
  701. this.renderChart(
  702. loading,
  703. [
  704. {
  705. seriesName:
  706. AlertWizardAlertNames[
  707. getAlertTypeFromAggregateDataset({
  708. aggregate,
  709. dataset: Dataset.SESSIONS,
  710. })
  711. ],
  712. data: getCrashFreeRateSeries(
  713. response?.groups,
  714. response?.intervals,
  715. SESSION_AGGREGATE_TO_FIELD[aggregate]
  716. ),
  717. },
  718. ],
  719. MINUTES_THRESHOLD_TO_DISPLAY_SECONDS
  720. )
  721. }
  722. </SessionsRequest>
  723. ) : (
  724. <EventsRequest
  725. api={api}
  726. organization={organization}
  727. query={query}
  728. environment={environment ? [environment] : undefined}
  729. project={(projects as Project[])
  730. .filter(p => p && p.slug)
  731. .map(project => Number(project.id))}
  732. interval={interval}
  733. comparisonDelta={rule.comparisonDelta ? rule.comparisonDelta * 60 : undefined}
  734. start={viableStartDate}
  735. end={viableEndDate}
  736. yAxis={aggregate}
  737. includePrevious={false}
  738. currentSeriesNames={[aggregate]}
  739. partial={false}
  740. referrer="api.alerts.alert-rule-chart"
  741. >
  742. {({loading, timeseriesData, comparisonTimeseriesData}) =>
  743. this.renderChart(loading, timeseriesData, undefined, comparisonTimeseriesData)
  744. }
  745. </EventsRequest>
  746. );
  747. }
  748. }
  749. export default withRouter(MetricChart);
  750. const ChartPanel = styled(Panel)`
  751. margin-top: ${space(2)};
  752. `;
  753. const ChartHeader = styled('div')`
  754. margin-bottom: ${space(3)};
  755. `;
  756. const ChartTitle = styled('header')`
  757. display: flex;
  758. flex-direction: row;
  759. `;
  760. const ChartActions = styled(PanelFooter)`
  761. display: flex;
  762. justify-content: flex-end;
  763. align-items: center;
  764. padding: ${space(1)} 20px;
  765. `;
  766. const ChartSummary = styled('div')`
  767. display: flex;
  768. margin-right: auto;
  769. `;
  770. const SummaryText = styled(SectionHeading)`
  771. flex: 1;
  772. display: flex;
  773. align-items: center;
  774. margin: 0;
  775. font-weight: bold;
  776. font-size: ${p => p.theme.fontSizeSmall};
  777. line-height: 1;
  778. `;
  779. const SummaryStats = styled('div')`
  780. display: flex;
  781. align-items: center;
  782. margin: 0 ${space(2)};
  783. `;
  784. const StatItem = styled('div')`
  785. display: flex;
  786. align-items: center;
  787. margin: 0 ${space(2)} 0 0;
  788. `;
  789. /* Override padding to make chart appear centered */
  790. const StyledPanelBody = styled(PanelBody)`
  791. padding-right: 6px;
  792. `;
  793. const StatCount = styled('span')`
  794. margin-left: ${space(0.5)};
  795. margin-top: ${space(0.25)};
  796. color: ${p => p.theme.textColor};
  797. `;