releaseSeries.tsx 8.8 KB

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