mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
import {Component} from 'react';
|
|
import type {RouteComponentProps} from 'react-router';
|
|
import * as Sentry from '@sentry/react';
|
|
import isEqual from 'lodash/isEqual';
|
|
|
|
import type {ResponseMeta} from 'sentry/api';
|
|
import {Client} from 'sentry/api';
|
|
import AsyncComponentSearchInput from 'sentry/components/asyncComponentSearchInput';
|
|
import LoadingError from 'sentry/components/loadingError';
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
import {t} from 'sentry/locale';
|
|
import {SentryPropTypeValidators} from 'sentry/sentryPropTypeValidators';
|
|
import {metric} from 'sentry/utils/analytics';
|
|
import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
|
|
import PermissionDenied from 'sentry/views/permissionDenied';
|
|
import RouteError from 'sentry/views/routeError';
|
|
|
|
export interface AsyncComponentProps extends Partial<RouteComponentProps<{}, {}>> {}
|
|
|
|
export interface AsyncComponentState {
|
|
[key: string]: any;
|
|
error: boolean;
|
|
errors: Record<string, ResponseMeta>;
|
|
loading: boolean;
|
|
reloading: boolean;
|
|
remainingRequests?: number;
|
|
}
|
|
|
|
type SearchInputProps = React.ComponentProps<typeof AsyncComponentSearchInput>;
|
|
|
|
type RenderSearchInputArgs = Omit<
|
|
SearchInputProps,
|
|
'api' | 'onSuccess' | 'onError' | 'url' | keyof RouteComponentProps<{}, {}>
|
|
> & {
|
|
stateKey?: string;
|
|
url?: SearchInputProps['url'];
|
|
};
|
|
|
|
/**
|
|
* Wraps methods on the AsyncComponent to catch errors and set the `error`
|
|
* state on error.
|
|
*/
|
|
function wrapErrorHandling<T extends any[], U>(
|
|
component: DeprecatedAsyncComponent,
|
|
fn: (...args: T) => U
|
|
) {
|
|
return (...args: T): U | null => {
|
|
try {
|
|
return fn(...args);
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(error);
|
|
window.setTimeout(() => {
|
|
throw error;
|
|
});
|
|
component.setState({error});
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @deprecated use useApiQuery instead
|
|
*
|
|
* Read the dev docs page on network requests for more information [1].
|
|
*
|
|
* [1]: https://develop.sentry.dev/frontend/network-requests/
|
|
*/
|
|
class DeprecatedAsyncComponent<
|
|
P extends AsyncComponentProps = AsyncComponentProps,
|
|
S extends AsyncComponentState = AsyncComponentState,
|
|
> extends Component<P, S> {
|
|
static contextTypes = {
|
|
router: SentryPropTypeValidators.isObject,
|
|
};
|
|
|
|
constructor(props: P, context: any) {
|
|
super(props, context);
|
|
|
|
this.api = new Client();
|
|
this.fetchData = wrapErrorHandling(this, this.fetchData.bind(this));
|
|
this.render = wrapErrorHandling(this, this.render.bind(this));
|
|
|
|
this.state = this.getDefaultState() as Readonly<S>;
|
|
|
|
this._measurement = {
|
|
hasMeasured: false,
|
|
};
|
|
if (props.routes) {
|
|
metric.mark({name: `async-component-${getRouteStringFromRoutes(props.routes)}`});
|
|
}
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.fetchData();
|
|
|
|
if (this.reloadOnVisible) {
|
|
document.addEventListener('visibilitychange', this.visibilityReloader);
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(prevProps: P, prevContext: any) {
|
|
const isRouterInContext = !!prevContext.router;
|
|
const isLocationInProps = prevProps.location !== undefined;
|
|
|
|
const currentLocation = isLocationInProps
|
|
? this.props.location
|
|
: isRouterInContext
|
|
? this.context.router.location
|
|
: null;
|
|
const prevLocation = isLocationInProps
|
|
? prevProps.location
|
|
: isRouterInContext
|
|
? prevContext.router.location
|
|
: null;
|
|
|
|
if (!(currentLocation && prevLocation)) {
|
|
return;
|
|
}
|
|
|
|
// Take a measurement from when this component is initially created until it finishes it's first
|
|
// set of API requests
|
|
if (
|
|
!this._measurement.hasMeasured &&
|
|
this._measurement.finished &&
|
|
this.props.routes
|
|
) {
|
|
const routeString = getRouteStringFromRoutes(this.props.routes);
|
|
metric.measure({
|
|
name: 'app.component.async-component',
|
|
start: `async-component-${routeString}`,
|
|
data: {
|
|
route: routeString,
|
|
error: this._measurement.error,
|
|
},
|
|
});
|
|
this._measurement.hasMeasured = true;
|
|
}
|
|
|
|
// Re-fetch data when router params change.
|
|
if (
|
|
!isEqual(this.props.params, prevProps.params) ||
|
|
currentLocation.search !== prevLocation.search ||
|
|
currentLocation.state !== prevLocation.state
|
|
) {
|
|
this.remountComponent();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.api.clear();
|
|
document.removeEventListener('visibilitychange', this.visibilityReloader);
|
|
}
|
|
|
|
/**
|
|
* Override this flag to have the component reload its state when the window
|
|
* becomes visible again. This will set the loading and reloading state, but
|
|
* will not render a loading state during reloading.
|
|
*
|
|
* eslint-disable-next-line react/sort-comp
|
|
*/
|
|
reloadOnVisible = false;
|
|
|
|
/**
|
|
* When enabling reloadOnVisible, this flag may be used to turn on and off
|
|
* the reloading. This is useful if your component only needs to reload when
|
|
* becoming visible during certain states.
|
|
*
|
|
* eslint-disable-next-line react/sort-comp
|
|
*/
|
|
shouldReloadOnVisible = false;
|
|
|
|
/**
|
|
* This affects how the component behaves when `remountComponent` is called
|
|
* By default, the component gets put back into a "loading" state when re-fetching data.
|
|
* If this is true, then when we fetch data, the original ready component remains mounted
|
|
* and it will need to handle any additional "reloading" states
|
|
*/
|
|
shouldReload = false;
|
|
|
|
/**
|
|
* should `renderError` render the `detail` attribute of a 400 error
|
|
*/
|
|
shouldRenderBadRequests = false;
|
|
|
|
/**
|
|
* If a request fails and is not a bad request, and if `disableErrorReport` is set to false,
|
|
* the UI will display an error modal.
|
|
*
|
|
* It is recommended to enable this property ideally only when the subclass is used by a top level route.
|
|
*/
|
|
disableErrorReport = true;
|
|
|
|
api: Client = new Client();
|
|
private _measurement: any;
|
|
|
|
// XXX: can't call this getInitialState as React whines
|
|
getDefaultState(): AsyncComponentState {
|
|
const endpoints = this.getEndpoints();
|
|
|
|
const state = {
|
|
// has all data finished requesting?
|
|
loading: true,
|
|
// is the component reload
|
|
reloading: false,
|
|
// is there an error loading ANY data?
|
|
error: false,
|
|
errors: {},
|
|
// We will fetch immeditaely upon mount
|
|
remainingRequests: endpoints.length || undefined,
|
|
};
|
|
|
|
// We are not loading if there are no endpoints
|
|
if (!endpoints.length) {
|
|
state.loading = false;
|
|
}
|
|
|
|
endpoints.forEach(([stateKey, _endpoint]) => {
|
|
state[stateKey] = null;
|
|
});
|
|
return state;
|
|
}
|
|
|
|
// Check if we should measure render time for this component
|
|
markShouldMeasure = ({
|
|
remainingRequests,
|
|
error,
|
|
}: {error?: any; remainingRequests?: number} = {}) => {
|
|
if (!this._measurement.hasMeasured) {
|
|
this._measurement.finished = remainingRequests === 0;
|
|
this._measurement.error = error || this._measurement.error;
|
|
}
|
|
};
|
|
|
|
remountComponent = () => {
|
|
if (this.shouldReload) {
|
|
this.reloadData();
|
|
} else {
|
|
this.setState(this.getDefaultState(), this.fetchData);
|
|
}
|
|
};
|
|
|
|
visibilityReloader = () =>
|
|
this.shouldReloadOnVisible &&
|
|
!this.state.loading &&
|
|
!document.hidden &&
|
|
this.reloadData();
|
|
|
|
reloadData() {
|
|
this.fetchData({reloading: true});
|
|
}
|
|
|
|
fetchData = (extraState?: object) => {
|
|
const endpoints = this.getEndpoints();
|
|
|
|
if (!endpoints.length) {
|
|
this.setState({loading: false, error: false});
|
|
return;
|
|
}
|
|
|
|
// Cancel any in flight requests
|
|
this.api.clear();
|
|
|
|
this.setState({
|
|
loading: true,
|
|
error: false,
|
|
remainingRequests: endpoints.length,
|
|
...extraState,
|
|
});
|
|
|
|
endpoints.forEach(([stateKey, endpoint, params, options]) => {
|
|
options = options || {};
|
|
// If you're using nested async components/views make sure to pass the
|
|
// props through so that the child component has access to props.location
|
|
const locationQuery = this.props.location?.query || {};
|
|
let query = params?.query || {};
|
|
// If paginate option then pass entire `query` object to API call
|
|
// It should only be expecting `query.cursor` for pagination
|
|
if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
|
|
query = {...locationQuery, ...query};
|
|
}
|
|
|
|
this.api.request(endpoint, {
|
|
method: 'GET',
|
|
...params,
|
|
query,
|
|
success: (data, _, resp) => {
|
|
this.handleRequestSuccess({stateKey, data, resp}, true);
|
|
},
|
|
error: error => {
|
|
// Allow endpoints to fail
|
|
// allowError can have side effects to handle the error
|
|
if (options.allowError?.(error)) {
|
|
error = null;
|
|
}
|
|
this.handleError(error, [stateKey, endpoint, params, options]);
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
onRequestSuccess(_resp /* {stateKey, data, resp} */) {
|
|
// Allow children to implement this
|
|
}
|
|
|
|
onRequestError(_resp, _args) {
|
|
// Allow children to implement this
|
|
}
|
|
|
|
onLoadAllEndpointsSuccess() {
|
|
// Allow children to implement this
|
|
}
|
|
|
|
handleRequestSuccess({stateKey, data, resp}, initialRequest?: boolean) {
|
|
this.setState(
|
|
prevState => {
|
|
const state = {
|
|
[stateKey]: data,
|
|
// TODO(billy): This currently fails if this request is retried by SudoModal
|
|
[`${stateKey}PageLinks`]: resp?.getResponseHeader('Link'),
|
|
};
|
|
|
|
if (initialRequest) {
|
|
state.remainingRequests = prevState.remainingRequests! - 1;
|
|
state.loading = prevState.remainingRequests! > 1;
|
|
state.reloading = prevState.reloading && state.loading;
|
|
this.markShouldMeasure({remainingRequests: state.remainingRequests});
|
|
}
|
|
|
|
return state;
|
|
},
|
|
() => {
|
|
// if everything is loaded and we don't have an error, call the callback
|
|
if (this.state.remainingRequests === 0 && !this.state.error) {
|
|
this.onLoadAllEndpointsSuccess();
|
|
}
|
|
}
|
|
);
|
|
this.onRequestSuccess({stateKey, data, resp});
|
|
}
|
|
|
|
handleError(error, args) {
|
|
const [stateKey] = args;
|
|
if (error?.responseText) {
|
|
Sentry.addBreadcrumb({
|
|
message: error.responseText,
|
|
category: 'xhr',
|
|
level: 'error',
|
|
});
|
|
}
|
|
this.setState(prevState => {
|
|
const loading = prevState.remainingRequests! > 1;
|
|
const state: AsyncComponentState = {
|
|
[stateKey]: null,
|
|
errors: {
|
|
...prevState.errors,
|
|
[stateKey]: error,
|
|
},
|
|
error: prevState.error || !!error,
|
|
remainingRequests: prevState.remainingRequests! - 1,
|
|
loading,
|
|
reloading: prevState.reloading && loading,
|
|
};
|
|
this.markShouldMeasure({remainingRequests: state.remainingRequests, error: true});
|
|
|
|
return state;
|
|
});
|
|
this.onRequestError(error, args);
|
|
}
|
|
|
|
/**
|
|
* Return a list of endpoint queries to make.
|
|
*
|
|
* return [
|
|
* ['stateKeyName', '/endpoint/', {optional: 'query params'}, {options}]
|
|
* ]
|
|
*/
|
|
getEndpoints(): Array<[string, string, any?, any?]> {
|
|
return [];
|
|
}
|
|
|
|
renderSearchInput({stateKey, url, ...props}: RenderSearchInputArgs) {
|
|
const [firstEndpoint] = this.getEndpoints() || [null];
|
|
const stateKeyOrDefault = stateKey || firstEndpoint?.[0];
|
|
const urlOrDefault = url || firstEndpoint?.[1];
|
|
return (
|
|
<AsyncComponentSearchInput
|
|
url={urlOrDefault}
|
|
{...props}
|
|
api={this.api}
|
|
onSuccess={(data, resp) => {
|
|
this.handleRequestSuccess({stateKey: stateKeyOrDefault, data, resp});
|
|
}}
|
|
onError={() => {
|
|
this.renderError(new Error('Error with AsyncComponentSearchInput'));
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
renderLoading(): React.ReactNode {
|
|
return <LoadingIndicator />;
|
|
}
|
|
|
|
renderError(error?: Error, disableLog = false): React.ReactNode {
|
|
const {errors} = this.state;
|
|
|
|
// 401s are captured by SudoModal, but may be passed back to AsyncComponent if they close the modal without identifying
|
|
const unauthorizedErrors = Object.values(errors).find(resp => resp?.status === 401);
|
|
|
|
// Look through endpoint results to see if we had any 403s, means their role can not access resource
|
|
const permissionErrors = Object.values(errors).find(resp => resp?.status === 403);
|
|
|
|
// If all error responses have status code === 0, then show error message but don't
|
|
// log it to sentry
|
|
const shouldLogSentry =
|
|
!!Object.values(errors).find(resp => resp?.status !== 0) || disableLog;
|
|
|
|
if (unauthorizedErrors) {
|
|
return (
|
|
<LoadingError message={t('You are not authorized to access this resource.')} />
|
|
);
|
|
}
|
|
|
|
if (permissionErrors) {
|
|
return <PermissionDenied />;
|
|
}
|
|
|
|
if (this.shouldRenderBadRequests) {
|
|
const badRequests = Object.values(errors)
|
|
.filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
|
|
.map(resp => resp.responseJSON.detail);
|
|
|
|
if (badRequests.length) {
|
|
return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<RouteError
|
|
error={error}
|
|
disableLogSentry={!shouldLogSentry}
|
|
disableReport={this.disableErrorReport}
|
|
/>
|
|
);
|
|
}
|
|
|
|
get shouldRenderLoading() {
|
|
return this.state.loading && (!this.shouldReload || !this.state.reloading);
|
|
}
|
|
|
|
renderComponent() {
|
|
return this.shouldRenderLoading
|
|
? this.renderLoading()
|
|
: this.state.error
|
|
? this.renderError()
|
|
: this.renderBody();
|
|
}
|
|
|
|
/**
|
|
* Renders once all endpoints have been loaded
|
|
*/
|
|
renderBody(): React.ReactNode {
|
|
// Allow children to implement this
|
|
throw new Error('Not implemented');
|
|
}
|
|
|
|
render() {
|
|
return this.renderComponent();
|
|
}
|
|
}
|
|
|
|
export default DeprecatedAsyncComponent;
|