releaseSeries.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import {Component} from 'react';
  2. import type {WithRouterProps} from 'react-router';
  3. import type {Theme} from '@emotion/react';
  4. import {withTheme} from '@emotion/react';
  5. import type {Query} from 'history';
  6. import isEqual from 'lodash/isEqual';
  7. import memoize from 'lodash/memoize';
  8. import partition from 'lodash/partition';
  9. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  10. import type {Client, ResponseMeta} from 'sentry/api';
  11. import MarkLine from 'sentry/components/charts/components/markLine';
  12. import {t} from 'sentry/locale';
  13. import type {DateString} from 'sentry/types/core';
  14. import type {Series} from 'sentry/types/echarts';
  15. import type {Organization} from 'sentry/types/organization';
  16. import {escape} from 'sentry/utils';
  17. import {getFormattedDate, getUtcDateString} from 'sentry/utils/dates';
  18. import {formatVersion} from 'sentry/utils/formatters';
  19. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  20. import withApi from 'sentry/utils/withApi';
  21. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  22. import withOrganization from 'sentry/utils/withOrganization';
  23. // eslint-disable-next-line no-restricted-imports
  24. import withSentryRouter from 'sentry/utils/withSentryRouter';
  25. type ReleaseMetaBasic = {
  26. date: string;
  27. version: string;
  28. };
  29. type ReleaseConditions = {
  30. end: DateString;
  31. environment: Readonly<string[]>;
  32. project: Readonly<number[]>;
  33. start: DateString;
  34. cursor?: string;
  35. query?: string;
  36. statsPeriod?: string | null;
  37. };
  38. // This is not an exported action/function because releases list uses AsyncComponent
  39. // and this is not re-used anywhere else afaict
  40. function getOrganizationReleases(
  41. api: Client,
  42. organization: Organization,
  43. conditions: ReleaseConditions
  44. ) {
  45. const query = {};
  46. Object.keys(conditions).forEach(key => {
  47. let value = conditions[key];
  48. if (value && (key === 'start' || key === 'end')) {
  49. value = getUtcDateString(value);
  50. }
  51. if (value) {
  52. query[key] = value;
  53. }
  54. });
  55. api.clear();
  56. return api.requestPromise(`/organizations/${organization.slug}/releases/stats/`, {
  57. includeAllArgs: true,
  58. method: 'GET',
  59. query,
  60. }) as Promise<[ReleaseMetaBasic[], any, ResponseMeta]>;
  61. }
  62. export interface ReleaseSeriesProps extends WithRouterProps {
  63. api: Client;
  64. children: (s: State) => React.ReactNode;
  65. end: DateString;
  66. environments: Readonly<string[]>;
  67. organization: Organization;
  68. projects: Readonly<number[]>;
  69. start: DateString;
  70. theme: Theme;
  71. emphasizeReleases?: string[];
  72. memoized?: boolean;
  73. period?: string | null;
  74. preserveQueryParams?: boolean;
  75. query?: string;
  76. queryExtra?: Query;
  77. releases?: ReleaseMetaBasic[] | null;
  78. tooltip?: Exclude<Parameters<typeof MarkLine>[0], undefined>['tooltip'];
  79. utc?: boolean | null;
  80. }
  81. type State = {
  82. releaseSeries: Series[];
  83. releases: ReleaseMetaBasic[] | null;
  84. };
  85. class ReleaseSeries extends Component<ReleaseSeriesProps, State> {
  86. state: State = {
  87. releases: null,
  88. releaseSeries: [],
  89. };
  90. componentDidMount() {
  91. this._isMounted = true;
  92. const {releases} = this.props;
  93. if (releases) {
  94. // No need to fetch releases if passed in from props
  95. this.setReleasesWithSeries(releases);
  96. return;
  97. }
  98. this.fetchData();
  99. }
  100. componentDidUpdate(prevProps) {
  101. if (
  102. !isEqual(prevProps.projects, this.props.projects) ||
  103. !isEqual(prevProps.environments, this.props.environments) ||
  104. !isEqual(prevProps.start, this.props.start) ||
  105. !isEqual(prevProps.end, this.props.end) ||
  106. !isEqual(prevProps.period, this.props.period) ||
  107. !isEqual(prevProps.query, this.props.query)
  108. ) {
  109. this.fetchData();
  110. } else if (!isEqual(prevProps.emphasizeReleases, this.props.emphasizeReleases)) {
  111. this.setReleasesWithSeries(this.state.releases);
  112. }
  113. }
  114. componentWillUnmount() {
  115. this._isMounted = false;
  116. this.props.api.clear();
  117. }
  118. _isMounted: boolean = false;
  119. getOrganizationReleasesMemoized = memoize(
  120. (api: Client, organization: Organization, conditions: ReleaseConditions) =>
  121. getOrganizationReleases(api, organization, conditions),
  122. (_, __, conditions) =>
  123. Object.values(conditions)
  124. .map(val => JSON.stringify(val))
  125. .join('-')
  126. );
  127. async fetchData() {
  128. const {
  129. api,
  130. organization,
  131. projects,
  132. environments,
  133. period,
  134. start,
  135. end,
  136. memoized,
  137. query,
  138. } = this.props;
  139. const conditions: ReleaseConditions = {
  140. start,
  141. end,
  142. project: projects,
  143. environment: environments,
  144. statsPeriod: period,
  145. query,
  146. };
  147. let hasMore = true;
  148. const releases: ReleaseMetaBasic[] = [];
  149. while (hasMore) {
  150. try {
  151. const getReleases = memoized
  152. ? this.getOrganizationReleasesMemoized
  153. : getOrganizationReleases;
  154. const [newReleases, , resp] = await getReleases(api, organization, conditions);
  155. releases.push(...newReleases);
  156. if (this._isMounted) {
  157. this.setReleasesWithSeries(releases);
  158. }
  159. const pageLinks = resp?.getResponseHeader('Link');
  160. if (pageLinks) {
  161. const paginationObject = parseLinkHeader(pageLinks);
  162. hasMore = paginationObject?.next?.results ?? false;
  163. conditions.cursor = paginationObject.next.cursor;
  164. } else {
  165. hasMore = false;
  166. }
  167. } catch {
  168. addErrorMessage(t('Error fetching releases'));
  169. hasMore = false;
  170. }
  171. }
  172. }
  173. setReleasesWithSeries(releases) {
  174. const {emphasizeReleases = []} = this.props;
  175. const releaseSeries: Series[] = [];
  176. if (emphasizeReleases.length) {
  177. const [unemphasizedReleases, emphasizedReleases] = partition(
  178. releases,
  179. release => !emphasizeReleases.includes(release.version)
  180. );
  181. if (unemphasizedReleases.length) {
  182. releaseSeries.push(this.getReleaseSeries(unemphasizedReleases, {type: 'dotted'}));
  183. }
  184. if (emphasizedReleases.length) {
  185. releaseSeries.push(
  186. this.getReleaseSeries(emphasizedReleases, {
  187. opacity: 0.8,
  188. })
  189. );
  190. }
  191. } else {
  192. releaseSeries.push(this.getReleaseSeries(releases));
  193. }
  194. this.setState({
  195. releases,
  196. releaseSeries,
  197. });
  198. }
  199. getReleaseSeries = (releases, lineStyle = {}) => {
  200. const {
  201. organization,
  202. router,
  203. tooltip,
  204. environments,
  205. start,
  206. end,
  207. period,
  208. preserveQueryParams,
  209. queryExtra,
  210. theme,
  211. } = this.props;
  212. const query = {...queryExtra};
  213. if (organization.features.includes('global-views')) {
  214. query.project = router.location.query.project;
  215. }
  216. if (preserveQueryParams) {
  217. query.environment = [...environments];
  218. query.start = start ? getUtcDateString(start) : undefined;
  219. query.end = end ? getUtcDateString(end) : undefined;
  220. query.statsPeriod = period || undefined;
  221. }
  222. const markLine = MarkLine({
  223. animation: false,
  224. lineStyle: {
  225. color: theme.purple300,
  226. opacity: 0.3,
  227. type: 'solid',
  228. ...lineStyle,
  229. },
  230. label: {
  231. show: false,
  232. },
  233. data: releases.map(release => ({
  234. xAxis: +new Date(release.date),
  235. name: formatVersion(release.version, true),
  236. value: formatVersion(release.version, true),
  237. onClick: () => {
  238. router.push(
  239. normalizeUrl({
  240. pathname: `/organizations/${
  241. organization.slug
  242. }/releases/${encodeURIComponent(release.version)}/`,
  243. query,
  244. })
  245. );
  246. },
  247. label: {
  248. formatter: () => formatVersion(release.version, true),
  249. },
  250. })),
  251. tooltip: tooltip || {
  252. trigger: 'item',
  253. formatter: ({data}: any) => {
  254. // Should only happen when navigating pages
  255. if (!data) {
  256. return '';
  257. }
  258. // XXX using this.props here as this function does not get re-run
  259. // unless projects are changed. Using a closure variable would result
  260. // in stale values.
  261. const time = getFormattedDate(data.value, 'MMM D, YYYY LT', {
  262. local: !this.props.utc,
  263. });
  264. const version = escape(formatVersion(data.name, true));
  265. return [
  266. '<div class="tooltip-series">',
  267. `<div><span class="tooltip-label"><strong>${t(
  268. 'Release'
  269. )}</strong></span> ${version}</div>`,
  270. '</div>',
  271. '<div class="tooltip-footer">',
  272. time,
  273. '</div>',
  274. '</div>',
  275. '<div class="tooltip-arrow"></div>',
  276. ].join('');
  277. },
  278. },
  279. });
  280. return {
  281. seriesName: 'Releases',
  282. color: theme.purple200,
  283. data: [],
  284. markLine,
  285. };
  286. };
  287. render() {
  288. const {children} = this.props;
  289. return children({
  290. releases: this.state.releases,
  291. releaseSeries: this.state.releaseSeries,
  292. });
  293. }
  294. }
  295. export default withSentryRouter(withOrganization(withApi(withTheme(ReleaseSeries))));