vitalChart.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. // eslint-disable-next-line no-restricted-imports
  2. import {browserHistory, withRouter, WithRouterProps} from 'react-router';
  3. import {useTheme} from '@emotion/react';
  4. import ChartZoom from 'sentry/components/charts/chartZoom';
  5. import ErrorPanel from 'sentry/components/charts/errorPanel';
  6. import EventsRequest from 'sentry/components/charts/eventsRequest';
  7. import {LineChart, LineChartProps} from 'sentry/components/charts/lineChart';
  8. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  9. import {ChartContainer, HeaderTitleLegend} from 'sentry/components/charts/styles';
  10. import TransitionChart from 'sentry/components/charts/transitionChart';
  11. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  12. import {Panel} from 'sentry/components/panels';
  13. import QuestionTooltip from 'sentry/components/questionTooltip';
  14. import {IconWarning} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {DateString, OrganizationSummary} from 'sentry/types';
  17. import {Series} from 'sentry/types/echarts';
  18. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  19. import {WebVital} from 'sentry/utils/discover/fields';
  20. import getDynamicText from 'sentry/utils/getDynamicText';
  21. import useApi from 'sentry/utils/useApi';
  22. import {replaceSeriesName, transformEventStatsSmoothed} from '../trends/utils';
  23. import {ViewProps} from '../types';
  24. import {
  25. getMaxOfSeries,
  26. getVitalChartDefinitions,
  27. getVitalChartTitle,
  28. vitalNameFromLocation,
  29. VitalState,
  30. vitalStateColors,
  31. } from './utils';
  32. type Props = WithRouterProps &
  33. Omit<ViewProps, 'start' | 'end'> & {
  34. end: DateString | null;
  35. interval: string;
  36. organization: OrganizationSummary;
  37. start: DateString | null;
  38. };
  39. function VitalChart({
  40. project,
  41. environment,
  42. location,
  43. organization,
  44. query,
  45. statsPeriod,
  46. router,
  47. start,
  48. end,
  49. interval,
  50. }: Props) {
  51. const api = useApi();
  52. const theme = useTheme();
  53. const vitalName = vitalNameFromLocation(location);
  54. const yAxis = `p75(${vitalName})`;
  55. const {utc, legend, vitalPoor, markLines, chartOptions} = getVitalChartDefinitions({
  56. theme,
  57. location,
  58. yAxis,
  59. vital: vitalName,
  60. });
  61. function handleLegendSelectChanged(legendChange: {
  62. name: string;
  63. selected: Record<string, boolean>;
  64. type: string;
  65. }) {
  66. const {selected} = legendChange;
  67. const unselected = Object.keys(selected).filter(key => !selected[key]);
  68. const to = {
  69. ...location,
  70. query: {
  71. ...location.query,
  72. unselectedSeries: unselected,
  73. },
  74. };
  75. browserHistory.push(to);
  76. }
  77. return (
  78. <Panel>
  79. <ChartContainer>
  80. <HeaderTitleLegend>
  81. {getVitalChartTitle(vitalName)}
  82. <QuestionTooltip
  83. size="sm"
  84. position="top"
  85. title={t(`The durations shown should fall under the vital threshold.`)}
  86. />
  87. </HeaderTitleLegend>
  88. <ChartZoom router={router} period={statsPeriod} start={start} end={end} utc={utc}>
  89. {zoomRenderProps => (
  90. <EventsRequest
  91. api={api}
  92. organization={organization}
  93. period={statsPeriod}
  94. project={project}
  95. environment={environment}
  96. start={start}
  97. end={end}
  98. interval={interval}
  99. showLoading={false}
  100. query={query}
  101. includePrevious={false}
  102. yAxis={[yAxis]}
  103. partial
  104. >
  105. {({timeseriesData: results, errored, loading, reloading}) => {
  106. if (errored) {
  107. return (
  108. <ErrorPanel>
  109. <IconWarning color="gray500" size="lg" />
  110. </ErrorPanel>
  111. );
  112. }
  113. const colors =
  114. (results && theme.charts.getColorPalette(results.length - 2)) || [];
  115. const {smoothedResults} = transformEventStatsSmoothed(results);
  116. const smoothedSeries = smoothedResults
  117. ? smoothedResults.map(({seriesName, ...rest}, i: number) => {
  118. return {
  119. seriesName: replaceSeriesName(seriesName) || 'p75',
  120. ...rest,
  121. color: colors[i],
  122. lineStyle: {
  123. opacity: 1,
  124. width: 2,
  125. },
  126. };
  127. })
  128. : [];
  129. const seriesMax = getMaxOfSeries(smoothedSeries);
  130. const yAxisMax = Math.max(seriesMax, vitalPoor);
  131. chartOptions.yAxis!.max = yAxisMax * 1.1;
  132. return (
  133. <ReleaseSeries
  134. start={start}
  135. end={end}
  136. period={statsPeriod}
  137. utc={utc}
  138. projects={project}
  139. environments={environment}
  140. >
  141. {({releaseSeries}) => (
  142. <TransitionChart loading={loading} reloading={reloading}>
  143. <TransparentLoadingMask visible={reloading} />
  144. {getDynamicText({
  145. value: (
  146. <LineChart
  147. {...zoomRenderProps}
  148. {...chartOptions}
  149. legend={legend}
  150. onLegendSelectChanged={handleLegendSelectChanged}
  151. series={[...markLines, ...releaseSeries, ...smoothedSeries]}
  152. />
  153. ),
  154. fixed: 'Web Vitals Chart',
  155. })}
  156. </TransitionChart>
  157. )}
  158. </ReleaseSeries>
  159. );
  160. }}
  161. </EventsRequest>
  162. )}
  163. </ChartZoom>
  164. </ChartContainer>
  165. </Panel>
  166. );
  167. }
  168. export default withRouter(VitalChart);
  169. export type _VitalChartProps = {
  170. field: string;
  171. grid: LineChartProps['grid'];
  172. loading: boolean;
  173. reloading: boolean;
  174. data?: Series[];
  175. height?: number;
  176. utc?: boolean;
  177. vitalFields?: {
  178. goodCountField: string;
  179. mehCountField: string;
  180. poorCountField: string;
  181. };
  182. };
  183. function fieldToVitalType(
  184. seriesName: string,
  185. vitalFields: _VitalChartProps['vitalFields']
  186. ): VitalState | undefined {
  187. if (seriesName === vitalFields?.poorCountField.replace('equation|', '')) {
  188. return VitalState.POOR;
  189. }
  190. if (seriesName === vitalFields?.mehCountField.replace('equation|', '')) {
  191. return VitalState.MEH;
  192. }
  193. if (seriesName === vitalFields?.goodCountField.replace('equation|', '')) {
  194. return VitalState.GOOD;
  195. }
  196. return undefined;
  197. }
  198. export function _VitalChart(props: _VitalChartProps) {
  199. const {
  200. field: yAxis,
  201. data: _results,
  202. loading,
  203. reloading,
  204. height,
  205. grid,
  206. utc,
  207. vitalFields,
  208. } = props;
  209. const theme = useTheme();
  210. if (!_results || !vitalFields) {
  211. return null;
  212. }
  213. const chartOptions: Omit<LineChartProps, 'series'> = {
  214. grid,
  215. seriesOptions: {
  216. showSymbol: false,
  217. },
  218. tooltip: {
  219. trigger: 'axis',
  220. valueFormatter: (value: number, seriesName?: string) => {
  221. return tooltipFormatter(
  222. value,
  223. vitalFields[0] === WebVital.CLS ? seriesName : yAxis
  224. );
  225. },
  226. },
  227. xAxis: {
  228. show: false,
  229. },
  230. xAxes: undefined,
  231. yAxis: {
  232. axisLabel: {
  233. color: theme.chartLabel,
  234. showMaxLabel: false,
  235. formatter: (value: number) => axisLabelFormatter(value, yAxis),
  236. },
  237. },
  238. utc,
  239. isGroupedByDate: true,
  240. showTimeInTooltip: true,
  241. };
  242. const results = _results.filter(s => !!fieldToVitalType(s.seriesName, vitalFields));
  243. const smoothedSeries = results?.length
  244. ? results.map(({seriesName, ...rest}) => {
  245. const adjustedSeries = fieldToVitalType(seriesName, vitalFields) || 'count';
  246. return {
  247. seriesName: adjustedSeries,
  248. ...rest,
  249. color: theme[vitalStateColors[adjustedSeries]],
  250. lineStyle: {
  251. opacity: 1,
  252. width: 2,
  253. },
  254. };
  255. })
  256. : [];
  257. return (
  258. <div>
  259. <TransitionChart loading={loading} reloading={reloading}>
  260. <TransparentLoadingMask visible={reloading} />
  261. {getDynamicText({
  262. value: (
  263. <LineChart
  264. height={height}
  265. {...chartOptions}
  266. onLegendSelectChanged={() => {}}
  267. series={[...smoothedSeries]}
  268. isGroupedByDate
  269. />
  270. ),
  271. fixed: 'Web Vitals Chart',
  272. })}
  273. </TransitionChart>
  274. </div>
  275. );
  276. }