[Security solution] Reinstall product documentation callout (#205975)

This commit is contained in:
Steph Milovic 2025-01-10 08:32:01 -07:00 committed by GitHub
parent 54436e3c1c
commit ac4577159e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 460 additions and 40 deletions

View file

@ -0,0 +1,11 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const REACT_QUERY_KEYS = {
GET_PRODUCT_DOC_STATUS: 'get_product_doc_status',
INSTALL_PRODUCT_DOC: 'install_product_doc',
};

View file

@ -0,0 +1,62 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { waitFor, renderHook } from '@testing-library/react';
import { useGetProductDocStatus } from './use_get_product_doc_status';
import { useAssistantContext } from '../../../..';
import { TestProviders } from '../../../mock/test_providers/test_providers';
jest.mock('../../../..', () => ({
useAssistantContext: jest.fn(),
}));
describe('useGetProductDocStatus', () => {
const mockGetStatus = jest.fn();
beforeEach(() => {
(useAssistantContext as jest.Mock).mockReturnValue({
productDocBase: {
installation: {
getStatus: mockGetStatus,
},
},
});
});
it('returns loading state initially', async () => {
mockGetStatus.mockResolvedValueOnce('status');
const { result } = renderHook(() => useGetProductDocStatus(), {
wrapper: TestProviders,
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => result.current.isSuccess);
});
it('returns success state with data', async () => {
mockGetStatus.mockResolvedValueOnce('status');
const { result } = renderHook(() => useGetProductDocStatus(), {
wrapper: TestProviders,
});
await waitFor(() => {
expect(result.current.status).toBe('status');
expect(result.current.isSuccess).toBe(true);
});
});
it('returns error state when query fails', async () => {
mockGetStatus.mockRejectedValueOnce(new Error('error'));
const { result } = renderHook(() => useGetProductDocStatus(), {
wrapper: TestProviders,
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});

View file

@ -0,0 +1,32 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { REACT_QUERY_KEYS } from './const';
import { useAssistantContext } from '../../../..';
export function useGetProductDocStatus() {
const { productDocBase } = useAssistantContext();
const { isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery({
queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS],
queryFn: async () => {
return productDocBase.installation.getStatus();
},
keepPreviousData: false,
refetchOnWindowFocus: false,
});
return {
status: data,
refetch,
isLoading,
isRefetching,
isSuccess,
isError,
};
}

View file

@ -0,0 +1,67 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { waitFor, renderHook } from '@testing-library/react';
import { useInstallProductDoc } from './use_install_product_doc';
import { useAssistantContext } from '../../../..';
import { TestProviders } from '../../../mock/test_providers/test_providers';
jest.mock('../../../..', () => ({
useAssistantContext: jest.fn(),
}));
describe('useInstallProductDoc', () => {
const mockInstall = jest.fn();
const mockAddSuccess = jest.fn();
const mockAddError = jest.fn();
beforeEach(() => {
(useAssistantContext as jest.Mock).mockReturnValue({
productDocBase: {
installation: {
install: mockInstall,
},
},
toasts: {
addSuccess: mockAddSuccess,
addError: mockAddError,
},
});
});
it('returns success state and shows success toast on successful installation', async () => {
mockInstall.mockResolvedValueOnce({});
const { result } = renderHook(() => useInstallProductDoc(), {
wrapper: TestProviders,
});
result.current.mutate();
await waitFor(() => result.current.isSuccess);
expect(mockAddSuccess).toHaveBeenCalledWith(
'The Elastic documentation was successfully installed'
);
});
it('returns error state and shows error toast on failed installation', async () => {
const error = new Error('error message');
mockInstall.mockRejectedValueOnce(error);
const { result } = renderHook(() => useInstallProductDoc(), {
wrapper: TestProviders,
});
result.current.mutate();
await waitFor(() => result.current.isError);
expect(mockAddError).toHaveBeenCalledWith(
expect.objectContaining({
message: 'error message',
}),
{ title: 'Something went wrong while installing the Elastic documentation' }
);
});
});

View file

@ -0,0 +1,48 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import type { PerformInstallResponse } from '@kbn/product-doc-base-plugin/common/http_api/installation';
import { REACT_QUERY_KEYS } from './const';
import { useAssistantContext } from '../../../..';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export function useInstallProductDoc() {
const { productDocBase, toasts } = useAssistantContext();
const queryClient = useQueryClient();
return useMutation<PerformInstallResponse, ServerError, void>(
[REACT_QUERY_KEYS.INSTALL_PRODUCT_DOC],
() => {
return productDocBase.installation.install();
},
{
onSuccess: () => {
toasts?.addSuccess(
i18n.translate('xpack.elasticAssistant.kb.installProductDoc.successNotification', {
defaultMessage: 'The Elastic documentation was successfully installed',
})
);
queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS],
refetchType: 'all',
});
},
onError: (error) => {
toasts?.addError(new Error(error.body?.message ?? error.message), {
title: i18n.translate('xpack.elasticAssistant.kb.installProductDoc.errorNotification', {
defaultMessage: 'Something went wrong while installing the Elastic documentation',
}),
});
},
}
);
}

View file

@ -0,0 +1,87 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProductDocumentationManagement } from '.';
import * as i18n from './translations';
import { useInstallProductDoc } from '../../api/product_docs/use_install_product_doc';
import { useGetProductDocStatus } from '../../api/product_docs/use_get_product_doc_status';
jest.mock('../../api/product_docs/use_install_product_doc');
jest.mock('../../api/product_docs/use_get_product_doc_status');
describe('ProductDocumentationManagement', () => {
const mockInstallProductDoc = jest.fn().mockResolvedValue({});
beforeEach(() => {
(useInstallProductDoc as jest.Mock).mockReturnValue({ mutateAsync: mockInstallProductDoc });
(useGetProductDocStatus as jest.Mock).mockReturnValue({ status: null, isLoading: false });
jest.clearAllMocks();
});
it('renders loading spinner when status is loading', async () => {
(useGetProductDocStatus as jest.Mock).mockReturnValue({
status: { overall: 'not_installed' },
isLoading: true,
});
render(<ProductDocumentationManagement />);
expect(screen.getByTestId('statusLoading')).toBeInTheDocument();
});
it('renders install button when not installed', () => {
(useGetProductDocStatus as jest.Mock).mockReturnValue({
status: { overall: 'not_installed' },
isLoading: false,
});
render(<ProductDocumentationManagement />);
expect(screen.getByText(i18n.INSTALL)).toBeInTheDocument();
});
it('does not render anything when already installed', () => {
(useGetProductDocStatus as jest.Mock).mockReturnValue({
status: { overall: 'installed' },
isLoading: false,
});
const { container } = render(<ProductDocumentationManagement />);
expect(container).toBeEmptyDOMElement();
});
it('shows installing spinner and text when installing', async () => {
(useGetProductDocStatus as jest.Mock).mockReturnValue({
status: { overall: 'not_installed' },
isLoading: false,
});
render(<ProductDocumentationManagement />);
fireEvent.click(screen.getByText(i18n.INSTALL));
await waitFor(() => {
expect(screen.getByTestId('installing')).toBeInTheDocument();
expect(screen.getByText(i18n.INSTALLING)).toBeInTheDocument();
});
});
it('sets installed state to true after successful installation', async () => {
(useGetProductDocStatus as jest.Mock).mockReturnValue({
status: { overall: 'not_installed' },
isLoading: false,
});
mockInstallProductDoc.mockResolvedValueOnce({});
render(<ProductDocumentationManagement />);
fireEvent.click(screen.getByText(i18n.INSTALL));
await waitFor(() => expect(screen.queryByText(i18n.INSTALL)).not.toBeInTheDocument());
});
it('sets installed state to false after failed installation', async () => {
(useGetProductDocStatus as jest.Mock).mockReturnValue({
status: { overall: 'not_installed' },
isLoading: false,
});
mockInstallProductDoc.mockRejectedValueOnce(new Error('Installation failed'));
render(<ProductDocumentationManagement />);
fireEvent.click(screen.getByText(i18n.INSTALL));
await waitFor(() => expect(screen.getByText(i18n.INSTALL)).toBeInTheDocument());
});
});

View file

@ -0,0 +1,94 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useInstallProductDoc } from '../../api/product_docs/use_install_product_doc';
import { useGetProductDocStatus } from '../../api/product_docs/use_get_product_doc_status';
import * as i18n from './translations';
export const ProductDocumentationManagement: React.FC = React.memo(() => {
const [{ isInstalled, isInstalling }, setState] = useState({
isInstalled: true,
isInstalling: false,
});
const { mutateAsync: installProductDoc } = useInstallProductDoc();
const { status, isLoading: isStatusLoading } = useGetProductDocStatus();
useEffect(() => {
if (status) {
setState((prevState) => ({
...prevState,
isInstalled: status.overall === 'installed',
}));
}
}, [status]);
const onClickInstall = useCallback(async () => {
setState((prevState) => ({ ...prevState, isInstalling: true }));
try {
await installProductDoc();
setState({ isInstalled: true, isInstalling: false });
} catch {
setState({ isInstalled: false, isInstalling: false });
}
}, [installProductDoc]);
const content = useMemo(() => {
if (isStatusLoading) {
return <EuiLoadingSpinner data-test-subj="statusLoading" size="m" />;
}
if (isInstalling) {
return (
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
<EuiLoadingSpinner size="m" data-test-subj="installing" />
<EuiText size="s">{i18n.INSTALLING}</EuiText>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
fill
data-test-subj="settingsTabInstallProductDocButton"
onClick={onClickInstall}
>
{i18n.INSTALL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [isInstalling, isStatusLoading, onClickInstall]);
if (isInstalled) {
return null;
}
return (
<>
<EuiCallOut title={i18n.LABEL} iconType="iInCircle">
<EuiText size="m">
<span>{i18n.DESCRIPTION}</span>
</EuiText>
<EuiSpacer size="m" />
{content}
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
});
ProductDocumentationManagement.displayName = 'ProductDocumentationManagement';

View file

@ -0,0 +1,29 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const LABEL = i18n.translate('xpack.elasticAssistant.assistant.settings.productDocLabel', {
defaultMessage: 'Elastic documentation is not installed',
});
export const DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.settings.productDocDescription',
{
defaultMessage:
'The Elastic Documentation has been uninstalled. Please reinstall to ensure the most accurate results from the AI Assistant.',
}
);
export const INSTALL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.installProductDocButtonLabel',
{ defaultMessage: 'Install' }
);
export const INSTALLING = i18n.translate(
'xpack.elasticAssistant.assistant.settings.installingText',
{ defaultMessage: 'Installing...' }
);

View file

@ -15,6 +15,7 @@ import useSessionStorage from 'react-use/lib/useSessionStorage';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { ChromeStart, NavigateToAppOptions, UserProfileService } from '@kbn/core/public';
import type { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public';
import { useQuery } from '@tanstack/react-query';
import { updatePromptContexts } from './helpers';
import type {
@ -78,6 +79,7 @@ export interface AssistantProviderProps {
title?: string;
toasts?: IToasts;
currentAppId: string;
productDocBase: ProductDocBasePluginStart;
userProfileService: UserProfileService;
chrome: ChromeStart;
}
@ -131,6 +133,7 @@ export interface UseAssistantContext {
unRegisterPromptContext: UnRegisterPromptContext;
currentAppId: string;
codeBlockRef: React.MutableRefObject<(codeBlock: string) => void>;
productDocBase: ProductDocBasePluginStart;
userProfileService: UserProfileService;
chrome: ChromeStart;
}
@ -153,6 +156,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
baseConversations,
navigateToApp,
nameSpace = DEFAULT_ASSISTANT_NAMESPACE,
productDocBase,
title = DEFAULT_ASSISTANT_TITLE,
toasts,
currentAppId,
@ -291,6 +295,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
promptContexts,
navigateToApp,
nameSpace,
productDocBase,
registerPromptContext,
selectedSettingsTab,
// can be undefined from localStorage, if not defined, default to true
@ -331,6 +336,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
promptContexts,
navigateToApp,
nameSpace,
productDocBase,
registerPromptContext,
selectedSettingsTab,
localStorageStreaming,

View file

@ -31,6 +31,7 @@ import {
import { css } from '@emotion/react';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
import useAsync from 'react-use/lib/useAsync';
import { ProductDocumentationManagement } from '../../assistant/settings/product_documentation';
import { KnowledgeBaseTour } from '../../tour/knowledge_base';
import { AlertsSettingsManagement } from '../../assistant/settings/alerts_settings/alerts_settings_management';
import { useKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_knowledge_base_entries';
@ -332,6 +333,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
return (
<>
<ProductDocumentationManagement />
<EuiPanel hasShadow={false} hasBorder paddingSize="l">
<EuiText size={'m'}>
<FormattedMessage

View file

@ -88,6 +88,9 @@ export const TestProvidersComponent: React.FC<Props> = ({
navigateToApp={mockNavigateToApp}
{...providerContext}
currentAppId={'test'}
productDocBase={{
installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() },
}}
userProfileService={jest.fn() as unknown as UserProfileService}
chrome={chrome}
>

View file

@ -37,5 +37,6 @@
"@kbn/core-chrome-browser-mocks",
"@kbn/core-chrome-browser",
"@kbn/ai-assistant-icon",
"@kbn/product-doc-base-plugin",
]
}

View file

@ -88,6 +88,9 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
http={mockHttp}
baseConversations={{}}
navigateToApp={mockNavigateToApp}
productDocBase={{
installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() },
}}
currentAppId={'securitySolutionUI'}
userProfileService={jest.fn() as unknown as UserProfileService}
chrome={chrome}

View file

@ -59,7 +59,8 @@
"charts",
"entityManager",
"inference",
"discoverShared"
"discoverShared",
"productDocBase"
],
"optionalPlugins": [
"encryptedSavedObjects",

View file

@ -144,6 +144,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
userProfile,
chrome,
productDocBase,
} = useKibana().services;
let inferenceEnabled = false;
@ -235,6 +236,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
http={http}
inferenceEnabled={inferenceEnabled}
navigateToApp={navigateToApp}
productDocBase={productDocBase}
title={ASSISTANT_TITLE}
toasts={toasts}
currentAppId={currentAppId ?? 'securitySolutionUI'}

View file

@ -61,6 +61,9 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({
navigateToApp={mockNavigateToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'test'}
productDocBase={{
installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() },
}}
userProfileService={mockUserProfileService}
chrome={chrome}
>

View file

@ -12,15 +12,10 @@ import { render } from '@testing-library/react';
import type { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring';
import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring';
import { RuleStatusFailedCallOut } from './rule_status_failed_callout';
import { AssistantProvider } from '@kbn/elastic-assistant';
import type { AssistantAvailability } from '@kbn/elastic-assistant';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BASE_SECURITY_CONVERSATIONS } from '../../../../assistant/content/conversations';
import type { UserProfileService } from '@kbn/core-user-profile-browser';
import { chromeServiceMock } from '@kbn/core/public/mocks';
import { of } from 'rxjs';
import { MockAssistantProviderComponent } from '../../../../common/mock/mock_assistant_provider';
jest.mock('../../../../common/lib/kibana');
@ -28,18 +23,6 @@ const TEST_ID = 'ruleStatusFailedCallOut';
const DATE = '2022-01-27T15:03:31.176Z';
const MESSAGE = 'This rule is attempting to query data but...';
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockGetComments = jest.fn(() => []);
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
const mockNavigationToApp = jest.fn();
const mockAssistantAvailability: AssistantAvailability = {
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@ -58,25 +41,7 @@ const ContextWrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
chrome.getChromeStyle$.mockReturnValue(of('classic'));
return (
<QueryClientProvider client={queryClient}>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
assistantAvailability={mockAssistantAvailability}
augmentMessageCodeBlocks={jest.fn()}
basePath={'https://localhost:5601/kbn'}
docLinks={{
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'current',
}}
getComments={mockGetComments}
http={mockHttp}
navigateToApp={mockNavigationToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'security'}
userProfileService={jest.fn() as unknown as UserProfileService}
chrome={chrome}
>
{children}
</AssistantProvider>
<MockAssistantProviderComponent>{children}</MockAssistantProviderComponent>
</QueryClientProvider>
);
};

View file

@ -153,6 +153,7 @@ export class PluginServices {
customDataService,
timelineDataService,
topValuesPopover: new TopValuesPopoverService(),
productDocBase: startPlugins.productDocBase,
siemMigrations: await createSiemMigrationsService(coreStart, startPlugins),
...(params && {
onAppLeave: params.onAppLeave,

View file

@ -21,6 +21,7 @@ import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { FleetStart } from '@kbn/fleet-plugin/public';
import type { PluginStart as ListsPluginStart } from '@kbn/lists-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public';
import type {
TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup,
TriggersAndActionsUIPublicPluginStart as TriggersActionsStart,
@ -161,6 +162,7 @@ export interface StartPlugins {
core: CoreStart;
integrationAssistant?: IntegrationAssistantPluginStart;
serverless?: ServerlessPluginStart;
productDocBase: ProductDocBasePluginStart;
}
export interface StartPluginsDependencies extends StartPlugins {
@ -198,6 +200,7 @@ export type StartServices = CoreStart &
topValuesPopover: TopValuesPopoverService;
timelineDataService: DataPublicPluginStart;
siemMigrations: SiemMigrationsService;
productDocBase: ProductDocBasePluginStart;
};
export type StartRenderServices = Pick<

View file

@ -225,7 +225,6 @@
"@kbn/core-saved-objects-server-mocks",
"@kbn/core-security-server-mocks",
"@kbn/serverless",
"@kbn/core-user-profile-browser",
"@kbn/data-stream-adapter",
"@kbn/core-lifecycle-server",
"@kbn/core-user-profile-common",
@ -237,6 +236,7 @@
"@kbn/core-chrome-browser-mocks",
"@kbn/ai-assistant-icon",
"@kbn/llm-tasks-plugin",
"@kbn/charts-theme"
"@kbn/charts-theme",
"@kbn/product-doc-base-plugin"
]
}