mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:**  **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:
parent
b0973cf26c
commit
b4342f44f0
16 changed files with 573 additions and 124 deletions
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue