Alerting: wrap top level components in ErrorBoundary (#34040)

This commit is contained in:
Domas 2021-05-18 13:56:31 +03:00 committed by GitHub
parent 8ecfad6995
commit 098b4fc319
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 182 additions and 124 deletions

View File

@ -54,3 +54,21 @@ import { ErrorWithStack } from '@grafana/ui';
<ArgsTable of={ErrorWithStack}/> <ArgsTable of={ErrorWithStack}/>
# withErrorBoundary
a HOC to conveniently wrap component in an error boundary
### Usage
```jsx
import { withErrorBoundary } from '@grafana/ui';
interface MyCompProps {
...
}
const MyComp = withErrorBoundary((props: MyCompProps) => {
return <>...</>
}, { style: 'page' });
```

View File

@ -1,4 +1,4 @@
import React, { PureComponent, ReactNode } from 'react'; import React, { PureComponent, ReactNode, ComponentType } from 'react';
import { captureException } from '@sentry/browser'; import { captureException } from '@sentry/browser';
import { Alert } from '../Alert/Alert'; import { Alert } from '../Alert/Alert';
import { ErrorWithStack } from './ErrorWithStack'; import { ErrorWithStack } from './ErrorWithStack';
@ -46,14 +46,24 @@ export class ErrorBoundary extends PureComponent<Props, State> {
} }
} }
interface WithAlertBoxProps { /**
* Props for the ErrorBoundaryAlert component
*
* @public
*/
export interface ErrorBoundaryAlertProps {
/** Title for the error boundary alert */
title?: string; title?: string;
/** Component to be wrapped with an error boundary */
children: ReactNode; children: ReactNode;
/** 'page' will render full page error with stacktrace. 'alertbox' will render an <Alert />. Default 'alertbox' */
style?: 'page' | 'alertbox'; style?: 'page' | 'alertbox';
} }
export class ErrorBoundaryAlert extends PureComponent<WithAlertBoxProps> { export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
static defaultProps: Partial<WithAlertBoxProps> = { static defaultProps: Partial<ErrorBoundaryAlertProps> = {
title: 'An unexpected error happened', title: 'An unexpected error happened',
style: 'alertbox', style: 'alertbox',
}; };
@ -86,3 +96,25 @@ export class ErrorBoundaryAlert extends PureComponent<WithAlertBoxProps> {
); );
} }
} }
/**
* HOC for wrapping a component in an error boundary.
*
* @param Component - the react component to wrap in error boundary
* @param errorBoundaryProps - error boundary options
*
* @public
*/
export function withErrorBoundary<P = {}>(
Component: ComponentType<P>,
errorBoundaryProps: Omit<ErrorBoundaryAlertProps, 'children'> = {}
): ComponentType<P> {
const comp = (props: P) => (
<ErrorBoundaryAlert {...errorBoundaryProps}>
<Component {...props} />
</ErrorBoundaryAlert>
);
comp.displayName = 'WithErrorBoundary';
return comp;
}

View File

@ -132,7 +132,12 @@ export { FeatureBadge, FeatureInfoBox } from './InfoBox/FeatureInfoBox';
export { JSONFormatter } from './JSONFormatter/JSONFormatter'; export { JSONFormatter } from './JSONFormatter/JSONFormatter';
export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer'; export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary'; export {
ErrorBoundary,
ErrorBoundaryAlert,
ErrorBoundaryAlertProps,
withErrorBoundary,
} from './ErrorBoundary/ErrorBoundary';
export { ErrorWithStack } from './ErrorBoundary/ErrorWithStack'; export { ErrorWithStack } from './ErrorBoundary/ErrorWithStack';
export { AlphaNotice } from './AlphaNotice/AlphaNotice'; export { AlphaNotice } from './AlphaNotice/AlphaNotice';
export { DataSourceHttpSettings } from './DataSourceSettings/DataSourceHttpSettings'; export { DataSourceHttpSettings } from './DataSourceSettings/DataSourceHttpSettings';

View File

@ -1,7 +1,7 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { Receiver } from 'app/plugins/datasource/alertmanager/types'; import { Receiver } from 'app/plugins/datasource/alertmanager/types';
@ -132,7 +132,7 @@ const AmRoutes: FC = () => {
); );
}; };
export default AmRoutes; export default withErrorBoundary(AmRoutes, { style: 'page' });
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
break: css` break: css`

View File

