utils.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import {Query} from 'history';
  2. import cloneDeep from 'lodash/cloneDeep';
  3. import pick from 'lodash/pick';
  4. import trimStart from 'lodash/trimStart';
  5. import * as qs from 'query-string';
  6. import WidgetArea from 'sentry-images/dashboard/widget-area.svg';
  7. import WidgetBar from 'sentry-images/dashboard/widget-bar.svg';
  8. import WidgetBigNumber from 'sentry-images/dashboard/widget-big-number.svg';
  9. import WidgetLine from 'sentry-images/dashboard/widget-line-1.svg';
  10. import WidgetTable from 'sentry-images/dashboard/widget-table.svg';
  11. import WidgetWorldMap from 'sentry-images/dashboard/widget-world-map.svg';
  12. import {parseArithmetic} from 'sentry/components/arithmeticInput/parser';
  13. import {
  14. Fidelity,
  15. getDiffInMinutes,
  16. getInterval,
  17. SIX_HOURS,
  18. TWENTY_FOUR_HOURS,
  19. } from 'sentry/components/charts/utils';
  20. import {Organization, PageFilters} from 'sentry/types';
  21. import {defined} from 'sentry/utils';
  22. import {getUtcDateString, parsePeriodToHours} from 'sentry/utils/dates';
  23. import EventView from 'sentry/utils/discover/eventView';
  24. import {
  25. getAggregateAlias,
  26. getAggregateArg,
  27. getColumnsAndAggregates,
  28. isEquation,
  29. isMeasurement,
  30. stripEquationPrefix,
  31. } from 'sentry/utils/discover/fields';
  32. import {DisplayModes} from 'sentry/utils/discover/types';
  33. import {getMeasurements} from 'sentry/utils/measurements/measurements';
  34. import {
  35. DashboardDetails,
  36. DisplayType,
  37. Widget,
  38. WidgetQuery,
  39. WidgetType,
  40. } from 'sentry/views/dashboardsV2/types';
  41. export type ValidationError = {
  42. [key: string]: string | string[] | ValidationError[] | ValidationError;
  43. };
  44. export type FlatValidationError = {
  45. [key: string]: string | FlatValidationError[] | FlatValidationError;
  46. };
  47. export function cloneDashboard(dashboard: DashboardDetails): DashboardDetails {
  48. return cloneDeep(dashboard);
  49. }
  50. export function eventViewFromWidget(
  51. title: string,
  52. query: WidgetQuery,
  53. selection: PageFilters,
  54. widgetDisplayType?: DisplayType
  55. ): EventView {
  56. const {start, end, period: statsPeriod} = selection.datetime;
  57. const {projects, environments} = selection;
  58. // World Map requires an additional column (geo.country_code) to display in discover when navigating from the widget
  59. const fields =
  60. widgetDisplayType === DisplayType.WORLD_MAP &&
  61. !query.columns.includes('geo.country_code')
  62. ? ['geo.country_code', ...query.columns, ...query.aggregates]
  63. : [...query.columns, ...query.aggregates];
  64. const conditions =
  65. widgetDisplayType === DisplayType.WORLD_MAP &&
  66. !query.conditions.includes('has:geo.country_code')
  67. ? `${query.conditions} has:geo.country_code`.trim()
  68. : query.conditions;
  69. const {orderby} = query;
  70. // Need to convert orderby to aggregate alias because eventView still uses aggregate alias format
  71. const aggregateAliasOrderBy = orderby
  72. ? `${orderby.startsWith('-') ? '-' : ''}${getAggregateAlias(trimStart(orderby, '-'))}`
  73. : orderby;
  74. return EventView.fromSavedQuery({
  75. id: undefined,
  76. name: title,
  77. version: 2,
  78. fields,
  79. query: conditions,
  80. orderby: aggregateAliasOrderBy,
  81. projects,
  82. range: statsPeriod ?? undefined,
  83. start: start ? getUtcDateString(start) : undefined,
  84. end: end ? getUtcDateString(end) : undefined,
  85. environment: environments,
  86. });
  87. }
  88. function coerceStringToArray(value?: string | string[] | null) {
  89. return typeof value === 'string' ? [value] : value;
  90. }
  91. export function constructWidgetFromQuery(query?: Query): Widget | undefined {
  92. if (query) {
  93. const queryNames = coerceStringToArray(query.queryNames);
  94. const queryConditions = coerceStringToArray(query.queryConditions);
  95. const queryFields = coerceStringToArray(query.queryFields);
  96. const queries: WidgetQuery[] = [];
  97. if (
  98. queryConditions &&
  99. queryNames &&
  100. queryFields &&
  101. typeof query.queryOrderby === 'string'
  102. ) {
  103. const {columns, aggregates} = getColumnsAndAggregates(queryFields);
  104. queryConditions.forEach((condition, index) => {
  105. queries.push({
  106. name: queryNames[index],
  107. conditions: condition,
  108. fields: queryFields,
  109. columns,
  110. aggregates,
  111. orderby: query.queryOrderby as string,
  112. });
  113. });
  114. }
  115. if (query.title && query.displayType && query.interval && queries.length > 0) {
  116. const newWidget: Widget = {
  117. ...(pick(query, ['title', 'displayType', 'interval']) as {
  118. displayType: DisplayType;
  119. interval: string;
  120. title: string;
  121. }),
  122. widgetType: WidgetType.DISCOVER,
  123. queries,
  124. };
  125. return newWidget;
  126. }
  127. }
  128. return undefined;
  129. }
  130. export function miniWidget(displayType: DisplayType): string {
  131. switch (displayType) {
  132. case DisplayType.BAR:
  133. return WidgetBar;
  134. case DisplayType.AREA:
  135. case DisplayType.TOP_N:
  136. return WidgetArea;
  137. case DisplayType.BIG_NUMBER:
  138. return WidgetBigNumber;
  139. case DisplayType.TABLE:
  140. return WidgetTable;
  141. case DisplayType.WORLD_MAP:
  142. return WidgetWorldMap;
  143. case DisplayType.LINE:
  144. default:
  145. return WidgetLine;
  146. }
  147. }
  148. export function getWidgetInterval(
  149. displayType: DisplayType,
  150. datetimeObj: Partial<PageFilters['datetime']>,
  151. widgetInterval?: string,
  152. fidelity?: Fidelity
  153. ): string {
  154. // Don't fetch more than 66 bins as we're plotting on a small area.
  155. const MAX_BIN_COUNT = 66;
  156. // Bars charts are daily totals to aligned with discover. It also makes them
  157. // usefully different from line/area charts until we expose the interval control, or remove it.
  158. let interval = displayType === 'bar' ? '1d' : widgetInterval;
  159. if (!interval) {
  160. // Default to 5 minutes
  161. interval = '5m';
  162. }
  163. const desiredPeriod = parsePeriodToHours(interval);
  164. const selectedRange = getDiffInMinutes(datetimeObj);
  165. if (fidelity) {
  166. // Primarily to support lower fidelity for Release Health widgets
  167. // the sort on releases and hit the metrics API endpoint.
  168. interval = getInterval(datetimeObj, fidelity);
  169. if (selectedRange > SIX_HOURS && selectedRange <= TWENTY_FOUR_HOURS) {
  170. interval = '1h';
  171. }
  172. return displayType === 'bar' ? '1d' : interval;
  173. }
  174. // selectedRange is in minutes, desiredPeriod is in hours
  175. // convert desiredPeriod to minutes
  176. if (selectedRange / (desiredPeriod * 60) > MAX_BIN_COUNT) {
  177. const highInterval = getInterval(datetimeObj, 'high');
  178. // Only return high fidelity interval if desired interval is higher fidelity
  179. if (desiredPeriod < parsePeriodToHours(highInterval)) {
  180. return highInterval;
  181. }
  182. }
  183. return interval;
  184. }
  185. export function getFieldsFromEquations(fields: string[]): string[] {
  186. // Gather all fields and functions used in equations and prepend them to the provided fields
  187. const termsSet: Set<string> = new Set();
  188. fields.filter(isEquation).forEach(field => {
  189. const parsed = parseArithmetic(stripEquationPrefix(field)).tc;
  190. parsed.fields.forEach(({term}) => termsSet.add(term as string));
  191. parsed.functions.forEach(({term}) => termsSet.add(term as string));
  192. });
  193. return Array.from(termsSet);
  194. }
  195. export function getWidgetDiscoverUrl(
  196. widget: Widget,
  197. selection: PageFilters,
  198. organization: Organization,
  199. index: number = 0,
  200. isMetricsData: boolean = false
  201. ) {
  202. const eventView = eventViewFromWidget(
  203. widget.title,
  204. widget.queries[index],
  205. selection,
  206. widget.displayType
  207. );
  208. const discoverLocation = eventView.getResultsViewUrlTarget(organization.slug);
  209. // Pull a max of 3 valid Y-Axis from the widget
  210. const yAxisOptions = eventView.getYAxisOptions().map(({value}) => value);
  211. discoverLocation.query.yAxis = [
  212. ...new Set(
  213. widget.queries[0].aggregates.filter(aggregate => yAxisOptions.includes(aggregate))
  214. ),
  215. ].slice(0, 3);
  216. // Visualization specific transforms
  217. switch (widget.displayType) {
  218. case DisplayType.WORLD_MAP:
  219. discoverLocation.query.display = DisplayModes.WORLDMAP;
  220. break;
  221. case DisplayType.BAR:
  222. discoverLocation.query.display = DisplayModes.BAR;
  223. break;
  224. case DisplayType.TOP_N:
  225. discoverLocation.query.display = DisplayModes.TOP5;
  226. // Last field is used as the yAxis
  227. const aggregates = widget.queries[0].aggregates;
  228. discoverLocation.query.yAxis = aggregates[aggregates.length - 1];
  229. if (aggregates.slice(0, -1).includes(aggregates[aggregates.length - 1])) {
  230. discoverLocation.query.field = aggregates.slice(0, -1);
  231. }
  232. break;
  233. default:
  234. break;
  235. }
  236. // Equation fields need to have their terms explicitly selected as columns in the discover table
  237. const fields = discoverLocation.query.field;
  238. const query = widget.queries[0];
  239. const queryFields = defined(query.fields)
  240. ? query.fields
  241. : [...query.columns, ...query.aggregates];
  242. const equationFields = getFieldsFromEquations(queryFields);
  243. // Updates fields by adding any individual terms from equation fields as a column
  244. equationFields.forEach(term => {
  245. if (Array.isArray(fields) && !fields.includes(term)) {
  246. fields.unshift(term);
  247. }
  248. });
  249. if (isMetricsData) {
  250. discoverLocation.query.fromMetric = 'true';
  251. }
  252. // Construct and return the discover url
  253. const discoverPath = `${discoverLocation.pathname}?${qs.stringify({
  254. ...discoverLocation.query,
  255. })}`;
  256. return discoverPath;
  257. }
  258. export function getWidgetIssueUrl(
  259. widget: Widget,
  260. selection: PageFilters,
  261. organization: Organization
  262. ) {
  263. const {start, end, utc, period} = selection.datetime;
  264. const datetime =
  265. start && end
  266. ? {start: getUtcDateString(start), end: getUtcDateString(end), utc}
  267. : {statsPeriod: period};
  268. const issuesLocation = `/organizations/${organization.slug}/issues/?${qs.stringify({
  269. query: widget.queries?.[0]?.conditions,
  270. sort: widget.queries?.[0]?.orderby,
  271. ...datetime,
  272. project: selection.projects,
  273. environment: selection.environments,
  274. })}`;
  275. return issuesLocation;
  276. }
  277. export function getWidgetReleasesUrl(
  278. _widget: Widget,
  279. selection: PageFilters,
  280. organization: Organization
  281. ) {
  282. const {start, end, utc, period} = selection.datetime;
  283. const datetime =
  284. start && end
  285. ? {start: getUtcDateString(start), end: getUtcDateString(end), utc}
  286. : {statsPeriod: period};
  287. const releasesLocation = `/organizations/${organization.slug}/releases/?${qs.stringify({
  288. ...datetime,
  289. project: selection.projects,
  290. environment: selection.environments,
  291. })}`;
  292. return releasesLocation;
  293. }
  294. export function flattenErrors(
  295. data: ValidationError | string,
  296. update: FlatValidationError
  297. ): FlatValidationError {
  298. if (typeof data === 'string') {
  299. update.error = data;
  300. } else {
  301. Object.keys(data).forEach((key: string) => {
  302. const value = data[key];
  303. if (typeof value === 'string') {
  304. update[key] = value;
  305. return;
  306. }
  307. // Recurse into nested objects.
  308. if (Array.isArray(value) && typeof value[0] === 'string') {
  309. update[key] = value[0];
  310. return;
  311. }
  312. if (Array.isArray(value) && typeof value[0] === 'object') {
  313. (value as ValidationError[]).map(item => flattenErrors(item, update));
  314. } else {
  315. flattenErrors(value as ValidationError, update);
  316. }
  317. });
  318. }
  319. return update;
  320. }
  321. export function getDashboardsMEPQueryParams(isMEPEnabled: boolean) {
  322. return isMEPEnabled
  323. ? {
  324. dataset: 'metricsEnhanced',
  325. }
  326. : {};
  327. }
  328. export function getNumEquations(possibleEquations: string[]) {
  329. return possibleEquations.filter(isEquation).length;
  330. }
  331. function isCustomMeasurement(field: string) {
  332. const definedMeasurements = Object.keys(getMeasurements());
  333. return isMeasurement(field) && !definedMeasurements.includes(field);
  334. }
  335. export function isCustomMeasurementWidget(widget: Widget) {
  336. return (
  337. widget.widgetType === WidgetType.DISCOVER &&
  338. widget.queries.some(({aggregates, columns, fields}) => {
  339. const aggregateArgs = aggregates.reduce((acc: string[], aggregate) => {
  340. // Should be ok to use getAggregateArg. getAggregateArg only returns the first arg
  341. // but there aren't any custom measurement aggregates that use custom measurements
  342. // outside of the first arg.
  343. const aggregateArg = getAggregateArg(aggregate);
  344. if (aggregateArg) {
  345. acc.push(aggregateArg);
  346. }
  347. return acc;
  348. }, []);
  349. return [...aggregateArgs, ...columns, ...(fields ?? [])].some(field =>
  350. isCustomMeasurement(field)
  351. );
  352. })
  353. );
  354. }
  355. export function getCustomMeasurementQueryParams() {
  356. return {
  357. dataset: 'metrics',
  358. };
  359. }