genericWidgetQueries.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import {Component} from 'react';
  2. import cloneDeep from 'lodash/cloneDeep';
  3. import isEqual from 'lodash/isEqual';
  4. import {Client, ResponseMeta} from 'sentry/api';
  5. import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
  6. import {t} from 'sentry/locale';
  7. import {Organization, PageFilters} from 'sentry/types';
  8. import {Series} from 'sentry/types/echarts';
  9. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  10. import {DatasetConfig} from '../datasetConfig/base';
  11. import {
  12. DashboardFilters,
  13. DEFAULT_TABLE_LIMIT,
  14. DisplayType,
  15. Widget,
  16. WidgetQuery,
  17. } from '../types';
  18. function getReferrer(displayType: DisplayType) {
  19. let referrer: string = '';
  20. if (displayType === DisplayType.TABLE) {
  21. referrer = 'api.dashboards.tablewidget';
  22. } else if (displayType === DisplayType.BIG_NUMBER) {
  23. referrer = 'api.dashboards.bignumberwidget';
  24. } else if (displayType === DisplayType.WORLD_MAP) {
  25. referrer = 'api.dashboards.worldmapwidget';
  26. } else {
  27. referrer = `api.dashboards.widget.${displayType}-chart`;
  28. }
  29. return referrer;
  30. }
  31. export type OnDataFetchedProps = {
  32. pageLinks?: string;
  33. tableResults?: TableDataWithTitle[];
  34. timeseriesResults?: Series[];
  35. totalIssuesCount?: string;
  36. };
  37. export type GenericWidgetQueriesChildrenProps = {
  38. loading: boolean;
  39. errorMessage?: string;
  40. pageLinks?: string;
  41. tableResults?: TableDataWithTitle[];
  42. timeseriesResults?: Series[];
  43. totalCount?: string;
  44. };
  45. export type GenericWidgetQueriesProps<SeriesResponse, TableResponse> = {
  46. api: Client;
  47. children: (props: GenericWidgetQueriesChildrenProps) => JSX.Element;
  48. config: DatasetConfig<SeriesResponse, TableResponse>;
  49. organization: Organization;
  50. selection: PageFilters;
  51. widget: Widget;
  52. afterFetchSeriesData?: (result: SeriesResponse) => void;
  53. afterFetchTableData?: (
  54. result: TableResponse,
  55. response?: ResponseMeta
  56. ) => void | {totalIssuesCount?: string};
  57. cursor?: string;
  58. customDidUpdateComparator?: (
  59. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  60. nextProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  61. ) => boolean;
  62. dashboardFilters?: DashboardFilters;
  63. limit?: number;
  64. loading?: boolean;
  65. onDataFetched?: ({
  66. tableResults,
  67. timeseriesResults,
  68. totalIssuesCount,
  69. pageLinks,
  70. }: OnDataFetchedProps) => void;
  71. };
  72. type State<SeriesResponse> = {
  73. loading: boolean;
  74. errorMessage?: GenericWidgetQueriesChildrenProps['errorMessage'];
  75. pageLinks?: GenericWidgetQueriesChildrenProps['pageLinks'];
  76. queryFetchID?: symbol;
  77. rawResults?: SeriesResponse[];
  78. tableResults?: GenericWidgetQueriesChildrenProps['tableResults'];
  79. timeseriesResults?: GenericWidgetQueriesChildrenProps['timeseriesResults'];
  80. };
  81. class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
  82. GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  83. State<SeriesResponse>
  84. > {
  85. state: State<SeriesResponse> = {
  86. loading: true,
  87. queryFetchID: undefined,
  88. errorMessage: undefined,
  89. timeseriesResults: undefined,
  90. rawResults: undefined,
  91. tableResults: undefined,
  92. pageLinks: undefined,
  93. };
  94. componentDidMount() {
  95. this._isMounted = true;
  96. if (!this.props.loading) {
  97. this.fetchData();
  98. }
  99. }
  100. componentDidUpdate(
  101. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  102. ) {
  103. const {selection, widget, cursor, organization, config, customDidUpdateComparator} =
  104. this.props;
  105. // We do not fetch data whenever the query name changes.
  106. // Also don't count empty fields when checking for field changes
  107. const [prevWidgetQueryNames, prevWidgetQueries] = prevProps.widget.queries
  108. .map((query: WidgetQuery) => {
  109. query.aggregates = query.aggregates.filter(field => !!field);
  110. query.columns = query.columns.filter(field => !!field);
  111. return query;
  112. })
  113. .reduce(
  114. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  115. names.push(name);
  116. queries.push(rest);
  117. return [names, queries];
  118. },
  119. [[], []]
  120. );
  121. const [widgetQueryNames, widgetQueries] = widget.queries
  122. .map((query: WidgetQuery) => {
  123. query.aggregates = query.aggregates.filter(
  124. field => !!field && field !== 'equation|'
  125. );
  126. query.columns = query.columns.filter(field => !!field && field !== 'equation|');
  127. return query;
  128. })
  129. .reduce(
  130. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  131. names.push(name);
  132. queries.push(rest);
  133. return [names, queries];
  134. },
  135. [[], []]
  136. );
  137. if (
  138. customDidUpdateComparator
  139. ? customDidUpdateComparator(prevProps, this.props)
  140. : widget.limit !== prevProps.widget.limit ||
  141. !isEqual(widget.displayType, prevProps.widget.displayType) ||
  142. !isEqual(widget.interval, prevProps.widget.interval) ||
  143. !isEqual(widgetQueries, prevWidgetQueries) ||
  144. !isEqual(this.props.dashboardFilters, prevProps.dashboardFilters) ||
  145. !isSelectionEqual(selection, prevProps.selection) ||
  146. cursor !== prevProps.cursor
  147. ) {
  148. this.fetchData();
  149. return;
  150. }
  151. if (
  152. !this.state.loading &&
  153. !isEqual(prevWidgetQueryNames, widgetQueryNames) &&
  154. this.state.rawResults?.length === widget.queries.length
  155. ) {
  156. // If the query names has changed, then update timeseries labels
  157. // eslint-disable-next-line react/no-did-update-set-state
  158. this.setState(prevState => {
  159. const timeseriesResults = widget.queries.reduce((acc: Series[], query, index) => {
  160. return acc.concat(
  161. config.transformSeries!(prevState.rawResults![index], query, organization)
  162. );
  163. }, []);
  164. return {...prevState, timeseriesResults};
  165. });
  166. }
  167. }
  168. componentWillUnmount() {
  169. this._isMounted = false;
  170. }
  171. private _isMounted: boolean = false;
  172. applyDashboardFilters(widget: Widget): Widget {
  173. const {dashboardFilters} = this.props;
  174. let dashboardFilterConditions = '';
  175. if (dashboardFilters) {
  176. for (const [key, activeFilters] of Object.entries(dashboardFilters)) {
  177. if (activeFilters.length === 1) {
  178. dashboardFilterConditions += `${key}:${activeFilters[0]} `;
  179. } else if (activeFilters.length > 1) {
  180. dashboardFilterConditions += `${key}:[${activeFilters.join(',')}] `;
  181. }
  182. }
  183. }
  184. widget.queries.forEach(query => {
  185. query.conditions =
  186. query.conditions +
  187. (dashboardFilterConditions === '' ? '' : ` ${dashboardFilterConditions}`);
  188. });
  189. return widget;
  190. }
  191. async fetchTableData(queryFetchID: symbol) {
  192. const {
  193. widget: originalWidget,
  194. limit,
  195. config,
  196. api,
  197. organization,
  198. selection,
  199. cursor,
  200. afterFetchTableData,
  201. onDataFetched,
  202. } = this.props;
  203. const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
  204. const responses = await Promise.all(
  205. widget.queries.map(query => {
  206. let requestLimit: number | undefined = limit ?? DEFAULT_TABLE_LIMIT;
  207. let requestCreator = config.getTableRequest;
  208. if (widget.displayType === DisplayType.WORLD_MAP) {
  209. requestLimit = undefined;
  210. requestCreator = config.getWorldMapRequest;
  211. }
  212. if (!requestCreator) {
  213. throw new Error(
  214. t('This display type is not supported by the selected dataset.')
  215. );
  216. }
  217. return requestCreator(
  218. api,
  219. query,
  220. organization,
  221. selection,
  222. requestLimit,
  223. cursor,
  224. getReferrer(widget.displayType)
  225. );
  226. })
  227. );
  228. let transformedTableResults: TableDataWithTitle[] = [];
  229. let responsePageLinks: string | undefined;
  230. let afterTableFetchData: OnDataFetchedProps | undefined;
  231. responses.forEach(([data, _textstatus, resp], i) => {
  232. afterTableFetchData = afterFetchTableData?.(data, resp) ?? {};
  233. // Cast so we can add the title.
  234. const transformedData = config.transformTable(
  235. data,
  236. widget.queries[0],
  237. organization,
  238. selection
  239. ) as TableDataWithTitle;
  240. transformedData.title = widget.queries[i]?.name ?? '';
  241. // Overwrite the local var to work around state being stale in tests.
  242. transformedTableResults = [...transformedTableResults, transformedData];
  243. // There is some inconsistency with the capitalization of "link" in response headers
  244. responsePageLinks =
  245. (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined;
  246. });
  247. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  248. onDataFetched?.({
  249. tableResults: transformedTableResults,
  250. pageLinks: responsePageLinks,
  251. ...afterTableFetchData,
  252. });
  253. this.setState({
  254. tableResults: transformedTableResults,
  255. pageLinks: responsePageLinks,
  256. });
  257. }
  258. }
  259. async fetchSeriesData(queryFetchID: symbol) {
  260. const {
  261. widget: originalWidget,
  262. config,
  263. api,
  264. organization,
  265. selection,
  266. afterFetchSeriesData,
  267. onDataFetched,
  268. } = this.props;
  269. const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
  270. const responses = await Promise.all(
  271. widget.queries.map((_query, index) => {
  272. return config.getSeriesRequest!(
  273. api,
  274. widget,
  275. index,
  276. organization,
  277. selection,
  278. getReferrer(widget.displayType)
  279. );
  280. })
  281. );
  282. const rawResultsClone = cloneDeep(this.state.rawResults) ?? [];
  283. const transformedTimeseriesResults: Series[] = [];
  284. responses.forEach(([data], requestIndex) => {
  285. afterFetchSeriesData?.(data);
  286. rawResultsClone[requestIndex] = data;
  287. const transformedResult = config.transformSeries!(
  288. data,
  289. widget.queries[requestIndex],
  290. organization
  291. );
  292. // When charting timeseriesData on echarts, color association to a timeseries result
  293. // is order sensitive, ie series at index i on the timeseries array will use color at
  294. // index i on the color array. This means that on multi series results, we need to make
  295. // sure that the order of series in our results do not change between fetches to avoid
  296. // coloring inconsistencies between renders.
  297. transformedResult.forEach((result, resultIndex) => {
  298. transformedTimeseriesResults[
  299. requestIndex * transformedResult.length + resultIndex
  300. ] = result;
  301. });
  302. });
  303. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  304. onDataFetched?.({timeseriesResults: transformedTimeseriesResults});
  305. this.setState({
  306. timeseriesResults: transformedTimeseriesResults,
  307. rawResults: rawResultsClone,
  308. });
  309. }
  310. }
  311. async fetchData() {
  312. const {widget} = this.props;
  313. const queryFetchID = Symbol('queryFetchID');
  314. this.setState({
  315. loading: true,
  316. tableResults: undefined,
  317. timeseriesResults: undefined,
  318. errorMessage: undefined,
  319. queryFetchID,
  320. });
  321. try {
  322. if (
  323. [DisplayType.TABLE, DisplayType.BIG_NUMBER, DisplayType.WORLD_MAP].includes(
  324. widget.displayType
  325. )
  326. ) {
  327. await this.fetchTableData(queryFetchID);
  328. } else {
  329. await this.fetchSeriesData(queryFetchID);
  330. }
  331. } catch (err) {
  332. if (this._isMounted) {
  333. this.setState({
  334. errorMessage:
  335. err?.responseJSON?.detail || err?.message || t('An unknown error occurred.'),
  336. });
  337. }
  338. } finally {
  339. if (this._isMounted) {
  340. this.setState({loading: false});
  341. }
  342. }
  343. }
  344. render() {
  345. const {children} = this.props;
  346. const {loading, tableResults, timeseriesResults, errorMessage, pageLinks} =
  347. this.state;
  348. return children({loading, tableResults, timeseriesResults, errorMessage, pageLinks});
  349. }
  350. }
  351. export default GenericWidgetQueries;