@ -1,4 +1,4 @@
import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
import React, { FC, useEffect } from 'react'; import React, { FC, useEffect } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom';
@ -104,4 +104,4 @@ const Receivers: FC = () => {
); );
}; };
export default Receivers; export default withErrorBoundary(Receivers, { style: 'page' });

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { Alert, LinkButton, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { useCleanup } from 'app/core/hooks/useCleanup'; import { useCleanup } from 'app/core/hooks/useCleanup';
@ -82,4 +82,4 @@ const warningStyles = (theme: GrafanaTheme2) => ({
`, `,
}); });
export default RuleEditor; export default withErrorBoundary(RuleEditor, { style: 'page' });

View File

@ -1,7 +1,7 @@
import { DataSourceInstanceSettings, GrafanaTheme, urlUtil } from '@grafana/data'; import { DataSourceInstanceSettings, GrafanaTheme, urlUtil } from '@grafana/data';
import { useStyles, ButtonGroup, ToolbarButton, Alert, LinkButton } from '@grafana/ui'; import { useStyles, ButtonGroup, ToolbarButton, Alert, LinkButton, withErrorBoundary } from '@grafana/ui';
import { SerializedError } from '@reduxjs/toolkit'; import { SerializedError } from '@reduxjs/toolkit';
import React, { FC, useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { NoRulesSplash } from './components/rules/NoRulesCTA'; import { NoRulesSplash } from './components/rules/NoRulesCTA';
@ -25,126 +25,129 @@ const VIEWS = {
state: RuleListStateView, state: RuleListStateView,
}; };
export const RuleList: FC = () => { export const RuleList = withErrorBoundary(
const dispatch = useDispatch(); () => {
const styles = useStyles(getStyles); const dispatch = useDispatch();
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); const styles = useStyles(getStyles);
const location = useLocation(); const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const location = useLocation();
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
const view = VIEWS[queryParams['view'] as keyof typeof VIEWS] const view = VIEWS[queryParams['view'] as keyof typeof VIEWS]
? (queryParams['view'] as keyof typeof VIEWS) ? (queryParams['view'] as keyof typeof VIEWS)
: 'groups'; : 'groups';
const ViewComponent = VIEWS[view]; const ViewComponent = VIEWS[view];
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => { useEffect(() => {
dispatch(fetchAllPromAndRulerRulesAction()); dispatch(fetchAllPromAndRulerRulesAction());
const interval = setInterval(() => dispatch(fetchAllPromAndRulerRulesAction()), RULE_LIST_POLL_INTERVAL_MS); const interval = setInterval(() => dispatch(fetchAllPromAndRulerRulesAction()), RULE_LIST_POLL_INTERVAL_MS);
return () => { return () => {
clearInterval(interval); clearInterval(interval);
}; };
}, [dispatch]); }, [dispatch]);
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const dispatched = rulesDataSourceNames.some( const dispatched = rulesDataSourceNames.some(
(name) => promRuleRequests[name]?.dispatched || rulerRuleRequests[name]?.dispatched (name) => promRuleRequests[name]?.dispatched || rulerRuleRequests[name]?.dispatched
); );
const loading = rulesDataSourceNames.some( const loading = rulesDataSourceNames.some(
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
); );
const haveResults = rulesDataSourceNames.some( const haveResults = rulesDataSourceNames.some(
(name) => (name) =>
(promRuleRequests[name]?.result?.length && !promRuleRequests[name]?.error) || (promRuleRequests[name]?.result?.length && !promRuleRequests[name]?.error) ||
(Object.keys(rulerRuleRequests[name]?.result || {}).length && !rulerRuleRequests[name]?.error) (Object.keys(rulerRuleRequests[name]?.result || {}).length && !rulerRuleRequests[name]?.error)
); );
const [promReqeustErrors, rulerRequestErrors] = useMemo( const [promReqeustErrors, rulerRequestErrors] = useMemo(
() => () =>
[promRuleRequests, rulerRuleRequests].map((requests) => [promRuleRequests, rulerRuleRequests].map((requests) =>
getRulesDataSources().reduce<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>( getRulesDataSources().reduce<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>(
(result, dataSource) => { (result, dataSource) => {
const error = requests[dataSource.name]?.error; const error = requests[dataSource.name]?.error;
if (requests[dataSource.name] && error && !isRulerNotSupportedResponse(requests[dataSource.name])) { if (requests[dataSource.name] && error && !isRulerNotSupportedResponse(requests[dataSource.name])) {
return [...result, { dataSource, error }]; return [...result, { dataSource, error }];
} }
return result; return result;
}, },
[] []
) )
), ),
[promRuleRequests, rulerRuleRequests] [promRuleRequests, rulerRuleRequests]
); );
const grafanaPromError = promRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; const grafanaPromError = promRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error;
const grafanaRulerError = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; const grafanaRulerError = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error;
const showNewAlertSplash = dispatched && !loading && !haveResults; const showNewAlertSplash = dispatched && !loading && !haveResults;
const combinedNamespaces = useCombinedRuleNamespaces(); const combinedNamespaces = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces); const filteredNamespaces = useFilteredRules(combinedNamespaces);
return ( return (
<AlertingPageWrapper pageId="alert-list" isLoading={loading && !haveResults}> <AlertingPageWrapper pageId="alert-list" isLoading={loading && !haveResults}>
{(promReqeustErrors.length || rulerRequestErrors.length || grafanaPromError) && ( {(promReqeustErrors.length || rulerRequestErrors.length || grafanaPromError) && (
<Alert data-testid="cloud-rulessource-errors" title="Errors loading rules" severity="error"> <Alert data-testid="cloud-rulessource-errors" title="Errors loading rules" severity="error">
{grafanaPromError && ( {grafanaPromError && (
<div>Failed to load Grafana threshold rules state: {grafanaPromError.message || 'Unknown error.'}</div> <div>Failed to load Grafana threshold rules state: {grafanaPromError.message || 'Unknown error.'}</div>
)}
{grafanaRulerError && (
<div>Failed to load Grafana threshold rules config: {grafanaRulerError.message || 'Unknown error.'}</div>
)}
{promReqeustErrors.map(({ dataSource, error }) => (
<div key={dataSource.name}>
Failed to load rules state from <a href={`datasources/edit/${dataSource.uid}`}>{dataSource.name}</a>:{' '}
{error.message || 'Unknown error.'}
</div>
))}
{rulerRequestErrors.map(({ dataSource, error }) => (
<div key={dataSource.name}>
Failed to load rules config from <a href={'datasources/edit/${dataSource.uid}'}>{dataSource.name}</a>:{' '}
{error.message || 'Unknown error.'}
</div>
))}
</Alert>
)}
{!showNewAlertSplash && (
<>
<RulesFilter />
<div className={styles.break} />
<div className={styles.buttonsContainer}>
<ButtonGroup>
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'group' })}>
<ToolbarButton variant={view === 'groups' ? 'active' : 'default'} icon="folder">
Groups
</ToolbarButton>
</a>
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'state' })}>
<ToolbarButton variant={view === 'state' ? 'active' : 'default'} icon="heart-rate">
State
</ToolbarButton>
</a>
</ButtonGroup>
<div />
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
<LinkButton
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
icon="plus"
>
New alert rule
</LinkButton>
)} )}
</div> {grafanaRulerError && (
</> <div>Failed to load Grafana threshold rules config: {grafanaRulerError.message || 'Unknown error.'}</div>
)} )}
{showNewAlertSplash && <NoRulesSplash />} {promReqeustErrors.map(({ dataSource, error }) => (
{haveResults && <ViewComponent namespaces={filteredNamespaces} />} <div key={dataSource.name}>
</AlertingPageWrapper> Failed to load rules state from <a href={`datasources/edit/${dataSource.uid}`}>{dataSource.name}</a>:{' '}
); {error.message || 'Unknown error.'}
}; </div>
))}
{rulerRequestErrors.map(({ dataSource, error }) => (
<div key={dataSource.name}>
Failed to load rules config from <a href={'datasources/edit/${dataSource.uid}'}>{dataSource.name}</a>:{' '}
{error.message || 'Unknown error.'}
</div>
))}
</Alert>
)}
{!showNewAlertSplash && (
<>
<RulesFilter />
<div className={styles.break} />
<div className={styles.buttonsContainer}>
<ButtonGroup>
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'group' })}>
<ToolbarButton variant={view === 'groups' ? 'active' : 'default'} icon="folder">
Groups
</ToolbarButton>
</a>
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'state' })}>
<ToolbarButton variant={view === 'state' ? 'active' : 'default'} icon="heart-rate">
State
</ToolbarButton>
</a>
</ButtonGroup>
<div />
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
<LinkButton
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
icon="plus"
>
New alert rule
</LinkButton>
)}
</div>
</>
)}
{showNewAlertSplash && <NoRulesSplash />}
{haveResults && <ViewComponent namespaces={filteredNamespaces} />}
</AlertingPageWrapper>
);
},
{ style: 'page' }
);
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = (theme: GrafanaTheme) => ({
break: css` break: css`

View File

@ -1,5 +1,5 @@
import React, { FC, useEffect, useCallback } from 'react'; import React, { FC, useEffect, useCallback } from 'react';
import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom';
@ -93,4 +93,4 @@ const Silences: FC = () => {
); );
}; };
export default Silences; export default withErrorBoundary(Silences, { style: 'page' });