metricChart.tsx 29 KB

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