mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -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} />
|
||||
|
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
@@ -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 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
@@ -13,6 +13,7 @@ export enum DebugMode {
|
||||
Events = 'events',
|
||||
Cursor = 'cursor',
|
||||
State = 'State',
|
||||
ThrowError = 'ThrowError',
|
||||
}
|
||||
|
||||
export interface DebugPanelOptions {
|
||||
|
@@ -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(
|
||||
|
Reference in New Issue
Block a user