|
@@ -5,7 +5,7 @@ import omit from 'lodash/omit';
|
|
|
import trimStart from 'lodash/trimStart';
|
|
|
|
|
|
import {addErrorMessage} from 'sentry/actionCreators/indicator';
|
|
|
-import {Client, ResponseMeta} from 'sentry/api';
|
|
|
+import {Client} from 'sentry/api';
|
|
|
import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
|
|
|
import {t} from 'sentry/locale';
|
|
|
import {
|
|
@@ -20,14 +20,8 @@ import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
|
|
|
import {stripDerivedMetricsPrefix} from 'sentry/utils/discover/fields';
|
|
|
import {TOP_N} from 'sentry/utils/discover/types';
|
|
|
|
|
|
-import {getDatasetConfig} from '../datasetConfig/base';
|
|
|
-import {
|
|
|
- DEFAULT_TABLE_LIMIT,
|
|
|
- DisplayType,
|
|
|
- Widget,
|
|
|
- WidgetQuery,
|
|
|
- WidgetType,
|
|
|
-} from '../types';
|
|
|
+import {ReleasesConfig} from '../datasetConfig/releases';
|
|
|
+import {DEFAULT_TABLE_LIMIT, DisplayType, Widget, WidgetQuery} from '../types';
|
|
|
import {
|
|
|
DERIVED_STATUS_METRICS_PATTERN,
|
|
|
DerivedStatusFields,
|
|
@@ -35,14 +29,14 @@ import {
|
|
|
METRICS_EXPRESSION_TO_FIELD,
|
|
|
} from '../widgetBuilder/releaseWidget/fields';
|
|
|
|
|
|
+import GenericWidgetQueries, {
|
|
|
+ GenericWidgetQueriesChildrenProps,
|
|
|
+ GenericWidgetQueriesProps,
|
|
|
+} from './genericWidgetQueries';
|
|
|
+
|
|
|
type Props = {
|
|
|
api: Client;
|
|
|
- children: (
|
|
|
- props: Pick<
|
|
|
- State,
|
|
|
- 'loading' | 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'pageLinks'
|
|
|
- >
|
|
|
- ) => React.ReactNode;
|
|
|
+ children: (props: GenericWidgetQueriesChildrenProps) => JSX.Element;
|
|
|
organization: Organization;
|
|
|
selection: PageFilters;
|
|
|
widget: Widget;
|
|
@@ -57,12 +51,7 @@ type Props = {
|
|
|
type State = {
|
|
|
loading: boolean;
|
|
|
errorMessage?: string;
|
|
|
- pageLinks?: string;
|
|
|
- queryFetchID?: symbol;
|
|
|
- rawResults?: SessionApiResponse[] | MetricsApiResponse[];
|
|
|
releases?: Release[];
|
|
|
- tableResults?: TableDataWithTitle[];
|
|
|
- timeseriesResults?: Series[];
|
|
|
};
|
|
|
|
|
|
export function derivedMetricsToField(field: string): string {
|
|
@@ -156,28 +145,87 @@ export function requiresCustomReleaseSorting(query: WidgetQuery): boolean {
|
|
|
class ReleaseWidgetQueries extends Component<Props, State> {
|
|
|
state: State = {
|
|
|
loading: true,
|
|
|
- queryFetchID: undefined,
|
|
|
errorMessage: undefined,
|
|
|
- timeseriesResults: undefined,
|
|
|
- rawResults: undefined,
|
|
|
- tableResults: undefined,
|
|
|
releases: undefined,
|
|
|
};
|
|
|
|
|
|
componentDidMount() {
|
|
|
this._isMounted = true;
|
|
|
-
|
|
|
if (requiresCustomReleaseSorting(this.props.widget.queries[0])) {
|
|
|
- this.fetchReleasesAndData();
|
|
|
+ this.fetchReleases();
|
|
|
return;
|
|
|
}
|
|
|
- this.fetchData();
|
|
|
}
|
|
|
|
|
|
- componentDidUpdate(prevProps: Props) {
|
|
|
- const {loading, rawResults} = this.state;
|
|
|
- const {selection, widget, organization, limit, cursor} = this.props;
|
|
|
- const ignroredWidgetProps = [
|
|
|
+ componentWillUnmount() {
|
|
|
+ this._isMounted = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ config = ReleasesConfig;
|
|
|
+ private _isMounted: boolean = false;
|
|
|
+
|
|
|
+ fetchReleases = async () => {
|
|
|
+ this.setState({loading: true, errorMessage: undefined});
|
|
|
+ const {selection, api, organization} = this.props;
|
|
|
+ const {environments, projects} = selection;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const releases = await api.requestPromise(
|
|
|
+ `/organizations/${organization.slug}/releases/`,
|
|
|
+ {
|
|
|
+ method: 'GET',
|
|
|
+ data: {
|
|
|
+ sort: 'date',
|
|
|
+ project: projects,
|
|
|
+ per_page: 50,
|
|
|
+ environments,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ );
|
|
|
+ if (!this._isMounted) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.setState({releases, loading: false});
|
|
|
+ } catch (error) {
|
|
|
+ if (!this._isMounted) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const message = error.responseJSON
|
|
|
+ ? error.responseJSON.error
|
|
|
+ : t('Error sorting by releases');
|
|
|
+ this.setState({errorMessage: message, loading: false});
|
|
|
+ addErrorMessage(message);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ get limit() {
|
|
|
+ const {limit} = this.props;
|
|
|
+
|
|
|
+ switch (this.props.widget.displayType) {
|
|
|
+ case DisplayType.TOP_N:
|
|
|
+ return TOP_N;
|
|
|
+ case DisplayType.TABLE:
|
|
|
+ return limit ?? DEFAULT_TABLE_LIMIT;
|
|
|
+ case DisplayType.BIG_NUMBER:
|
|
|
+ return 1;
|
|
|
+ default:
|
|
|
+ return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ customDidUpdateComparator = (
|
|
|
+ prevProps: GenericWidgetQueriesProps<
|
|
|
+ SessionApiResponse | MetricsApiResponse,
|
|
|
+ SessionApiResponse | MetricsApiResponse
|
|
|
+ >,
|
|
|
+ nextProps: GenericWidgetQueriesProps<
|
|
|
+ SessionApiResponse | MetricsApiResponse,
|
|
|
+ SessionApiResponse | MetricsApiResponse
|
|
|
+ >
|
|
|
+ ) => {
|
|
|
+ const {loading, limit, widget, cursor, organization, selection} = nextProps;
|
|
|
+ const ignoredWidgetProps = [
|
|
|
'queries',
|
|
|
'title',
|
|
|
'id',
|
|
@@ -186,30 +234,14 @@ class ReleaseWidgetQueries extends Component<Props, State> {
|
|
|
'widgetType',
|
|
|
];
|
|
|
const ignoredQueryProps = ['name', 'fields', 'aggregates', 'columns'];
|
|
|
- const widgetQueryNames = widget.queries.map(q => q.name);
|
|
|
- const prevWidgetQueryNames = prevProps.widget.queries.map(q => q.name);
|
|
|
-
|
|
|
- if (
|
|
|
- requiresCustomReleaseSorting(widget.queries[0]) &&
|
|
|
- (!isEqual(
|
|
|
- widget.queries.map(q => q.orderby),
|
|
|
- prevProps.widget.queries.map(q => q.orderby)
|
|
|
- ) ||
|
|
|
- !isSelectionEqual(selection, prevProps.selection) ||
|
|
|
- !isEqual(organization, prevProps.organization))
|
|
|
- ) {
|
|
|
- this.fetchReleasesAndData();
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (
|
|
|
+ return (
|
|
|
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)
|
|
|
+ omit(widget, ignoredWidgetProps),
|
|
|
+ omit(prevProps.widget, ignoredWidgetProps)
|
|
|
) ||
|
|
|
// If the queries changed (ignore unimportant name, + fields as they are handled lower)
|
|
|
!isEqual(
|
|
@@ -231,114 +263,19 @@ class ReleaseWidgetQueries extends Component<Props, State> {
|
|
|
widget.queries.flatMap(q => q.columns.filter(column => !!column)),
|
|
|
prevProps.widget.queries.flatMap(q => q.columns.filter(column => !!column))
|
|
|
) ||
|
|
|
+ loading !== prevProps.loading ||
|
|
|
cursor !== prevProps.cursor
|
|
|
- ) {
|
|
|
- this.fetchData();
|
|
|
- return;
|
|
|
- }
|
|
|
- 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) =>
|
|
|
- this.config.transformSeries!(rawResult, widget.queries[index], organization)
|
|
|
- ),
|
|
|
- };
|
|
|
- });
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- componentWillUnmount() {
|
|
|
- this._isMounted = false;
|
|
|
- }
|
|
|
-
|
|
|
- private _isMounted: boolean = false;
|
|
|
- config = getDatasetConfig(WidgetType.RELEASE);
|
|
|
-
|
|
|
- get limit() {
|
|
|
- const {limit} = this.props;
|
|
|
-
|
|
|
- switch (this.props.widget.displayType) {
|
|
|
- case DisplayType.TOP_N:
|
|
|
- return TOP_N;
|
|
|
- case DisplayType.TABLE:
|
|
|
- return limit ?? DEFAULT_TABLE_LIMIT;
|
|
|
- case DisplayType.BIG_NUMBER:
|
|
|
- return 1;
|
|
|
- default:
|
|
|
- return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async fetchReleasesAndData() {
|
|
|
- const {selection, api, organization} = this.props;
|
|
|
- const {environments, projects} = selection;
|
|
|
-
|
|
|
- try {
|
|
|
- const releases = await api.requestPromise(
|
|
|
- `/organizations/${organization.slug}/releases/`,
|
|
|
- {
|
|
|
- method: 'GET',
|
|
|
- data: {
|
|
|
- sort: 'date',
|
|
|
- project: projects,
|
|
|
- per_page: 50,
|
|
|
- environments,
|
|
|
- },
|
|
|
- }
|
|
|
- );
|
|
|
- if (!this._isMounted) {
|
|
|
- return;
|
|
|
- }
|
|
|
- this.setState({releases});
|
|
|
- } catch (error) {
|
|
|
- addErrorMessage(
|
|
|
- error.responseJSON ? error.responseJSON.error : t('Error sorting by releases')
|
|
|
- );
|
|
|
- }
|
|
|
- this.fetchData();
|
|
|
- }
|
|
|
+ );
|
|
|
+ };
|
|
|
|
|
|
- async fetchData() {
|
|
|
- const {
|
|
|
- selection,
|
|
|
- api,
|
|
|
- organization,
|
|
|
- widget: initialWidget,
|
|
|
- cursor,
|
|
|
- onDataFetched,
|
|
|
- } = this.props;
|
|
|
+ transformWidget = (initialWidget: Widget): Widget => {
|
|
|
const {releases} = this.state;
|
|
|
-
|
|
|
- // HACK: Cloning the widget because we're modifying the query conditions
|
|
|
- // to support sorting by release
|
|
|
const widget = cloneDeep(initialWidget);
|
|
|
|
|
|
const isCustomReleaseSorting = requiresCustomReleaseSorting(widget.queries[0]);
|
|
|
const isDescending = widget.queries[0].orderby.startsWith('-');
|
|
|
const useSessionAPI = widget.queries[0].columns.includes('session.status');
|
|
|
|
|
|
- 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,
|
|
|
- timeseriesResults: [],
|
|
|
- rawResults: [],
|
|
|
- tableResults: [],
|
|
|
- queryFetchID,
|
|
|
- });
|
|
|
-
|
|
|
let releaseCondition = '';
|
|
|
const releasesArray: string[] = [];
|
|
|
if (isCustomReleaseSorting) {
|
|
@@ -364,141 +301,75 @@ class ReleaseWidgetQueries extends Component<Props, State> {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- let responses: [MetricsApiResponse | SessionApiResponse, string, ResponseMeta][] = [];
|
|
|
-
|
|
|
- try {
|
|
|
- responses = await Promise.all(
|
|
|
- widget.queries.map((query, index) => {
|
|
|
- if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
|
|
|
- return this.config.getTableRequest!(
|
|
|
- api,
|
|
|
- query,
|
|
|
- organization,
|
|
|
- selection,
|
|
|
- this.limit,
|
|
|
- cursor
|
|
|
- );
|
|
|
- }
|
|
|
- return this.config.getSeriesRequest!(
|
|
|
- api,
|
|
|
- widget,
|
|
|
- index,
|
|
|
- organization,
|
|
|
- selection
|
|
|
- );
|
|
|
- })
|
|
|
- );
|
|
|
- } catch (err) {
|
|
|
- const errorMessage = err?.responseJSON?.detail || t('An unknown error occurred.');
|
|
|
- if (!this._isMounted) {
|
|
|
- return;
|
|
|
- }
|
|
|
- this.setState({errorMessage});
|
|
|
- } finally {
|
|
|
- if (!this._isMounted) {
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- responses.forEach(([data, _textstatus, response], requestIndex) => {
|
|
|
- if (!this._isMounted) {
|
|
|
- return;
|
|
|
- }
|
|
|
- this.setState(prevState => {
|
|
|
- if (prevState.queryFetchID !== queryFetchID) {
|
|
|
- // invariant: a different request was initiated after this request
|
|
|
- return prevState;
|
|
|
- }
|
|
|
+ return widget;
|
|
|
+ };
|
|
|
|
|
|
- if (releasesArray.length) {
|
|
|
- data.groups.sort(function (group1, group2) {
|
|
|
- const release1 = group1.by.release;
|
|
|
- const release2 = group2.by.release;
|
|
|
- return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
|
|
|
- });
|
|
|
- data.groups = data.groups.slice(0, this.limit);
|
|
|
- }
|
|
|
+ afterFetchData = (data: SessionApiResponse | MetricsApiResponse) => {
|
|
|
+ const {widget} = this.props;
|
|
|
+ const {releases} = this.state;
|
|
|
|
|
|
- let tableResults: TableDataWithTitle[] | undefined;
|
|
|
- const timeseriesResults = [...(prevState.timeseriesResults ?? [])];
|
|
|
- if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
|
|
|
- // Transform to fit the table format
|
|
|
- const tableData = this.config.transformTable(
|
|
|
- data,
|
|
|
- widget.queries[0],
|
|
|
- organization,
|
|
|
- selection
|
|
|
- ) as TableDataWithTitle; // Cast so we can add the title.
|
|
|
- tableData.title = widget.queries[requestIndex]?.name ?? '';
|
|
|
- tableResults = [...(prevState.tableResults ?? []), tableData];
|
|
|
- } else {
|
|
|
- // Transform to fit the chart format
|
|
|
- const transformedResult = this.config.transformSeries!(
|
|
|
- data,
|
|
|
- widget.queries[requestIndex],
|
|
|
- organization
|
|
|
- );
|
|
|
-
|
|
|
- // 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 isDescending = widget.queries[0].orderby.startsWith('-');
|
|
|
|
|
|
- onDataFetched?.({timeseriesResults, tableResults});
|
|
|
+ const releasesArray: string[] = [];
|
|
|
+ if (requiresCustomReleaseSorting(widget.queries[0])) {
|
|
|
+ if (releases && releases.length === 1) {
|
|
|
+ releasesArray.push(releases[0].version);
|
|
|
+ }
|
|
|
+ if (releases && releases.length > 1) {
|
|
|
+ const {releasesUsed} = getReleasesQuery(releases);
|
|
|
+ releasesArray.push(...releasesUsed);
|
|
|
|
|
|
- if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
|
|
|
- return {
|
|
|
- ...prevState,
|
|
|
- errorMessage: undefined,
|
|
|
- tableResults,
|
|
|
- pageLinks: response?.getResponseHeader('link') ?? undefined,
|
|
|
- };
|
|
|
+ if (!!!isDescending) {
|
|
|
+ releasesArray.reverse();
|
|
|
}
|
|
|
-
|
|
|
- const rawResultsClone = cloneDeep(prevState.rawResults ?? []);
|
|
|
- rawResultsClone[requestIndex] = data;
|
|
|
-
|
|
|
- return {
|
|
|
- ...prevState,
|
|
|
- errorMessage: undefined,
|
|
|
- timeseriesResults,
|
|
|
- rawResults: rawResultsClone,
|
|
|
- pageLinks: response?.getResponseHeader('link') ?? undefined,
|
|
|
- };
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- this.setState(prevState => {
|
|
|
- if (prevState.queryFetchID !== queryFetchID) {
|
|
|
- // invariant: a different request was initiated after this request
|
|
|
- return prevState;
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- return {
|
|
|
- ...prevState,
|
|
|
- loading: false,
|
|
|
- };
|
|
|
- });
|
|
|
- }
|
|
|
+ if (releasesArray.length) {
|
|
|
+ data.groups.sort(function (group1, group2) {
|
|
|
+ const release1 = group1.by.release;
|
|
|
+ const release2 = group2.by.release;
|
|
|
+ return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
|
|
|
+ });
|
|
|
+ data.groups = data.groups.slice(0, this.limit);
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
render() {
|
|
|
- const {children} = this.props;
|
|
|
- const {loading, timeseriesResults, tableResults, errorMessage, pageLinks} =
|
|
|
- this.state;
|
|
|
-
|
|
|
- return children({
|
|
|
- loading,
|
|
|
- timeseriesResults,
|
|
|
- tableResults,
|
|
|
- errorMessage,
|
|
|
- pageLinks,
|
|
|
- });
|
|
|
+ const {api, children, organization, selection, widget, cursor, onDataFetched} =
|
|
|
+ this.props;
|
|
|
+ const config = ReleasesConfig;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <GenericWidgetQueries<
|
|
|
+ SessionApiResponse | MetricsApiResponse,
|
|
|
+ SessionApiResponse | MetricsApiResponse
|
|
|
+ >
|
|
|
+ config={config}
|
|
|
+ api={api}
|
|
|
+ organization={organization}
|
|
|
+ selection={selection}
|
|
|
+ widget={this.transformWidget(widget)}
|
|
|
+ cursor={cursor}
|
|
|
+ limit={this.limit}
|
|
|
+ onDataFetched={onDataFetched}
|
|
|
+ loading={
|
|
|
+ requiresCustomReleaseSorting(widget.queries[0])
|
|
|
+ ? !this.state.releases
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ customDidUpdateComparator={this.customDidUpdateComparator}
|
|
|
+ afterFetchTableData={this.afterFetchData}
|
|
|
+ afterFetchSeriesData={this.afterFetchData}
|
|
|
+ >
|
|
|
+ {({errorMessage, ...rest}) =>
|
|
|
+ children({
|
|
|
+ errorMessage: this.state.errorMessage ?? errorMessage,
|
|
|
+ ...rest,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ </GenericWidgetQueries>
|
|
|
+ );
|
|
|
}
|
|
|
}
|
|
|
|