ErrorBoundary: Support recovering from errors in PanelChrome & PanelRenderer (#40785)

* ErrorBoundary: Support recovering from errors in PanelChrome & PanelRenderer

* Rename recover to dependencies

* Pushed an update that fixed test and adds new error mode to DebugPanel
This commit is contained in:
Torkel Ödegaard
2021-10-22 11:52:05 +02:00
committed by GitHub
parent 5af8d7de07
commit a091d35f11
9 changed files with 133 additions and 50 deletions

View File

@@ -33,4 +33,39 @@ describe('ErrorBoundary', () => {
expect(context.contexts.react).toHaveProperty('componentStack');
expect(context.contexts.react.componentStack).toMatch(/^\s+at ErrorThrower (.*)\s+at ErrorBoundary (.*)\s*$/);
});
it('should recover when when recover props change', async () => {
const problem = new Error('things went terribly wrong');
let renderCount = 0;
const { rerender } = render(
<ErrorBoundary dependencies={[1, 2]}>
{({ error }) => {
if (!error) {
renderCount += 1;
return <ErrorThrower error={problem} />;
} else {
return <p>{error.message}</p>;
}
}}
</ErrorBoundary>
);
await screen.findByText(problem.message);
rerender(
<ErrorBoundary dependencies={[1, 3]}>
{({ error }) => {
if (!error) {
renderCount += 1;
return <ErrorThrower error={problem} />;
} else {
return <p>{error.message}</p>;
}
}}
</ErrorBoundary>
);
expect(renderCount).toBe(2);
});
});

View File

