[Security Solution] Add error boundaries to rule upgrade workflow flyout (#204315)

**Partially addresses:** https://github.com/elastic/kibana/issues/202715

## Summary

This PR adds React error boundaries to comparison side, final side readonly and final side edit modes. The goal is mitigating chances of blocking rule upgrade workflow.

## Details

Kibana already has `KibanaErrorBoundary` component to catch thrown errors. Closer look at the component reveals it was designed to be applied at page level. The component doesn't accept any customization.

Obviously Kibana requires an error boundary component to catch thrown errors at section levels. Such error are usually fatal non-recoverable error happening due to unexpected data arrives from the storage. It may block critical workflows.

To mitigate workflow blocking and address section level errors a new `KibanaSectionErrorBoundary` component was added. It accepts `sectionName` property to properly reflect it in messages. On top of that it shared displaying error functionality with `KibanaErrorBoundary`.

`KibanaSectionErrorBoundary`  was applied to the following sections in Rule Upgrade Flyout

- All flyout tabs
- comparison side (Diff View)
- final side readonly mode
- final side edit mode

## Screenshots

**Before:**

![image](https://github.com/user-attachments/assets/c7890b3f-0b6b-478f-a91b-a332e31a4260)

**After:**

<img width="2549" alt="Screenshot 2025-01-02 at 12 26 15" src="https://github.com/user-attachments/assets/3617be5b-c063-4529-9b7f-e931520fbf92" />

<img width="2557" alt="Screenshot 2025-01-02 at 12 24 33" src="https://github.com/user-attachments/assets/da7407af-a263-4e4a-812e-6b76a75b5be9" />

<img width="2556" alt="Screenshot 2025-01-02 at 12 26 57" src="https://github.com/user-attachments/assets/c2faedbe-15a5-4da6-9c9a-a767edb403b0" />

<img width="2556" alt="Screenshot 2025-01-02 at 12 27 08" src="https://github.com/user-attachments/assets/061dd645-f5e4-48ac-957b-50a8fea2d2e7" />

<img width="2556" alt="Screenshot 2025-01-02 at 12 27 27" src="https://github.com/user-attachments/assets/3e8c31de-d251-4eb1-a49f-8622b5640b70" />

## How to test?

- Ensure the `prebuiltRulesCustomizationEnabled` feature flag is enabled
- Allow internal APIs via adding `server.restrictInternalApis: false` to `kibana.dev.yaml`
- Clear Elasticsearch data
- Run Elasticsearch and Kibana locally (do not open Kibana in a web browser)
- Install an outdated version of the `security_detection_engine` Fleet package
```bash
curl -X POST --user elastic:changeme  -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"force":true}' http://localhost:5601/kbn/api/fleet/epm/packages/security_detection_engine/8.14.1
```

- Install prebuilt rules
```bash
curl -X POST --user elastic:changeme  -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 1" -d '{"mode":"ALL_RULES"}' http://localhost:5601/kbn/internal/detection_engine/prebuilt_rules/installation/_perform
```

- Cause some error in the rule upgrade flyout, for example set a negative look-back duration for `Suspicious File Creation via Kworker` rule by patching the rule

```bash
curl -X PATCH --user elastic:changeme -H "Content-Type: application/json" -H "elastic-api-version: 2023-10-31" -H "kbn-xsrf: 123" -d '{"rule_id":"ae343298-97bc-47bc-9ea2-5f2ad831c16e","interval":"10m","from":"now-5m","to":"now-2m"}' http://localhost:5601/kbn/api/detection_engine/rules
```

- Open rule upgrade flyout for `Suspicious File Creation via Kworker` rule
This commit is contained in:
Maxim Palenov 2025-01-15 16:01:21 +01:00 committed by GitHub
parent b0973cf26c
commit b4342f44f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 573 additions and 124 deletions

View file

@ -9,6 +9,13 @@ date: 2023-10-03
## Description
This package exports `KibanaErrorBoundary` and `KibanaSectionErrorBoundary` components.
- `KibanaErrorBoundary` is designed to capture rendering errors by blocking the main part of the UI.
- `KibanaSectionErrorBoundary` is designed to capture errors at a more granular level.
In general, it's best to use `KibanaErrorBoundary` and block the whole page. If it is acceptable to assume the risk of allowing users to interact with a UI that has an error state, then using `KibanaSectionErrorBoundary` may be an acceptable alternative, but this must be judged on a case-by-case basis.
## API
## EUI Promotion Status

View file

@ -9,6 +9,7 @@
export { KibanaErrorBoundaryProvider } from './src/services/error_boundary_services';
export { KibanaErrorBoundary } from './src/ui/error_boundary';
export { KibanaSectionErrorBoundary } from './src/ui/section_error_boundary';
export { ThrowIfError } from './src/ui/throw_if_error';
export { REACT_FATAL_ERROR_EVENT_TYPE, reactFatalErrorSchema } from './lib/telemetry_events';

View file

@ -10,10 +10,12 @@
import { Meta, Story } from '@storybook/react';
import React from 'react';
import { EuiFormFieldset } from '@elastic/eui';
import { Template } from '../../mocks/src/storybook_template';
import { BadComponent, KibanaErrorBoundaryStorybookMock } from '../../mocks';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
import { KibanaErrorBoundary } from './error_boundary';
import { KibanaSectionErrorBoundary } from './section_error_boundary';
import mdx from '../../README.mdx';
@ -43,3 +45,24 @@ export const ErrorInCallout: Story = () => {
</Template>
);
};
export const SectionErrorInCallout: Story = () => {
const services = storybookMock.getServices();
return (
<Template>
<KibanaErrorBoundaryDepsProvider {...services}>
<EuiFormFieldset legend={{ children: 'Section A' }}>
<KibanaSectionErrorBoundary sectionName="sectionA">
<BadComponent />
</KibanaSectionErrorBoundary>
</EuiFormFieldset>
<EuiFormFieldset legend={{ children: 'Section B' }}>
<KibanaSectionErrorBoundary sectionName="sectionB">
<BadComponent />
</KibanaSectionErrorBoundary>
</EuiFormFieldset>
</KibanaErrorBoundaryDepsProvider>
</Template>
);
};

View file

@ -10,10 +10,12 @@
import { Meta, Story } from '@storybook/react';
import React from 'react';
import { EuiFormFieldset } from '@elastic/eui';
import { Template } from '../../mocks/src/storybook_template';
import { ChunkLoadErrorComponent, KibanaErrorBoundaryStorybookMock } from '../../mocks';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
import { KibanaErrorBoundary } from './error_boundary';
import { KibanaSectionErrorBoundary } from './section_error_boundary';
import mdx from '../../README.mdx';
@ -45,3 +47,24 @@ export const ErrorInCallout: Story = () => {
</Template>
);
};
export const SectionErrorInCallout: Story = () => {
const services = storybookMock.getServices();
return (
<Template>
<KibanaErrorBoundaryDepsProvider {...services}>
<EuiFormFieldset legend={{ children: 'Section A' }}>
<KibanaSectionErrorBoundary sectionName="sectionA">
<ChunkLoadErrorComponent />
</KibanaSectionErrorBoundary>
</EuiFormFieldset>
<EuiFormFieldset legend={{ children: 'Section B' }}>
<KibanaSectionErrorBoundary sectionName="sectionB">
<ChunkLoadErrorComponent />
</KibanaSectionErrorBoundary>
</EuiFormFieldset>
</KibanaErrorBoundaryDepsProvider>
</Template>
);
};

