vitalChart.tsx 8.8 KB

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