@@ -14,6 +14,12 @@ export interface ErrorBoundaryApi {
interface Props {
children: (r: ErrorBoundaryApi) => ReactNode;
/** Will re-render children after error if recover values changes */
dependencies?: any[];
/** Callback called on error */
onError?: (error: Error) => void;
/** Callback error state is cleared due to recover props change */
onRecover?: () => void;
}
interface State {
@@ -29,10 +35,29 @@ export class ErrorBoundary extends PureComponent<Props, State> {
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
captureException(error, { contexts: { react: { componentStack: errorInfo.componentStack } } });
this.setState({
error: error,
errorInfo: errorInfo,
});
this.setState({ error, errorInfo });
if (this.props.onError) {
this.props.onError(error);
}
}
componentDidUpdate(prevProps: Props) {
const { dependencies, onRecover } = this.props;
if (this.state.error) {
if (dependencies && prevProps.dependencies) {
for (let i = 0; i < dependencies.length; i++) {
if (dependencies[i] !== prevProps.dependencies[i]) {
this.setState({ error: null, errorInfo: null });
if (onRecover) {
onRecover();
}
break;
}
}
}
}
}
render() {
@@ -60,6 +85,9 @@ export interface ErrorBoundaryAlertProps {
/** 'page' will render full page error with stacktrace. 'alertbox' will render an <Alert />. Default 'alertbox' */
style?: 'page' | 'alertbox';
/** Will re-render children after error if recover values changes */
dependencies?: any[];
}
export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
@@ -69,10 +97,10 @@ export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
};
render() {
const { title, children, style } = this.props;
const { title, children, style, dependencies } = this.props;
return (
<ErrorBoundary>
<ErrorBoundary dependencies={dependencies}>
{({ error, errorInfo }) => {
if (!errorInfo) {
return children;

View File

@@ -309,12 +309,17 @@ export class PanelChrome extends PureComponent<Props, State> {
this.props.panel.updateFieldConfig(config);
};
onPanelError = (message: string) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
onPanelError = (error: Error) => {
const errorMessage = error.message || DEFAULT_PLUGIN_ERROR;
if (this.state.errorMessage !== errorMessage) {
this.setState({ errorMessage });
}
};
onPanelErrorRecover = () => {
this.setState({ errorMessage: undefined });
};
onAnnotationCreate = async (event: AnnotationEventUIModel) => {
const isRegion = event.from !== event.to;
const anno = {
@@ -458,7 +463,7 @@ export class PanelChrome extends PureComponent<Props, State> {
}
render() {
const { dashboard, panel, isViewing, isEditing, width, height } = this.props;
const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props;
const { errorMessage, data } = this.state;
const { transparent } = panel;
@@ -489,10 +494,13 @@ export class PanelChrome extends PureComponent<Props, State> {
alertState={alertState}
data={data}
/>
<ErrorBoundary>
<ErrorBoundary
dependencies={[data, plugin, panel.getOptions()]}
onError={this.onPanelError}
onRecover={this.onPanelErrorRecover}
>
{({ error }) => {
if (error) {
this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return this.renderPanel(width, height);

View File

@@ -5,7 +5,7 @@ import { appEvents } from 'app/core/core';
import { useAsync } from 'react-use';
import { getPanelOptionsWithDefaults, OptionDefaults } from '../../dashboard/state/getPanelOptionsWithDefaults';
import { importPanelPlugin } from '../../plugins/importPanelPlugin';
import { useTheme2 } from '@grafana/ui';
import { ErrorBoundaryAlert, useTheme2 } from '@grafana/ui';
const defaultFieldConfig = { defaults: {}, overrides: [] };
@@ -51,24 +51,26 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
const PanelComponent = plugin.panel;
return (
<PanelComponent
id={1}
data={dataWithOverrides}
title={title}
timeRange={dataWithOverrides.timeRange}
timeZone={timeZone}
options={optionsWithDefaults!.options}
fieldConfig={localFieldConfig}
transparent={false}
width={width}
height={height}
renderCounter={0}
replaceVariables={(str: string) => str}
onOptionsChange={onOptionsChange}
onFieldConfigChange={setFieldConfig}
onChangeTimeRange={onChangeTimeRange}
eventBus={appEvents}
/>
<ErrorBoundaryAlert dependencies={[plugin, data]}>
<PanelComponent
id={1}
data={dataWithOverrides}
title={title}
timeRange={dataWithOverrides.timeRange}
timeZone={timeZone}
options={optionsWithDefaults!.options}
fieldConfig={localFieldConfig}
transparent={false}
width={width}
height={height}
renderCounter={0}
replaceVariables={(str: string) => str}
onOptionsChange={onOptionsChange}
onFieldConfigChange={setFieldConfig}
onChangeTimeRange={onChangeTimeRange}
eventBus={appEvents}
/>
</ErrorBoundaryAlert>
);
}

View File

@@ -1,4 +1,3 @@
import { LegendDisplayMode } from '@grafana/schema';
import {
ApplyFieldOverrideOptions,
DataTransformerConfig,
@@ -7,7 +6,7 @@ import {
NavModelItem,
PanelData,
} from '@grafana/data';
import { Table, TimeSeries } from '@grafana/ui';
import { Table } from '@grafana/ui';
import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
@@ -16,6 +15,7 @@ import { PanelQueryRunner } from '../query/state/PanelQueryRunner';
import { QueryGroupOptions } from 'app/types';
import Page from '../../core/components/Page/Page';
import AutoSizer from 'react-virtualized-auto-sizer';
import { PanelRenderer } from '../panel/components/PanelRenderer';
interface State {
queryRunner: PanelQueryRunner;
@@ -66,12 +66,14 @@ export const TestStuffPage: FC = () => {
{({ width }) => {
return (
<div>
<TimeSeries
<PanelRenderer
title="Hello"
pluginId="timeseries"
width={width}
height={300}
frames={data.series}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeRange={data.timeRange}
data={data}
options={{}}
fieldConfig={{ defaults: {}, overrides: [] }}
timeZone="browser"
/>
<Table data={data.series[0]} width={width} height={300} />

View File

@@ -13,18 +13,17 @@ export class DebugPanel extends Component<Props> {
render() {
const { options } = this.props;
if (options.mode === DebugMode.Events) {
return <EventBusLoggerPanel eventBus={this.props.eventBus} />;
switch (options.mode) {
case DebugMode.Events:
return <EventBusLoggerPanel eventBus={this.props.eventBus} />;
case DebugMode.Cursor:
return <CursorView eventBus={this.props.eventBus} />;
case DebugMode.State:
return <StateView {...this.props} />;
case DebugMode.ThrowError:
throw new Error('I failed you and for that i am deeply sorry');
default:
return <RenderInfoViewer {...this.props} />;
}
if (options.mode === DebugMode.Cursor) {
return <CursorView eventBus={this.props.eventBus} />;
}
if (options.mode === DebugMode.State) {
return <StateView {...this.props} />;
}
return <RenderInfoViewer {...this.props} />;
}
}

View File

@@ -5,7 +5,7 @@ import { DebugMode, DebugPanelOptions } from './types';
export const plugin = new PanelPlugin<DebugPanelOptions>(DebugPanel).useFieldConfig().setPanelOptions((builder) => {
builder
.addRadio({
.addSelect({
path: 'mode',
name: 'Mode',
defaultValue: DebugMode.Render,
@@ -14,7 +14,9 @@ export const plugin = new PanelPlugin<DebugPanelOptions>(DebugPanel).useFieldCon
{ label: 'Render', value: DebugMode.Render },
{ label: 'Events', value: DebugMode.Events },
{ label: 'Cursor', value: DebugMode.Cursor },
{ label: 'Cursor', value: DebugMode.Cursor },
{ label: 'Share state', value: DebugMode.State },
{ label: 'Throw error', value: DebugMode.ThrowError },
],
},
})

View File

@@ -13,6 +13,7 @@ export enum DebugMode {
Events = 'events',
Cursor = 'cursor',
State = 'State',
ThrowError = 'ThrowError',
}
export interface DebugPanelOptions {

View File

@@ -502,6 +502,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "BenchmarksPage"*/ 'app/features/sandbox/BenchmarksPage')
),
},
{
path: '/sandbox/test',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "TestStuffPage"*/ 'app/features/sandbox/TestStuffPage')
),
},
{
path: '/dashboards/f/:uid/:slug/library-panels',
component: SafeDynamicImport(