Files
2024-02-15 17:01:50 +00:00

422 lines
13 KiB
TypeScript

import {Component} from 'react';
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import type {Client, ResponseMeta} from 'sentry/api';
import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
import {t} from 'sentry/locale';
import type {Organization, PageFilters} from 'sentry/types';
import type {Series} from 'sentry/types/echarts';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
import type {MEPState} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl';
import {dashboardFiltersToString} from 'sentry/views/dashboards/utils';
import type {DatasetConfig} from '../datasetConfig/base';
import type {DashboardFilters, Widget, WidgetQuery} from '../types';
import {DEFAULT_TABLE_LIMIT, DisplayType} from '../types';
function getReferrer(displayType: DisplayType) {
let referrer: string = '';
if (displayType === DisplayType.TABLE) {
referrer = 'api.dashboards.tablewidget';
} else if (displayType === DisplayType.BIG_NUMBER) {
referrer = 'api.dashboards.bignumberwidget';
} else {
referrer = `api.dashboards.widget.${displayType}-chart`;
}
return referrer;
}
export type OnDataFetchedProps = {
pageLinks?: string;
tableResults?: TableDataWithTitle[];
timeseriesResults?: Series[];
timeseriesResultsTypes?: Record<string, AggregationOutputType>;
totalIssuesCount?: string;
};
export type GenericWidgetQueriesChildrenProps = {
loading: boolean;
errorMessage?: string;
pageLinks?: string;
tableResults?: TableDataWithTitle[];
timeseriesResults?: Series[];
timeseriesResultsTypes?: Record<string, AggregationOutputType>;
totalCount?: string;
};
export type GenericWidgetQueriesProps<SeriesResponse, TableResponse> = {
api: Client;
children: (props: GenericWidgetQueriesChildrenProps) => React.ReactNode;
config: DatasetConfig<SeriesResponse, TableResponse>;
organization: Organization;
selection: PageFilters;
widget: Widget;
afterFetchSeriesData?: (result: SeriesResponse) => void;
afterFetchTableData?: (
result: TableResponse,
response?: ResponseMeta
) => void | {totalIssuesCount?: string};
cursor?: string;
customDidUpdateComparator?: (
prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
nextProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
) => boolean;
dashboardFilters?: DashboardFilters;
forceOnDemand?: boolean;
limit?: number;
loading?: boolean;
mepSetting?: MEPState | null;
onDataFetched?: ({
tableResults,
timeseriesResults,
totalIssuesCount,
pageLinks,
timeseriesResultsTypes,
}: OnDataFetchedProps) => void;
onDemandControlContext?: OnDemandControlContext;
};
type State<SeriesResponse> = {
loading: boolean;
errorMessage?: GenericWidgetQueriesChildrenProps['errorMessage'];
pageLinks?: GenericWidgetQueriesChildrenProps['pageLinks'];
queryFetchID?: symbol;
rawResults?: SeriesResponse[];
tableResults?: GenericWidgetQueriesChildrenProps['tableResults'];
timeseriesResults?: GenericWidgetQueriesChildrenProps['timeseriesResults'];
timeseriesResultsTypes?: Record<string, AggregationOutputType>;
};
class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
State<SeriesResponse>
> {
state: State<SeriesResponse> = {
loading: true,
queryFetchID: undefined,
errorMessage: undefined,
timeseriesResults: undefined,
rawResults: undefined,
tableResults: undefined,
pageLinks: undefined,
timeseriesResultsTypes: undefined,
};
componentDidMount() {
this._isMounted = true;
if (!this.props.loading) {
this.fetchData();
}
}
componentDidUpdate(
prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
) {
const {selection, widget, cursor, organization, config, customDidUpdateComparator} =
this.props;
// We do not fetch data whenever the query name changes.
// Also don't count empty fields when checking for field changes
const [prevWidgetQueryNames, prevWidgetQueries] = prevProps.widget.queries
.map((query: WidgetQuery) => {
query.aggregates = query.aggregates.filter(field => !!field);
query.columns = query.columns.filter(field => !!field);
return query;
})
.reduce(
([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
names.push(name);
rest.fields = rest.fields?.filter(field => !!field) ?? [];
// Ignore aliases because changing alias does not need a query
rest = omit(rest, 'fieldAliases');
queries.push(rest);
return [names, queries];
},
[[], []]
);
const [widgetQueryNames, widgetQueries] = widget.queries
.map((query: WidgetQuery) => {
query.aggregates = query.aggregates.filter(
field => !!field && field !== 'equation|'
);
query.columns = query.columns.filter(field => !!field && field !== 'equation|');
return query;
})
.reduce(
([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
names.push(name);
rest.fields = rest.fields?.filter(field => !!field) ?? [];
// Ignore aliases because changing alias does not need a query
rest = omit(rest, 'fieldAliases');
queries.push(rest);
return [names, queries];
},
[[], []]
);
if (
customDidUpdateComparator
? customDidUpdateComparator(prevProps, this.props)
: widget.limit !== prevProps.widget.limit ||
!isEqual(widget.displayType, prevProps.widget.displayType) ||
!isEqual(widget.interval, prevProps.widget.interval) ||
!isEqual(new Set(widgetQueries), new Set(prevWidgetQueries)) ||
!isEqual(this.props.dashboardFilters, prevProps.dashboardFilters) ||
!isEqual(this.props.forceOnDemand, prevProps.forceOnDemand) ||
!isSelectionEqual(selection, prevProps.selection) ||
cursor !== prevProps.cursor
) {
this.fetchData();
return;
}
if (
!this.state.loading &&
!isEqual(prevWidgetQueryNames, widgetQueryNames) &&
this.state.rawResults?.length === widget.queries.length
) {
// If the query names has changed, then update timeseries labels
// eslint-disable-next-line react/no-did-update-set-state
this.setState(prevState => {
const timeseriesResults = widget.queries.reduce((acc: Series[], query, index) => {
return acc.concat(
config.transformSeries!(prevState.rawResults![index], query, organization)
);
}, []);
return {...prevState, timeseriesResults};
});
}
}
componentWillUnmount() {
this._isMounted = false;
}
private _isMounted: boolean = false;
applyDashboardFilters(widget: Widget): Widget {
const {dashboardFilters} = this.props;
const dashboardFilterConditions = dashboardFiltersToString(dashboardFilters);
widget.queries.forEach(query => {
query.conditions =
query.conditions +
(dashboardFilterConditions === '' ? '' : ` ${dashboardFilterConditions}`);
});
return widget;
}
async fetchTableData(queryFetchID: symbol) {
const {
widget: originalWidget,
limit,
config,
api,
organization,
selection,
cursor,
afterFetchTableData,
onDataFetched,
onDemandControlContext,
mepSetting,
} = this.props;
const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
const responses = await Promise.all(
widget.queries.map(query => {
const requestLimit: number | undefined = limit ?? DEFAULT_TABLE_LIMIT;
const requestCreator = config.getTableRequest;
if (!requestCreator) {
throw new Error(
t('This display type is not supported by the selected dataset.')
);
}
return requestCreator(
api,
widget,
query,
organization,
selection,
onDemandControlContext,
requestLimit,
cursor,
getReferrer(widget.displayType),
mepSetting
);
})
);
let transformedTableResults: TableDataWithTitle[] = [];
let responsePageLinks: string | undefined;
let afterTableFetchData: OnDataFetchedProps | undefined;
responses.forEach(([data, _textstatus, resp], i) => {
afterTableFetchData = afterFetchTableData?.(data, resp) ?? {};
// Cast so we can add the title.
const transformedData = config.transformTable(
data,
widget.queries[0],
organization,
selection
) as TableDataWithTitle;
transformedData.title = widget.queries[i]?.name ?? '';
// Overwrite the local var to work around state being stale in tests.
transformedTableResults = [...transformedTableResults, transformedData];
// There is some inconsistency with the capitalization of "link" in response headers
responsePageLinks =
(resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined;
});
if (this._isMounted && this.state.queryFetchID === queryFetchID) {
onDataFetched?.({
tableResults: transformedTableResults,
pageLinks: responsePageLinks,
...afterTableFetchData,
});
this.setState({
tableResults: transformedTableResults,
pageLinks: responsePageLinks,
});
}
}
async fetchSeriesData(queryFetchID: symbol) {
const {
widget: originalWidget,
config,
api,
organization,
selection,
afterFetchSeriesData,
onDataFetched,
mepSetting,
onDemandControlContext,
} = this.props;
const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
const responses = await Promise.all(
widget.queries.map((_query, index) => {
return config.getSeriesRequest!(
api,
widget,
index,
organization,
selection,
onDemandControlContext,
getReferrer(widget.displayType),
mepSetting
);
})
);
const rawResultsClone = cloneDeep(this.state.rawResults) ?? [];
const transformedTimeseriesResults: Series[] = [];
responses.forEach(([data], requestIndex) => {
afterFetchSeriesData?.(data);
rawResultsClone[requestIndex] = data;
const transformedResult = 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) => {
transformedTimeseriesResults[
requestIndex * transformedResult.length + resultIndex
] = result;
});
});
// Get series result type
// Only used by custom measurements in errorsAndTransactions at the moment
const timeseriesResultsTypes = config.getSeriesResultType?.(
responses[0][0],
widget.queries[0]
);
if (this._isMounted && this.state.queryFetchID === queryFetchID) {
onDataFetched?.({
timeseriesResults: transformedTimeseriesResults,
timeseriesResultsTypes,
});
this.setState({
timeseriesResults: transformedTimeseriesResults,
rawResults: rawResultsClone,
timeseriesResultsTypes,
});
}
}
async fetchData() {
const {widget} = this.props;
const queryFetchID = Symbol('queryFetchID');
this.setState({
loading: true,
tableResults: undefined,
timeseriesResults: undefined,
errorMessage: undefined,
queryFetchID,
});
try {
if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
await this.fetchTableData(queryFetchID);
} else {
await this.fetchSeriesData(queryFetchID);
}
} catch (err) {
if (this._isMounted) {
this.setState({
errorMessage:
err?.responseJSON?.detail || err?.message || t('An unknown error occurred.'),
});
}
} finally {
if (this._isMounted) {
this.setState({loading: false});
}
}
}
render() {
const {children} = this.props;
const {
loading,
tableResults,
timeseriesResults,
errorMessage,
pageLinks,
timeseriesResultsTypes,
} = this.state;
return children({
loading,
tableResults,
timeseriesResults,
errorMessage,
pageLinks,
timeseriesResultsTypes,
});
}
}
export default GenericWidgetQueries;