import * as React from 'react'; import cloneDeep from 'lodash/cloneDeep'; import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import {doMetricsRequest} from 'sentry/actionCreators/metrics'; import {Client} from 'sentry/api'; import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils'; import {t} from 'sentry/locale'; import {MetricsApiResponse, OrganizationSummary, PageFilters} from 'sentry/types'; import {Series} from 'sentry/types/echarts'; import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; import {TOP_N} from 'sentry/utils/discover/types'; import {transformMetricsResponseToSeries} from 'sentry/utils/metrics/transformMetricsResponseToSeries'; import {DisplayType, Widget} from '../types'; import {getWidgetInterval} from '../utils'; type Props = { api: Client; children: ( props: Pick ) => React.ReactNode; organization: OrganizationSummary; selection: PageFilters; widget: Widget; limit?: number; }; type State = { loading: boolean; errorMessage?: string; queryFetchID?: symbol; rawResults?: MetricsApiResponse[]; tableResults?: TableDataWithTitle[]; timeseriesResults?: Series[]; }; class MetricsWidgetQueries extends React.Component { state: State = { loading: true, queryFetchID: undefined, errorMessage: undefined, timeseriesResults: undefined, rawResults: undefined, tableResults: undefined, }; componentDidMount() { this._isMounted = true; this.fetchData(); } componentDidUpdate(prevProps: Props) { const {loading, rawResults} = this.state; const {selection, widget, organization, limit} = this.props; const ignroredWidgetProps = [ 'queries', 'title', 'id', 'layout', 'tempId', 'widgetType', ]; const ignoredQueryProps = ['name', 'fields']; const widgetQueryNames = widget.queries.map(q => q.name); const prevWidgetQueryNames = prevProps.widget.queries.map(q => q.name); if ( limit !== prevProps.limit || organization.slug !== prevProps.organization.slug || !isSelectionEqual(selection, prevProps.selection) || // If the widget changed (ignore unimportant fields, + queries as they are handled lower) !isEqual( omit(widget, ignroredWidgetProps), omit(prevProps.widget, ignroredWidgetProps) ) || // If the queries changed (ignore unimportant name, + fields as they are handled lower) !isEqual( widget.queries.map(q => omit(q, ignoredQueryProps)), prevProps.widget.queries.map(q => omit(q, ignoredQueryProps)) ) || // If the fields changed (ignore falsy/empty fields -> they can happen after clicking on Add Overlay) !isEqual( widget.queries.flatMap(q => q.fields.filter(field => !!field)), prevProps.widget.queries.flatMap(q => q.fields.filter(field => !!field)) ) ) { this.fetchData(); return; } // If the query names have changed, then update timeseries labels if ( !loading && !isEqual(widgetQueryNames, prevWidgetQueryNames) && rawResults?.length === widget.queries.length ) { // eslint-disable-next-line react/no-did-update-set-state this.setState(prevState => { return { ...prevState, timeseriesResults: prevState.rawResults?.flatMap((rawResult, index) => transformMetricsResponseToSeries(rawResult, widget.queries[index].name) ), }; }); } } componentWillUnmount() { this._isMounted = false; } private _isMounted: boolean = false; fetchTabularData(_queryFetchID: symbol) { this.setState({loading: false, tableResults: []}); // TODO(dam): implement the rest } fetchTimeseriesData(queryFetchID: symbol) { const {selection, api, organization, widget} = this.props; this.setState({loading: false, timeseriesResults: [], rawResults: []}); const {environments, projects, datetime} = selection; const {start, end, period} = datetime; const interval = getWidgetInterval(widget, {start, end, period}); const promises = widget.queries.map(query => { const requestData = { field: query.fields, orgSlug: organization.slug, end, environment: environments, // groupBy: query.groupBy // TODO(dam): add backend groupBy support interval, limit: widget.displayType === DisplayType.TOP_N ? TOP_N : undefined, orderBy: query.orderby, project: projects, query: query.conditions, start, statsPeriod: period, }; return doMetricsRequest(api, requestData); }); let completed = 0; promises.forEach(async (promise, requestIndex) => { try { const rawResults = await promise; if (!this._isMounted) { return; } this.setState(prevState => { if (prevState.queryFetchID !== queryFetchID) { // invariant: a different request was initiated after this request return prevState; } const timeseriesResults = [...(prevState.timeseriesResults ?? [])]; const transformedResult = transformMetricsResponseToSeries( rawResults, widget.queries[requestIndex].name ); // When charting timeseriesData on echarts, color association to a timeseries result // is order sensitive, ie series at index i on the timeseries array will use color at // index i on the color array. This means that on multi series results, we need to make // sure that the order of series in our results do not change between fetches to avoid // coloring inconsistencies between renders. transformedResult.forEach((result, resultIndex) => { timeseriesResults[requestIndex * transformedResult.length + resultIndex] = result; }); const rawResultsClone = cloneDeep(prevState.rawResults ?? []); rawResultsClone[requestIndex] = rawResults; return { ...prevState, timeseriesResults, rawResults: rawResultsClone, }; }); } catch (err) { const errorMessage = err?.responseJSON?.detail || t('An unknown error occurred.'); this.setState({errorMessage}); } finally { completed++; if (!this._isMounted) { return; } this.setState(prevState => { if (prevState.queryFetchID !== queryFetchID) { // invariant: a different request was initiated after this request return prevState; } return { ...prevState, loading: completed === promises.length ? false : true, }; }); } }); } fetchData() { const {widget} = this.props; if (widget.displayType === DisplayType.WORLD_MAP) { this.setState({errorMessage: t('World Map is not supported by metrics.')}); return; } const queryFetchID = Symbol('queryFetchID'); this.setState({loading: true, errorMessage: undefined, queryFetchID}); if (['table', 'big_number'].includes(widget.displayType)) { this.fetchTabularData(queryFetchID); } else { this.fetchTimeseriesData(queryFetchID); } } render() { const {children} = this.props; const {loading, timeseriesResults, tableResults, errorMessage} = this.state; return children({ loading, timeseriesResults, tableResults, errorMessage, }); } } export default MetricsWidgetQueries;