View file

@ -48,8 +48,8 @@ describe('<KibanaErrorBoundary>', () => {
);
(await findByTestId('clickForErrorBtn')).click();
expect(await findByText(strings.recoverable.callout.title())).toBeVisible();
expect(await findByText(strings.recoverable.callout.pageReloadButton())).toBeVisible();
expect(await findByText(strings.page.callout.recoverable.title())).toBeVisible();
expect(await findByText(strings.page.callout.recoverable.pageReloadButton())).toBeVisible();
(await findByTestId('errorBoundaryRecoverablePromptReloadBtn')).click();
@ -66,10 +66,10 @@ describe('<KibanaErrorBoundary>', () => {
);
(await findByTestId('clickForErrorBtn')).click();
expect(await findByText(strings.fatal.callout.title())).toBeVisible();
expect(await findByText(strings.fatal.callout.body())).toBeVisible();
expect(await findByText(strings.fatal.callout.showDetailsButton())).toBeVisible();
expect(await findByText(strings.fatal.callout.pageReloadButton())).toBeVisible();
expect(await findByText(strings.page.callout.fatal.title())).toBeVisible();
expect(await findByText(strings.page.callout.fatal.body())).toBeVisible();
expect(await findByText(strings.page.callout.fatal.showDetailsButton())).toBeVisible();
expect(await findByText(strings.page.callout.fatal.pageReloadButton())).toBeVisible();
(await findByTestId('errorBoundaryFatalPromptReloadBtn')).click();

View file

@ -9,7 +9,7 @@
import React from 'react';
import { KibanaErrorBoundaryServices } from '../../types';
import type { KibanaErrorBoundaryServices } from '../../types';
import { useErrorBoundary } from '../services/error_boundary_services';
import { FatalPrompt, RecoverablePrompt } from './message_components';
@ -20,19 +20,15 @@ interface ErrorBoundaryState {
isFatal: null | boolean;
}
interface ErrorBoundaryProps {
children?: React.ReactNode;
}
interface ServiceContext {
services: KibanaErrorBoundaryServices;
}
class ErrorBoundaryInternal extends React.Component<
ErrorBoundaryProps & ServiceContext,
React.PropsWithChildren<ServiceContext>,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps & ServiceContext) {
constructor(props: React.PropsWithChildren<ServiceContext>) {
super(props);
this.state = {
error: null,
@ -66,14 +62,7 @@ class ErrorBoundaryInternal extends React.Component<
/>
);
} else {
return (
<RecoverablePrompt
error={error}
errorInfo={errorInfo}
name={componentName}
onClickRefresh={this.props.services.onClickRefresh}
/>
);
return <RecoverablePrompt onClickRefresh={this.props.services.onClickRefresh} />;
}
}
@ -87,7 +76,7 @@ class ErrorBoundaryInternal extends React.Component<
* @param {ErrorBoundaryProps} props - ErrorBoundaryProps
* @public
*/
export const KibanaErrorBoundary = (props: ErrorBoundaryProps) => {
export const KibanaErrorBoundary = (props: React.PropsWithChildren<{}>) => {
const services = useErrorBoundary();
return <ErrorBoundaryInternal {...props} services={services} />;
};

View file

@ -29,21 +29,18 @@ import {
import { errorMessageStrings as strings } from './message_strings';
export interface ErrorCalloutProps {
error: Error;
errorInfo: Partial<React.ErrorInfo> | null;
name: string | null;
interface FatalPromptProps {
showErrorDetails: () => void;
onClickRefresh: () => void;
}
const CodePanel: React.FC<ErrorCalloutProps & { onClose: () => void }> = (props) => {
const CodePanel: React.FC<CodePanelProps> = (props) => {
const { error, errorInfo, name: errorComponentName, onClose } = props;
const simpleFlyoutTitleId = useGeneratedHtmlId({
prefix: 'simpleFlyoutTitle',
});
const errorName =
errorComponentName && strings.fatal.callout.details.componentName(errorComponentName);
const errorName = errorComponentName && strings.details.componentName(errorComponentName);
const errorTrace = errorInfo?.componentStack ?? error.stack ?? error.toString();
return (
@ -51,7 +48,7 @@ const CodePanel: React.FC<ErrorCalloutProps & { onClose: () => void }> = (props)
<EuiFlyoutHeader hasBorder>
<EuiPanel paddingSize="m" hasBorder={false} hasShadow={false}>
<EuiTitle size="m">
<h2>{strings.fatal.callout.details.title()}</h2>
<h2>{strings.details.title()}</h2>
</EuiTitle>
</EuiPanel>
</EuiFlyoutHeader>
@ -69,14 +66,14 @@ const CodePanel: React.FC<ErrorCalloutProps & { onClose: () => void }> = (props)
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} flush="left">
{strings.fatal.callout.details.closeButton()}
{strings.details.closeButton()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={errorName + '\n\n' + errorTrace}>
{(copy) => (
<EuiButton onClick={copy} fill iconType="copyClipboard">
{strings.fatal.callout.details.copyToClipboardButton()}
{strings.details.copyToClipboardButton()}
</EuiButton>
)}
</EuiCopy>
@ -88,18 +85,17 @@ const CodePanel: React.FC<ErrorCalloutProps & { onClose: () => void }> = (props)
);
};
export const FatalPrompt: React.FC<ErrorCalloutProps> = (props) => {
const { onClickRefresh } = props;
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
return (
export const FatalPrompt = withErrorDetails(
({ showErrorDetails, onClickRefresh }: FatalPromptProps): JSX.Element => (
<EuiEmptyPrompt
title={<h2 data-test-subj="errorBoundaryFatalHeader">{strings.fatal.callout.title()}</h2>}
title={
<h2 data-test-subj="errorBoundaryFatalHeader">{strings.page.callout.fatal.title()}</h2>
}
color="danger"
iconType="error"
body={
<>
<p data-test-subj="errorBoundaryFatalPromptBody">{strings.fatal.callout.body()}</p>
<p data-test-subj="errorBoundaryFatalPromptBody">{strings.page.callout.fatal.body()}</p>
<p>
<EuiButton
color="danger"
@ -108,41 +104,41 @@ export const FatalPrompt: React.FC<ErrorCalloutProps> = (props) => {
onClick={onClickRefresh}
data-test-subj="errorBoundaryFatalPromptReloadBtn"
>
{strings.fatal.callout.pageReloadButton()}
{strings.page.callout.fatal.pageReloadButton()}
</EuiButton>
</p>
<p>
<EuiLink
color="danger"
onClick={() => setIsFlyoutVisible(true)}
onClick={showErrorDetails}
data-test-subj="errorBoundaryFatalShowDetailsBtn"
>
{strings.fatal.callout.showDetailsButton()}
{strings.page.callout.fatal.showDetailsButton()}
</EuiLink>
{isFlyoutVisible ? (
<CodePanel {...props} onClose={() => setIsFlyoutVisible(false)} />
) : null}
</p>
</>
}
/>
);
};
)
);
export const RecoverablePrompt = (props: ErrorCalloutProps) => {
const { onClickRefresh } = props;
interface RecoverablePromptProps {
onClickRefresh: () => void;
}
export const RecoverablePrompt = ({ onClickRefresh }: RecoverablePromptProps) => {
return (
<EuiEmptyPrompt
title={
<h2 data-test-subj="errorBoundaryRecoverableHeader">
{strings.recoverable.callout.title()}
{strings.page.callout.recoverable.title()}
</h2>
}
color="warning"
iconType="warning"
body={
<p data-test-subj="errorBoundaryRecoverablePromptBody">
{strings.recoverable.callout.body()}
{strings.page.callout.recoverable.body()}
</p>
}
actions={
@ -153,9 +149,119 @@ export const RecoverablePrompt = (props: ErrorCalloutProps) => {
onClick={onClickRefresh}
data-test-subj="errorBoundaryRecoverablePromptReloadBtn"
>
{strings.recoverable.callout.pageReloadButton()}
{strings.page.callout.recoverable.pageReloadButton()}
</EuiButton>
}
/>
);
};
interface SectionFatalPromptProps {
sectionName: string;
showErrorDetails: () => void;
}
export const SectionFatalPrompt = withErrorDetails(
({ sectionName, showErrorDetails }: SectionFatalPromptProps): JSX.Element => {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<h2 data-test-subj="sectionErrorBoundaryPromptHeader">
{strings.section.callout.fatal.title(sectionName)}
</h2>
}
body={
<>
<p data-test-subj="sectionErrorBoundaryPromptBody">
{strings.section.callout.fatal.body(sectionName)}
</p>
<p>
<EuiLink color="danger" onClick={showErrorDetails}>
{strings.section.callout.fatal.showDetailsButton()}
</EuiLink>
</p>
</>
}
/>
);
}
);
interface SectionRecoverablePromptProps {
sectionName: string;
onClickRefresh: () => void;
}
export const SectionRecoverablePrompt = ({
sectionName,
onClickRefresh,
}: SectionRecoverablePromptProps): JSX.Element => {
return (
<EuiEmptyPrompt
color="warning"
iconType="warning"
title={
<h2 data-test-subj="sectionErrorBoundaryPromptHeader">
{strings.section.callout.recoverable.title(sectionName)}
</h2>
}
body={
<p data-test-subj="sectionErrorBoundaryPromptBody">
{strings.section.callout.recoverable.body(sectionName)}
</p>
}
actions={
<EuiButton
color="warning"
iconType="refresh"
fill={true}
onClick={onClickRefresh}
data-test-subj="sectionErrorBoundaryRecoverBtn"
>
{strings.section.callout.recoverable.pageReloadButton()}
</EuiButton>
}
/>
);
};
interface ErrorDetailsProps {
error: Error;
errorInfo: Partial<React.ErrorInfo> | null;
name: string | null;
}
interface ErrorPromptProps {
showErrorDetails: () => void;
}
function withErrorDetails<PromptComponentProps extends ErrorPromptProps = ErrorPromptProps>(
PromptComponent: React.FC<PromptComponentProps>
): React.FC<ErrorDetailsProps & Omit<PromptComponentProps, 'showErrorDetails'>> {
return ({ error, errorInfo, name, ...rest }) => {
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
return (
<>
<PromptComponent {...(rest as any)} showErrorDetails={() => setIsFlyoutVisible(true)} />
{isFlyoutVisible ? (
<CodePanel
error={error}
errorInfo={errorInfo}
name={name}
onClose={() => setIsFlyoutVisible(false)}
/>
) : null}
</>
);
};
}
interface CodePanelProps {
error: Error;
errorInfo: Partial<React.ErrorInfo> | null;
name: string | null;
onClose: () => void;
}

View file

@ -10,59 +10,95 @@
import { i18n } from '@kbn/i18n';
export const errorMessageStrings = {
fatal: {
page: {
callout: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.title', {
defaultMessage: 'Unable to load page',
}),
body: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.body', {
defaultMessage: 'Try refreshing the page to resolve the issue.',
}),
showDetailsButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.detailButton', {
defaultMessage: 'Show details',
}),
details: {
fatal: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.title', {
defaultMessage: 'Error details',
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.title', {
defaultMessage: 'Unable to load page',
}),
componentName: (errorComponentName: string) =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details', {
defaultMessage: 'The above error occurred in {name}:',
values: { name: errorComponentName },
body: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.body', {
defaultMessage: 'Try refreshing the page to resolve the issue.',
}),
closeButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.close', {
defaultMessage: 'Close',
showDetailsButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.detailButton', {
defaultMessage: 'Show details',
}),
copyToClipboardButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.copyToClipboard', {
defaultMessage: 'Copy error to clipboard',
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
},
recoverable: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.title', {
defaultMessage: 'Refresh the page',
}),
body: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.body', {
defaultMessage: 'This should resolve any issues loading the page.',
}),
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
},
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
},
},
recoverable: {
section: {
callout: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.title', {
defaultMessage: 'Refresh the page',
}),
body: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.body', {
defaultMessage: 'This should resolve any issues loading the page.',
}),
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
fatal: {
title: (sectionName: string) =>
i18n.translate('sharedUXPackages.section_error_boundary.fatal.prompt.title', {
defaultMessage: 'Unable to render {sectionName}',
values: { sectionName },
}),
body: (sectionName: string) =>
i18n.translate('sharedUXPackages.section_error_boundary.fatal.prompt.body', {
defaultMessage: 'An error happened while rendering {sectionName}.',
values: { sectionName },
}),
showDetailsButton: () =>
i18n.translate('sharedUXPackages.section_error_boundary.fatal.prompt.detailButton', {
defaultMessage: 'Show details',
}),
},
recoverable: {
title: (sectionName: string) =>
i18n.translate('sharedUXPackages.section_error_boundary.recoverable.prompt.title', {
defaultMessage: 'Unable to render {sectionName}',
values: { sectionName },
}),
body: (sectionName: string) =>
i18n.translate('sharedUXPackages.section_error_boundary.recoverable.prompt.body', {
defaultMessage: 'Refreshing should resolve any issues in {sectionName}.',
values: { sectionName },
}),
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
},
},
},
details: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.title', {
defaultMessage: 'Error details',
}),
componentName: (errorComponentName: string) =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details', {
defaultMessage: 'The above error occurred in {name}:',
values: { name: errorComponentName },
}),
closeButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.close', {
defaultMessage: 'Close',
}),
copyToClipboardButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.copyToClipboard', {
defaultMessage: 'Copy error to clipboard',
}),
},
};

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { render } from '@testing-library/react';
import React, { FC, PropsWithChildren } from 'react';
import { BadComponent, ChunkLoadErrorComponent, getServicesMock } from '../../mocks';
import { KibanaErrorBoundaryServices } from '../../types';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
import { KibanaErrorService } from '../services/error_service';
import { KibanaSectionErrorBoundary } from './section_error_boundary';
import { errorMessageStrings as strings } from './message_strings';
describe('<KibanaSectionErrorBoundary>', () => {
let services: KibanaErrorBoundaryServices;
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
services = getServicesMock();
});
const Template: FC<PropsWithChildren<unknown>> = ({ children }) => {
return (
<KibanaErrorBoundaryDepsProvider {...services}>
<KibanaSectionErrorBoundary sectionName="test section name">
{children}
</KibanaSectionErrorBoundary>
</KibanaErrorBoundaryDepsProvider>
);
};
it('allow children to render when there is no error', () => {
const inputText = 'Hello, beautiful world.';
const res = render(<Template>{inputText}</Template>);
expect(res.getByText(inputText)).toBeInTheDocument();
});
it('renders a recoverable prompt when a recoverable error is caught', () => {
const reloadSpy = jest.spyOn(services, 'onClickRefresh');
const { getByTestId, getByText } = render(
<Template>
<ChunkLoadErrorComponent />
</Template>
);
getByTestId('clickForErrorBtn').click();
expect(getByText(strings.section.callout.recoverable.title('test section name'))).toBeVisible();
expect(getByText(strings.section.callout.recoverable.body('test section name'))).toBeVisible();
expect(getByText(strings.section.callout.recoverable.pageReloadButton())).toBeVisible();
getByTestId('sectionErrorBoundaryRecoverBtn').click();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
it('renders a fatal prompt when a fatal error is caught', () => {
const { getByTestId, getByText } = render(
<Template>
<BadComponent />
</Template>
);
getByTestId('clickForErrorBtn').click();
expect(getByText(strings.section.callout.fatal.title('test section name'))).toBeVisible();
expect(getByText(strings.section.callout.fatal.body('test section name'))).toBeVisible();
expect(getByText(strings.section.callout.fatal.showDetailsButton())).toBeVisible();
});
it('captures the error event for telemetry', async () => {
const mockDeps = {
analytics: { reportEvent: jest.fn() },
};
services.errorService = new KibanaErrorService(mockDeps);
const { findByTestId } = render(
<Template>
<BadComponent />
</Template>
);
(await findByTestId('clickForErrorBtn')).click();
expect(mockDeps.analytics.reportEvent.mock.calls[0][0]).toBe('fatal-error-react');
expect(mockDeps.analytics.reportEvent.mock.calls[0][1]).toMatchObject({
component_name: 'BadComponent',
error_message: 'Error: This is an error to show the test user!',
});
});
it('captures component and error stack traces in telemetry', async () => {
const mockDeps = {
analytics: { reportEvent: jest.fn() },
};
services.errorService = new KibanaErrorService(mockDeps);
const { findByTestId } = render(
<Template>
<BadComponent />
</Template>
);
(await findByTestId('clickForErrorBtn')).click();
expect(
mockDeps.analytics.reportEvent.mock.calls[0][1].component_stack.includes('at BadComponent')
).toBe(true);
expect(
mockDeps.analytics.reportEvent.mock.calls[0][1].error_stack.startsWith(
'Error: This is an error to show the test user!'
)
).toBe(true);
});
});

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import type { KibanaErrorBoundaryServices } from '../../types';
import { useErrorBoundary } from '../services/error_boundary_services';
import { SectionFatalPrompt, SectionRecoverablePrompt } from './message_components';
interface SectionErrorBoundaryProps {
sectionName: string;
}
/**
* `KibanaSectionErrorBoundary` is designed to capture errors at a granular level.
*
* In general, it's best to use `KibanaErrorBoundary` and block the whole page.
* Users will see an error state on the page and think that there are instabilities in the system.
* They will be / should be wary about making any changes in a UI showing an error, since it risks
* further instability.
*
* If it is acceptable to assume the risk of allowing users to interact with a UI that
* has an error state, then using `KibanaSectionErrorBoundary` may be an acceptable alternative,
* but this must be judged on a case-by-case basis.
*/
export const KibanaSectionErrorBoundary = (
props: React.PropsWithChildren<SectionErrorBoundaryProps>
) => {
const services = useErrorBoundary();
return <SectionErrorBoundaryInternal {...props} services={services} />;
};
interface ErrorBoundaryState {
error: null | Error;
errorInfo: null | Partial<React.ErrorInfo>;
componentName: null | string;
isFatal: null | boolean;
}
interface ServiceContext {
services: KibanaErrorBoundaryServices;
}
class SectionErrorBoundaryInternal extends React.Component<
React.PropsWithChildren<SectionErrorBoundaryProps> & ServiceContext,
ErrorBoundaryState
> {
constructor(props: SectionErrorBoundaryProps & ServiceContext) {
super(props);
this.state = {
error: null,
errorInfo: null,
componentName: null,
isFatal: null,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('Error caught by Kibana React Section Error Boundary'); // eslint-disable-line no-console
console.error(error); // eslint-disable-line no-console
const { name, isFatal } = this.props.services.errorService.registerError(error, errorInfo);
this.setState({ error, errorInfo, componentName: name, isFatal });
}
render() {
if (!this.state.error) {
return this.props.children;
}
const { error, errorInfo, componentName, isFatal } = this.state;
if (isFatal) {
return (
<SectionFatalPrompt
sectionName={this.props.sectionName}
error={error}
errorInfo={errorInfo}
name={componentName}
/>
);
}
return (
<SectionRecoverablePrompt
sectionName={this.props.sectionName}
onClickRefresh={this.props.services.onClickRefresh}
/>
);
}
}

View file

@ -24,6 +24,7 @@ import {
useEuiTheme,
} from '@elastic/eui';
import type { EuiTabbedContentTab, EuiTabbedContentProps, EuiFlyoutProps } from '@elastic/eui';
import { KibanaSectionErrorBoundary } from '@kbn/shared-ux-error-boundary';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { RuleOverviewTab, useOverviewTabSections } from './rule_overview_tab';
@ -130,15 +131,47 @@ interface RuleDetailsFlyoutProps {
}
export function RuleDetailsFlyout({
id,
dataTestSubj,
...props
}: RuleDetailsFlyoutProps): JSX.Element {
const prebuiltRulesFlyoutTitleId = useGeneratedHtmlId({
prefix: 'prebuiltRulesFlyoutTitle',
});
return (
<EuiFlyout
id={id}
size={props.size}
onClose={props.closeFlyout}
key="prebuilt-rules-flyout"
paddingSize="l"
data-test-subj={dataTestSubj}
aria-labelledby={prebuiltRulesFlyoutTitleId}
ownFocus
>
<KibanaSectionErrorBoundary sectionName={i18n.RULE_DETAILS_FLYOUT_LABEL}>
<RuleDetailsFlyoutContent {...props} titleId={prebuiltRulesFlyoutTitleId} />
</KibanaSectionErrorBoundary>
</EuiFlyout>
);
}
const DEFAULT_EXTRA_TABS: EuiTabbedContentTab[] = [];
type RuleDetailsFlyoutContentProps = Omit<RuleDetailsFlyoutProps, 'id' | 'dataTestSubj'> & {
titleId?: string;
};
function RuleDetailsFlyoutContent({
rule,
ruleActions,
subHeader,
size = 'm',
extraTabs = [],
dataTestSubj,
id,
extraTabs = DEFAULT_EXTRA_TABS,
titleId,
closeFlyout,
}: RuleDetailsFlyoutProps): JSX.Element {
}: RuleDetailsFlyoutContentProps): JSX.Element {
const { expandedOverviewSections, toggleOverviewSection } = useOverviewTabSections();
const overviewTab: EuiTabbedContentTab = useMemo(
@ -198,24 +231,11 @@ export function RuleDetailsFlyout({
setSelectedTabId(tab.id);
};
const prebuiltRulesFlyoutTitleId = useGeneratedHtmlId({
prefix: 'prebuiltRulesFlyoutTitle',
});
return (
<EuiFlyout
id={id}
size={size}
onClose={closeFlyout}
key="prebuilt-rules-flyout"
paddingSize="l"
data-test-subj={dataTestSubj}
aria-labelledby={prebuiltRulesFlyoutTitleId}
ownFocus
>
<>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id={prebuiltRulesFlyoutTitleId}>{rule.name}</h2>
<h2 id={titleId}>{rule.name}</h2>
</EuiTitle>
<EuiSpacer size="s" />
{subHeader && (
@ -242,6 +262,6 @@ export function RuleDetailsFlyout({
<EuiFlexItem grow={false}>{ruleActions}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</>
);
}

View file

@ -9,6 +9,7 @@ import React, { useState, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import { isEqual } from 'lodash';
import usePrevious from 'react-use/lib/usePrevious';
import { KibanaSectionErrorBoundary } from '@kbn/shared-ux-error-boundary';
import { VersionsPicker, VersionsPickerOptionEnum } from './versions_picker/versions_picker';
import { FieldUpgradeSideHeader } from '../field_upgrade_side_header';
import { useFieldUpgradeContext } from '../rule_upgrade/field_upgrade_context';
@ -77,7 +78,9 @@ export function FieldComparisonSide(): JSX.Element {
</EuiFlexItem>
</EuiFlexGroup>
</FieldUpgradeSideHeader>
<SubfieldChanges fieldName={fieldName} subfieldChanges={subfieldChanges} />
<KibanaSectionErrorBoundary sectionName={i18n.TITLE}>
<SubfieldChanges fieldName={fieldName} subfieldChanges={subfieldChanges} />
</KibanaSectionErrorBoundary>
</>
);
}

View file

@ -7,6 +7,7 @@
import React from 'react';
import { EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
import { KibanaSectionErrorBoundary } from '@kbn/shared-ux-error-boundary';
import { FieldFinalReadOnly } from '../../final_readonly';
import { FieldFinalEdit } from '../../final_edit';
import { assertUnreachable } from '../../../../../../../../common/utility_types';
@ -28,7 +29,12 @@ export function FieldFinalSideContent(): JSX.Element {
{i18n.EDIT}
</EuiButtonEmpty>
</EuiFlexGroup>
<FieldFinalReadOnly />
<KibanaSectionErrorBoundary
key="rule-field-readonly-view"
sectionName={i18n.READONLY_MODE}
>
<FieldFinalReadOnly />
</KibanaSectionErrorBoundary>
</>
);
case FieldFinalSideMode.Edit:
@ -39,7 +45,9 @@ export function FieldFinalSideContent(): JSX.Element {
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexGroup>
<FieldFinalEdit />
<KibanaSectionErrorBoundary key="rule-field-editing-view" sectionName={i18n.EDIT_MODE}>
<FieldFinalEdit />
</KibanaSectionErrorBoundary>
</>
);
default:

View file

@ -48,3 +48,17 @@ export const EDIT = i18n.translate(
defaultMessage: 'Edit',
}
);
export const READONLY_MODE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.readonlyMode',
{
defaultMessage: 'Field view',
}
);
export const EDIT_MODE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.editMode',
{
defaultMessage: 'Field editing view',
}
);

View file

@ -7,6 +7,13 @@
import { i18n } from '@kbn/i18n';
export const RULE_DETAILS_FLYOUT_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.label',
{
defaultMessage: 'Rule details',
}
);
export const OVERVIEW_TAB_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.overviewTabLabel',
{

View file

@ -15,11 +15,7 @@
"public/**/*.json",
"../../../../../typings/**/*"
],
"exclude": [
"target/**/*",
"**/cypress/**",
"public/management/cypress.config.ts"
],
"exclude": ["target/**/*", "**/cypress/**", "public/management/cypress.config.ts"],
"kbn_references": [
"@kbn/core",
{
@ -238,6 +234,7 @@
"@kbn/ai-assistant-icon",
"@kbn/llm-tasks-plugin",
"@kbn/charts-theme",
"@kbn/product-doc-base-plugin"
"@kbn/product-doc-base-plugin",
"@kbn/shared-ux-error-boundary"
]
}