utils.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import type {EChartsOption, LegendComponentOption, LineSeriesOption} from 'echarts';
  2. import type {Location} from 'history';
  3. import moment from 'moment';
  4. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  5. import {EventsStats, MultiSeriesEventsStats, PageFilters} from 'sentry/types';
  6. import {defined, escape} from 'sentry/utils';
  7. import {getFormattedDate, parsePeriodToHours} from 'sentry/utils/dates';
  8. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  9. import {decodeList} from 'sentry/utils/queryString';
  10. const DEFAULT_TRUNCATE_LENGTH = 80;
  11. // In minutes
  12. export const SIXTY_DAYS = 86400;
  13. export const THIRTY_DAYS = 43200;
  14. export const TWO_WEEKS = 20160;
  15. export const ONE_WEEK = 10080;
  16. export const TWENTY_FOUR_HOURS = 1440;
  17. export const SIX_HOURS = 360;
  18. export const ONE_HOUR = 60;
  19. /**
  20. * If there are more releases than this number we hide "Releases" series by default
  21. */
  22. export const RELEASE_LINES_THRESHOLD = 50;
  23. export type DateTimeObject = Partial<PageFilters['datetime']>;
  24. export function truncationFormatter(
  25. value: string,
  26. truncate: number | boolean | undefined
  27. ): string {
  28. if (!truncate) {
  29. return escape(value);
  30. }
  31. const truncationLength =
  32. truncate && typeof truncate === 'number' ? truncate : DEFAULT_TRUNCATE_LENGTH;
  33. const truncated =
  34. value.length > truncationLength ? value.substring(0, truncationLength) + '…' : value;
  35. return escape(truncated);
  36. }
  37. /**
  38. * Use a shorter interval if the time difference is <= 24 hours.
  39. */
  40. export function useShortInterval(datetimeObj: DateTimeObject): boolean {
  41. const diffInMinutes = getDiffInMinutes(datetimeObj);
  42. return diffInMinutes <= TWENTY_FOUR_HOURS;
  43. }
  44. export type Fidelity = 'high' | 'medium' | 'low';
  45. export function getInterval(datetimeObj: DateTimeObject, fidelity: Fidelity = 'medium') {
  46. const diffInMinutes = getDiffInMinutes(datetimeObj);
  47. if (diffInMinutes >= SIXTY_DAYS) {
  48. // Greater than or equal to 60 days
  49. if (fidelity === 'high') {
  50. return '4h';
  51. }
  52. if (fidelity === 'medium') {
  53. return '1d';
  54. }
  55. return '2d';
  56. }
  57. if (diffInMinutes >= THIRTY_DAYS) {
  58. // Greater than or equal to 30 days
  59. if (fidelity === 'high') {
  60. return '1h';
  61. }
  62. if (fidelity === 'medium') {
  63. return '4h';
  64. }
  65. return '1d';
  66. }
  67. if (diffInMinutes >= TWO_WEEKS) {
  68. if (fidelity === 'high') {
  69. return '30m';
  70. }
  71. if (fidelity === 'medium') {
  72. return '1h';
  73. }
  74. return '12h';
  75. }
  76. if (diffInMinutes > TWENTY_FOUR_HOURS) {
  77. // Greater than 24 hours
  78. if (fidelity === 'high') {
  79. return '30m';
  80. }
  81. if (fidelity === 'medium') {
  82. return '1h';
  83. }
  84. return '6h';
  85. }
  86. if (diffInMinutes > ONE_HOUR) {
  87. // Between 1 hour and 24 hours
  88. if (fidelity === 'high') {
  89. return '5m';
  90. }
  91. if (fidelity === 'medium') {
  92. return '15m';
  93. }
  94. return '1h';
  95. }
  96. // Less than or equal to 1 hour
  97. if (fidelity === 'high') {
  98. return '1m';
  99. }
  100. if (fidelity === 'medium') {
  101. return '5m';
  102. }
  103. return '10m';
  104. }
  105. /**
  106. * Duplicate of getInterval, except that we do not support <1h granularity
  107. * Used by OrgStatsV2 API
  108. */
  109. export function getSeriesApiInterval(datetimeObj: DateTimeObject) {
  110. const diffInMinutes = getDiffInMinutes(datetimeObj);
  111. if (diffInMinutes >= SIXTY_DAYS) {
  112. // Greater than or equal to 60 days
  113. return '1d';
  114. }
  115. if (diffInMinutes >= THIRTY_DAYS) {
  116. // Greater than or equal to 30 days
  117. return '4h';
  118. }
  119. return '1h';
  120. }
  121. export function getDiffInMinutes(datetimeObj: DateTimeObject): number {
  122. const {period, start, end} = datetimeObj;
  123. if (start && end) {
  124. return moment(end).diff(start, 'minutes');
  125. }
  126. return (
  127. parsePeriodToHours(typeof period === 'string' ? period : DEFAULT_STATS_PERIOD) * 60
  128. );
  129. }
  130. // Max period (in hours) before we can no long include previous period
  131. const MAX_PERIOD_HOURS_INCLUDE_PREVIOUS = 45 * 24;
  132. export function canIncludePreviousPeriod(
  133. includePrevious: boolean | undefined,
  134. period: string | null | undefined
  135. ) {
  136. if (!includePrevious) {
  137. return false;
  138. }
  139. if (period && parsePeriodToHours(period) > MAX_PERIOD_HOURS_INCLUDE_PREVIOUS) {
  140. return false;
  141. }
  142. // otherwise true
  143. return !!includePrevious;
  144. }
  145. export function shouldFetchPreviousPeriod({
  146. includePrevious = true,
  147. period,
  148. start,
  149. end,
  150. }: {
  151. includePrevious?: boolean;
  152. } & Pick<DateTimeObject, 'start' | 'end' | 'period'>) {
  153. return !start && !end && canIncludePreviousPeriod(includePrevious, period);
  154. }
  155. /**
  156. * Generates a series selection based on the query parameters defined by the location.
  157. */
  158. export function getSeriesSelection(
  159. location: Location
  160. ): LegendComponentOption['selected'] {
  161. const unselectedSeries = decodeList(location?.query.unselectedSeries);
  162. return unselectedSeries.reduce((selection, series) => {
  163. selection[series] = false;
  164. return selection;
  165. }, {});
  166. }
  167. function isSingleSeriesStats(
  168. data: MultiSeriesEventsStats | EventsStats
  169. ): data is EventsStats {
  170. return (
  171. (defined(data.data) || defined(data.totals)) &&
  172. defined(data.start) &&
  173. defined(data.end)
  174. );
  175. }
  176. export function isMultiSeriesStats(
  177. data: MultiSeriesEventsStats | EventsStats | null | undefined,
  178. isTopN?: boolean
  179. ): data is MultiSeriesEventsStats {
  180. return (
  181. defined(data) &&
  182. ((data.data === undefined && data.totals === undefined) ||
  183. (defined(isTopN) && isTopN && defined(data) && !isSingleSeriesStats(data))) // the isSingleSeriesStats check is for topN queries returning null data
  184. );
  185. }
  186. // If dimension is a number convert it to pixels, otherwise use dimension
  187. // without transform
  188. export const getDimensionValue = (dimension?: number | string | null) => {
  189. if (typeof dimension === 'number') {
  190. return `${dimension}px`;
  191. }
  192. if (dimension === null) {
  193. return undefined;
  194. }
  195. return dimension;
  196. };
  197. const RGB_LIGHTEN_VALUE = 30;
  198. export const lightenHexToRgb = (colors: string[]) =>
  199. colors.map(hex => {
  200. const rgb = [
  201. Math.min(parseInt(hex.slice(1, 3), 16) + RGB_LIGHTEN_VALUE, 255),
  202. Math.min(parseInt(hex.slice(3, 5), 16) + RGB_LIGHTEN_VALUE, 255),
  203. Math.min(parseInt(hex.slice(5, 7), 16) + RGB_LIGHTEN_VALUE, 255),
  204. ];
  205. return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
  206. });
  207. const DEFAULT_GEO_DATA = {
  208. title: '',
  209. data: [],
  210. };
  211. export const processTableResults = (tableResults?: TableDataWithTitle[]) => {
  212. if (!tableResults || !tableResults.length) {
  213. return DEFAULT_GEO_DATA;
  214. }
  215. const tableResult = tableResults[0];
  216. const {data} = tableResult;
  217. if (!data || !data.length) {
  218. return DEFAULT_GEO_DATA;
  219. }
  220. const preAggregate = Object.keys(data[0]).find(column => {
  221. return column !== 'geo.country_code';
  222. });
  223. if (!preAggregate) {
  224. return DEFAULT_GEO_DATA;
  225. }
  226. return {
  227. title: tableResult.title ?? '',
  228. data: data.map(row => {
  229. return {
  230. name: row['geo.country_code'] as string,
  231. value: row[preAggregate] as number,
  232. };
  233. }),
  234. };
  235. };
  236. export const getPreviousSeriesName = (seriesName: string) => {
  237. return `previous ${seriesName}`;
  238. };
  239. function formatList(items: Array<string | number | undefined>) {
  240. const filteredItems = items.filter(item => !!item);
  241. return [[...filteredItems].slice(0, -1).join(', '), [...filteredItems].slice(-1)]
  242. .filter(type => !!type)
  243. .join(' and ');
  244. }
  245. export function useEchartsAriaLabels(
  246. {series, useUTC}: Omit<EChartsOption, 'series'>,
  247. isGroupedByDate: boolean
  248. ) {
  249. const filteredSeries = Array.isArray(series)
  250. ? series.filter(s => s && !!s.data && s.data.length > 0)
  251. : [series];
  252. const dateFormat = useShortInterval({
  253. start: filteredSeries[0]?.data?.[0][0],
  254. end: filteredSeries[0]?.data?.slice(-1)[0][0],
  255. })
  256. ? `MMMM D, h:mm A`
  257. : 'MMMM Do';
  258. if (!filteredSeries[0]) {
  259. return {enabled: false};
  260. }
  261. function formatDate(date) {
  262. return getFormattedDate(date, dateFormat, {
  263. local: !useUTC,
  264. });
  265. }
  266. // Generate title (first sentence)
  267. const chartTypes = new Set(filteredSeries.map(s => s.type));
  268. const title = [
  269. `${formatList([...chartTypes])} chart`,
  270. isGroupedByDate
  271. ? `with ${formatDate(filteredSeries[0].data?.[0][0])} to ${formatDate(
  272. filteredSeries[0].data?.slice(-1)[0][0]
  273. )}`
  274. : '',
  275. `featuring ${filteredSeries.length} data series: ${formatList(
  276. filteredSeries.filter(s => s.data && s.data.length > 0).map(s => s.name)
  277. )}`,
  278. ].join(' ');
  279. // Generate series descriptions
  280. const seriesDescriptions = filteredSeries
  281. .map(s => {
  282. if (!s.data || s.data.length === 0) {
  283. return '';
  284. }
  285. let highestValue: NonNullable<LineSeriesOption['data']>[0] = [0, -Infinity];
  286. let lowestValue: NonNullable<LineSeriesOption['data']>[0] = [0, Infinity];
  287. s.data.forEach(datum => {
  288. if (!Array.isArray(datum)) {
  289. return;
  290. }
  291. if (datum[1] > highestValue[1]) {
  292. highestValue = datum;
  293. }
  294. if (datum[1] < lowestValue[1]) {
  295. lowestValue = datum;
  296. }
  297. });
  298. const lowestX = isGroupedByDate ? formatDate(lowestValue[0]) : lowestValue[0];
  299. const highestX = isGroupedByDate ? formatDate(lowestValue[0]) : lowestValue[0];
  300. const lowestY =
  301. typeof lowestValue[1] === 'number' ? +lowestValue[1].toFixed(3) : lowestValue[1];
  302. const highestY =
  303. typeof highestValue[1] === 'number'
  304. ? +highestValue[1].toFixed(3)
  305. : lowestValue[1];
  306. return `The ${s.name} series contains ${
  307. s.data?.length
  308. } data points. Its lowest value is ${lowestY} ${
  309. isGroupedByDate ? 'on' : 'at'
  310. } ${lowestX} and highest value is ${highestY} ${
  311. isGroupedByDate ? 'on' : 'at'
  312. } ${highestX}`;
  313. })
  314. .filter(s => !!s);
  315. return {
  316. enabled: true,
  317. label: {description: [title, ...seriesDescriptions].join('. ')},
  318. };
  319. }