From b323923e65af29416fc820fa4e7c8282d16f8ecc Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 12 Jul 2023 01:50:10 -0600 Subject: [PATCH 01/20] [Security Solution] [Elastic AI Assistant] Consolidates settings into a single modal (#160468) ## Summary This PR fixes the disjointed settings across the assistant by combining them all into a single settings modal. It also resolves the Connector `Model` configuration not being available when using the `OpenAI` variant of the GenAI Connector. Additional issues resolved: - [x] Clearing conversation doesn't restore default system prompt - [X] Double repeated welcome prompt - [X] Clicking skip button broken Resolves: https://github.com/elastic/security-team/issues/7110 Resolves: https://github.com/elastic/kibana/pull/161039#pullrequestreview-1517129764 Resolves: https://github.com/elastic/kibana/pull/161027#pullrequestreview-1523018176 #### Conversations

#### Quick Prompts

#### System Prompts

#### Anonymization

### Checklist Delete any items that are not applicable to this PR. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../impl/assistant/api.tsx | 3 +- .../assistant/assistant_overlay/index.tsx | 2 +- .../impl/assistant/constants.ts | 1 + .../conversation_settings_popover.tsx | 129 ------- .../conversation_selector/index.tsx | 41 +-- .../conversation_selector/translations.ts | 2 +- .../conversation_selector_settings/index.tsx | 251 ++++++++++++++ .../translations.ts | 57 ++++ .../conversation_settings.tsx | 265 +++++++++++++++ .../conversation_settings/helpers.tsx | 33 ++ .../conversation_settings/translations.ts | 43 +++ .../impl/assistant/helpers.ts | 27 ++ .../impl/assistant/index.test.tsx | 4 +- .../impl/assistant/index.tsx | 122 +++---- .../assistant/prompt_editor/index.test.tsx | 4 +- .../system_prompt/index.test.tsx | 33 +- .../prompt_editor/system_prompt/index.tsx | 32 +- .../select_system_prompt/index.test.tsx | 10 + .../select_system_prompt/index.tsx | 77 ++--- .../conversation_multi_selector.tsx | 13 +- .../system_prompt_modal.tsx | 272 --------------- .../system_prompt_selector.tsx | 8 +- .../system_prompt_selector/translations.ts | 2 +- .../system_prompt_settings.tsx | 264 +++++++++++++++ .../system_prompt_modal/translations.ts | 25 +- .../add_quick_prompt_modal.tsx | 229 ------------- .../prompt_context_selector.tsx | 6 +- .../quick_prompt_selector.tsx | 12 +- .../quick_prompt_selector/translations.ts | 2 +- .../quick_prompt_settings.tsx | 235 +++++++++++++ .../translations.ts | 32 +- .../assistant/quick_prompts/quick_prompts.tsx | 28 +- .../assistant/quick_prompts/translations.ts | 6 + .../advanced_settings/advanced_settings.tsx | 45 +++ .../advanced_settings/translations.ts | 21 ++ .../assistant/settings/assistant_settings.tsx | 315 ++++++++++++++++++ .../settings/assistant_settings_button.tsx | 74 ++++ .../impl/assistant/settings/translations.ts | 85 +++++ .../use_settings_updater.tsx | 108 ++++++ .../impl/assistant/streaming_text/index.tsx | 7 +- .../impl/assistant/translations.ts | 40 --- .../use_conversation/sample_conversations.tsx | 5 - .../impl/assistant_context/index.tsx | 21 +- .../impl/assistant_context/types.tsx | 1 + .../connectorland/connector_button/index.tsx | 10 +- .../connector_missing_callout/index.tsx | 58 ++++ .../connector_selector/index.tsx | 59 ++-- .../connectorland/connector_setup/index.tsx | 43 ++- .../models/model_selector/model_selector.tsx | 109 ++++++ .../models/model_selector/translations.ts | 35 ++ .../impl/connectorland/translations.ts | 14 + .../anonymization_settings/index.test.tsx | 73 +--- .../settings/anonymization_settings/index.tsx | 125 +++---- .../anonymization_settings/translations.ts | 13 +- .../index.test.tsx | 42 --- .../anonymization_settings_modal/index.tsx | 26 -- .../translations.ts | 29 -- .../settings/settings_popover/index.test.tsx | 39 --- .../settings/settings_popover/index.tsx | 90 ----- .../settings/settings_popover/translations.ts | 22 -- .../context_editor/index.tsx | 19 +- .../context_editor/toolbar/index.test.tsx | 1 + .../context_editor/toolbar/index.tsx | 24 ++ .../context_editor/translations.ts | 6 + .../content/prompts/system/translations.ts | 2 +- .../timeline/tabs_content/translations.ts | 9 +- 66 files changed, 2469 insertions(+), 1371 deletions(-) delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_settings_popover/conversation_settings_popover.tsx rename x-pack/packages/kbn-elastic-assistant/impl/assistant/{ => conversations}/conversation_selector/index.tsx (89%) rename x-pack/packages/kbn-elastic-assistant/impl/assistant/{ => conversations}/conversation_selector/translations.ts (97%) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/translations.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_modal.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx rename x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/{add_quick_prompt_modal => quick_prompt_settings}/translations.ts (54%) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/advanced_settings.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/model_selector.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/translations.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx index 798da471e32d8c..0bb68409caf917 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx @@ -11,6 +11,7 @@ import { HttpSetup } from '@kbn/core-http-browser'; import type { Message } from '../assistant_context/types'; import { Conversation } from '../assistant_context/types'; import { API_ERROR } from './translations'; +import { MODEL_GPT_3_5_TURBO } from '../connectorland/models/model_selector/model_selector'; export interface FetchConnectorExecuteAction { apiConfig: Conversation['apiConfig']; @@ -33,7 +34,7 @@ export const fetchConnectorExecuteAction = async ({ const body = apiConfig?.provider === OpenAiProviderType.OpenAi ? { - model: 'gpt-3.5-turbo', + model: apiConfig.model ?? MODEL_GPT_3_5_TURBO, messages: outboundMessages, n: 1, stop: null, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx index 32f0b48aa2a230..5a25c01468dee9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -60,7 +60,7 @@ export const AssistantOverlay = React.memo(({ isAssistantEnabled }) => { const handleShortcutPress = useCallback(() => { // Try to restore the last conversation on shortcut pressed if (!isModalVisible) { - setConversationId(localStorageLastConversationId || WELCOME_CONVERSATION_TITLE); + setConversationId(localStorageLastConversationId ?? WELCOME_CONVERSATION_TITLE); } setIsModalVisible(!isModalVisible); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/constants.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/constants.ts index 7d4d7f97c207ce..37ed0356cc697c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/constants.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/constants.ts @@ -11,6 +11,7 @@ export const TEST_IDS = { ADD_SYSTEM_PROMPT: 'addSystemPrompt', PROMPT_SUPERSELECT: 'promptSuperSelect', CONVERSATIONS_MULTISELECTOR_OPTION: (id: string) => `conversationMultiSelectorOption-${id}`, + SETTINGS_MODAL: 'settingsModal', SYSTEM_PROMPT_MODAL: { ID: 'systemPromptModal', PROMPT_TEXT: 'systemPromptModalPromptText', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_settings_popover/conversation_settings_popover.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_settings_popover/conversation_settings_popover.tsx deleted file mode 100644 index 6c6c8c598f2703..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_settings_popover/conversation_settings_popover.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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 { - EuiButtonIcon, - EuiFormRow, - EuiPopover, - EuiPopoverTitle, - EuiLink, - EuiToolTip, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; - -import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; -import { HttpSetup } from '@kbn/core-http-browser'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; -import { Conversation, Prompt } from '../../..'; -import * as i18n from '../translations'; -import { ConnectorSelector } from '../../connectorland/connector_selector'; -import { SelectSystemPrompt } from '../prompt_editor/system_prompt/select_system_prompt'; - -export interface ConversationSettingsPopoverProps { - actionTypeRegistry: ActionTypeRegistryContract; - conversation: Conversation; - http: HttpSetup; - isDisabled?: boolean; - allSystemPrompts: Prompt[]; -} - -export const ConversationSettingsPopover: React.FC = React.memo( - ({ actionTypeRegistry, conversation, http, isDisabled = false, allSystemPrompts }) => { - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - // So we can hide the settings popover when the connector modal is displayed - const popoverPanelRef = useRef(null); - - const provider = useMemo(() => { - return conversation.apiConfig?.provider; - }, [conversation.apiConfig]); - - const selectedPrompt: Prompt | undefined = useMemo(() => { - const convoDefaultSystemPromptId = conversation?.apiConfig.defaultSystemPromptId; - if (convoDefaultSystemPromptId && allSystemPrompts) { - return allSystemPrompts.find((prompt) => prompt.id === convoDefaultSystemPromptId); - } - return allSystemPrompts.find((prompt) => prompt.isNewConversationDefault); - }, [conversation, allSystemPrompts]); - - const closeSettingsHandler = useCallback(() => { - setIsSettingsOpen(false); - }, []); - - // Hide settings panel when modal is visible (to keep visual clutter minimal) - const onDescendantModalVisibilityChange = useCallback((isVisible: boolean) => { - if (popoverPanelRef.current) { - popoverPanelRef.current.style.visibility = isVisible ? 'hidden' : 'visible'; - } - }, []); - - return ( - - setIsSettingsOpen(!isSettingsOpen)} - iconType="controlsVertical" - aria-label={i18n.SETTINGS_TITLE} - data-test-subj="assistant-settings-button" - /> - - } - isOpen={isSettingsOpen} - closePopover={closeSettingsHandler} - anchorPosition="rightCenter" - panelRef={(el) => (popoverPanelRef.current = el)} - > - {i18n.SETTINGS_TITLE} -
- - - - } - > - - - - {provider === OpenAiProviderType.OpenAi && <>} - - - - -
-
- ); - } -); -ConversationSettingsPopover.displayName = 'ConversationSettingsPopover'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx similarity index 89% rename from x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/index.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx index 37d292152c4961..77e0c703ea4e56 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx @@ -20,20 +20,20 @@ import useEvent from 'react-use/lib/useEvent'; import { css } from '@emotion/react'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; -import { Conversation } from '../../..'; -import { useAssistantContext } from '../../assistant_context'; +import { Conversation } from '../../../..'; +import { useAssistantContext } from '../../../assistant_context'; import * as i18n from './translations'; -import { DEFAULT_CONVERSATION_TITLE } from '../use_conversation/translations'; -import { useConversation } from '../use_conversation'; -import { SystemPromptSelectorOption } from '../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector'; +import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations'; +import { useConversation } from '../../use_conversation'; +import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; interface Props { - conversationId?: string; defaultConnectorId?: string; defaultProvider?: OpenAiProviderType; - onSelectionChange?: (value: string) => void; + selectedConversationId: string | undefined; + setSelectedConversationId: React.Dispatch>; shouldDisableKeyboardShortcut?: () => boolean; isDisabled?: boolean; } @@ -56,17 +56,16 @@ export type ConversationSelectorOption = EuiComboBoxOptionOption<{ export const ConversationSelector: React.FC = React.memo( ({ - conversationId = DEFAULT_CONVERSATION_TITLE, + selectedConversationId = DEFAULT_CONVERSATION_TITLE, defaultConnectorId, defaultProvider, - onSelectionChange, + setSelectedConversationId, shouldDisableKeyboardShortcut = () => false, isDisabled = false, }) => { const { allSystemPrompts } = useAssistantContext(); const { deleteConversation, setConversation } = useConversation(); - const [selectedConversationId, setSelectedConversationId] = useState(conversationId); const { conversations } = useAssistantContext(); const conversationIds = useMemo(() => Object.keys(conversations), [conversations]); @@ -112,7 +111,13 @@ export const ConversationSelector: React.FC = React.memo( } setSelectedConversationId(searchValue); }, - [allSystemPrompts, defaultConnectorId, defaultProvider, setConversation] + [ + allSystemPrompts, + defaultConnectorId, + defaultProvider, + setConversation, + setSelectedConversationId, + ] ); // Callback for when user deletes a conversation @@ -124,32 +129,29 @@ export const ConversationSelector: React.FC = React.memo( setTimeout(() => { deleteConversation(cId); }, 0); - // onSystemPromptDeleted(cId); }, - [conversationIds, deleteConversation, selectedConversationId] + [conversationIds, deleteConversation, selectedConversationId, setSelectedConversationId] ); const onChange = useCallback( (newOptions: ConversationSelectorOption[]) => { if (newOptions.length === 0) { setSelectedOptions([]); - // handleSelectionChange([]); } else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) { setSelectedConversationId(newOptions?.[0].label); } - // setSelectedConversationId(value ?? DEFAULT_CONVERSATION_TITLE); }, - [conversationOptions] + [conversationOptions, setSelectedConversationId] ); const onLeftArrowClick = useCallback(() => { const prevId = getPreviousConversationId(conversationIds, selectedConversationId); setSelectedConversationId(prevId); - }, [conversationIds, selectedConversationId]); + }, [conversationIds, selectedConversationId, setSelectedConversationId]); const onRightArrowClick = useCallback(() => { const nextId = getNextConversationId(conversationIds, selectedConversationId); setSelectedConversationId(nextId); - }, [conversationIds, selectedConversationId]); + }, [conversationIds, selectedConversationId, setSelectedConversationId]); // Register keyboard listener for quick conversation switching const onKeyDown = useCallback( @@ -186,9 +188,8 @@ export const ConversationSelector: React.FC = React.memo( useEvent('keydown', onKeyDown); useEffect(() => { - onSelectionChange?.(selectedConversationId); setSelectedOptions(conversationOptions.filter((c) => c.label === selectedConversationId)); - }, [conversationOptions, onSelectionChange, selectedConversationId]); + }, [conversationOptions, selectedConversationId]); const renderOption: ( option: ConversationSelectorOption, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/translations.ts similarity index 97% rename from x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/translations.ts rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/translations.ts index 625c3e3605f651..fb93bed03c5bb2 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const SELECTED_CONVERSATION_LABEL = i18n.translate( 'xpack.elasticAssistant.assistant.conversationSelector.defaultConversationTitle', { - defaultMessage: 'Selected conversation', + defaultMessage: 'Conversations', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx new file mode 100644 index 00000000000000..873f7dc7bb0981 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx @@ -0,0 +1,251 @@ +/* + * 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 { + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHighlight, + EuiToolTip, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { css } from '@emotion/react'; + +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; +import { Conversation, Prompt } from '../../../..'; +import { UseAssistantContext } from '../../../assistant_context'; +import * as i18n from './translations'; +import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector'; + +interface Props { + allSystemPrompts: Prompt[]; + conversations: UseAssistantContext['conversations']; + onConversationDeleted: (conversationId: string) => void; + onConversationSelectionChange: (conversation?: Conversation | string) => void; + selectedConversationId?: string; + defaultConnectorId?: string; + defaultProvider?: OpenAiProviderType; +} + +const getPreviousConversationId = (conversationIds: string[], selectedConversationId = '') => { + return conversationIds.indexOf(selectedConversationId) === 0 + ? conversationIds[conversationIds.length - 1] + : conversationIds[conversationIds.indexOf(selectedConversationId) - 1]; +}; + +const getNextConversationId = (conversationIds: string[], selectedConversationId = '') => { + return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length + ? conversationIds[0] + : conversationIds[conversationIds.indexOf(selectedConversationId) + 1]; +}; + +export type ConversationSelectorSettingsOption = EuiComboBoxOptionOption<{ + isDefault: boolean; +}>; + +/** + * A disconnected variant of the ConversationSelector component that allows for + * modifiable settings without persistence. Also changes some styling and removes + * the keyboard shortcuts. Could be merged w/ ConversationSelector if refactored + * as a connected wrapper. + */ +export const ConversationSelectorSettings: React.FC = React.memo( + ({ + allSystemPrompts, + conversations, + onConversationDeleted, + onConversationSelectionChange, + selectedConversationId, + defaultConnectorId, + defaultProvider, + }) => { + const conversationIds = useMemo(() => Object.keys(conversations), [conversations]); + + const [conversationOptions, setConversationOptions] = useState< + ConversationSelectorSettingsOption[] + >(() => { + return Object.values(conversations).map((conversation) => ({ + value: { isDefault: conversation.isDefault ?? false }, + label: conversation.id, + })); + }); + + const selectedOptions = useMemo(() => { + return selectedConversationId + ? conversationOptions.filter((c) => c.label === selectedConversationId) ?? [] + : []; + }, [conversationOptions, selectedConversationId]); + + const handleSelectionChange = useCallback( + (conversationSelectorSettingsOption: ConversationSelectorSettingsOption[]) => { + const newConversation = + conversationSelectorSettingsOption.length === 0 + ? undefined + : Object.values(conversations).find( + (conversation) => conversation.id === conversationSelectorSettingsOption[0]?.label + ) ?? conversationSelectorSettingsOption[0]?.label; + onConversationSelectionChange(newConversation); + }, + [onConversationSelectionChange, conversations] + ); + + // Callback for when user types to create a new conversation + const onCreateOption = useCallback( + (searchValue, flattenedOptions = []) => { + if (!searchValue || !searchValue.trim().toLowerCase()) { + return; + } + + const normalizedSearchValue = searchValue.trim().toLowerCase(); + const optionExists = + flattenedOptions.findIndex( + (option: SystemPromptSelectorOption) => + option.label.trim().toLowerCase() === normalizedSearchValue + ) !== -1; + + const newOption = { + value: searchValue, + label: searchValue, + }; + + if (!optionExists) { + setConversationOptions([...conversationOptions, newOption]); + } + handleSelectionChange([newOption]); + }, + [conversationOptions, handleSelectionChange] + ); + + // Callback for when a user selects a conversation + const onChange = useCallback( + (newOptions: ConversationSelectorSettingsOption[]) => { + if (newOptions.length === 0) { + handleSelectionChange([]); + } else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) { + handleSelectionChange(newOptions); + } + }, + [conversationOptions, handleSelectionChange] + ); + + // Callback for when user deletes a conversation + const onDelete = useCallback( + (label: string) => { + setConversationOptions(conversationOptions.filter((o) => o.label !== label)); + if (selectedOptions?.[0]?.label === label) { + handleSelectionChange([]); + } + onConversationDeleted(label); + }, + [conversationOptions, handleSelectionChange, onConversationDeleted, selectedOptions] + ); + + const onLeftArrowClick = useCallback(() => { + const prevId = getPreviousConversationId(conversationIds, selectedConversationId); + const previousOption = conversationOptions.filter((c) => c.label === prevId); + handleSelectionChange(previousOption); + }, [conversationIds, conversationOptions, handleSelectionChange, selectedConversationId]); + const onRightArrowClick = useCallback(() => { + const nextId = getNextConversationId(conversationIds, selectedConversationId); + const nextOption = conversationOptions.filter((c) => c.label === nextId); + handleSelectionChange(nextOption); + }, [conversationIds, conversationOptions, handleSelectionChange, selectedConversationId]); + + const renderOption: ( + option: ConversationSelectorSettingsOption, + searchValue: string, + OPTION_CONTENT_CLASSNAME: string + ) => React.ReactNode = (option, searchValue, contentClassName) => { + const { label, value } = option; + return ( + + + + {label} + + + {!value?.isDefault && ( + + + { + e.stopPropagation(); + onDelete(label); + }} + css={css` + visibility: hidden; + .parentFlexGroup:hover & { + visibility: visible; + } + `} + /> + + + )} + + ); + }; + + return ( + + + } + append={ + + } + /> + + ); + } +); + +ConversationSelectorSettings.displayName = 'ConversationSelectorSettings'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts new file mode 100644 index 00000000000000..7ba634ded5f126 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts @@ -0,0 +1,57 @@ +/* + * 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 SELECTED_CONVERSATION_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.defaultConversationTitle', + { + defaultMessage: 'Conversations', + } +); + +export const CONVERSATION_SELECTOR_ARIA_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.ariaLabel', + { + defaultMessage: 'Conversation selector', + } +); + +export const CONVERSATION_SELECTOR_PLACE_HOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.placeholderTitle', + { + defaultMessage: 'Select or type to create new...', + } +); + +export const CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.CustomOptionTextTitle', + { + defaultMessage: 'Create new conversation:', + } +); + +export const PREVIOUS_CONVERSATION_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.previousConversationTitle', + { + defaultMessage: 'Previous conversation', + } +); + +export const NEXT_CONVERSATION_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.nextConversationTitle', + { + defaultMessage: 'Next conversation', + } +); + +export const DELETE_CONVERSATION = i18n.translate( + 'xpack.elasticAssistant.assistant.conversationSelectorSettings.deleteConversationTitle', + { + defaultMessage: 'Delete conversation', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx new file mode 100644 index 00000000000000..b519aeb9eb9ba8 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx @@ -0,0 +1,265 @@ +/* + * 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 { EuiFormRow, EuiLink, EuiTitle, EuiText, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; +import { Conversation, Prompt } from '../../../..'; +import * as i18n from './translations'; +import * as i18nModel from '../../../connectorland/models/model_selector/translations'; + +import { ConnectorSelector } from '../../../connectorland/connector_selector'; +import { SelectSystemPrompt } from '../../prompt_editor/system_prompt/select_system_prompt'; +import { ModelSelector } from '../../../connectorland/models/model_selector/model_selector'; +import { UseAssistantContext } from '../../../assistant_context'; +import { ConversationSelectorSettings } from '../conversation_selector_settings'; +import { getDefaultSystemPromptFromConversation } from './helpers'; + +export interface ConversationSettingsProps { + actionTypeRegistry: ActionTypeRegistryContract; + allSystemPrompts: Prompt[]; + conversationSettings: UseAssistantContext['conversations']; + http: HttpSetup; + onSelectedConversationChange: (conversation?: Conversation) => void; + selectedConversation: Conversation | undefined; + setUpdatedConversationSettings: React.Dispatch< + React.SetStateAction + >; + isDisabled?: boolean; +} + +/** + * Settings for adding/removing conversation and configuring default system prompt and connector. + */ +export const ConversationSettings: React.FC = React.memo( + ({ + actionTypeRegistry, + allSystemPrompts, + selectedConversation, + onSelectedConversationChange, + conversationSettings, + http, + setUpdatedConversationSettings, + isDisabled = false, + }) => { + // Defaults + const defaultSystemPrompt = useMemo(() => { + return ( + allSystemPrompts.find((systemPrompt) => systemPrompt.isNewConversationDefault) ?? + allSystemPrompts[0] + ); + }, [allSystemPrompts]); + + // Conversation callbacks + // When top level conversation selection changes + const onConversationSelectionChange = useCallback( + (c?: Conversation | string) => { + const isNew = typeof c === 'string'; + const newSelectedConversation: Conversation | undefined = isNew + ? { + id: c ?? '', + messages: [], + apiConfig: { + connectorId: undefined, + provider: undefined, + defaultSystemPromptId: defaultSystemPrompt?.id, + }, + } + : c; + + if (newSelectedConversation != null) { + setUpdatedConversationSettings((prev) => { + return { + ...prev, + [newSelectedConversation.id]: newSelectedConversation, + }; + }); + } + + onSelectedConversationChange(newSelectedConversation); + }, + [defaultSystemPrompt?.id, onSelectedConversationChange, setUpdatedConversationSettings] + ); + + const onConversationDeleted = useCallback( + (conversationId: string) => { + setUpdatedConversationSettings((prev) => { + const { [conversationId]: prevConversation, ...updatedConversations } = prev; + if (prevConversation != null) { + return updatedConversations; + } + return prev; + }); + }, + [setUpdatedConversationSettings] + ); + + const selectedSystemPrompt = useMemo( + () => + getDefaultSystemPromptFromConversation({ + allSystemPrompts, + conversation: selectedConversation, + defaultSystemPrompt, + }), + [allSystemPrompts, defaultSystemPrompt, selectedConversation] + ); + + const handleOnSystemPromptSelectionChange = useCallback( + (systemPromptId?: string) => { + if (selectedConversation != null) { + setUpdatedConversationSettings((prev) => ({ + ...prev, + [selectedConversation.id]: { + ...selectedConversation, + apiConfig: { + ...selectedConversation.apiConfig, + defaultSystemPromptId: systemPromptId, + }, + }, + })); + } + }, + [selectedConversation, setUpdatedConversationSettings] + ); + + const selectedConnectorId = useMemo( + () => selectedConversation?.apiConfig.connectorId, + [selectedConversation?.apiConfig.connectorId] + ); + + const selectedProvider = useMemo( + () => selectedConversation?.apiConfig.provider, + [selectedConversation?.apiConfig.provider] + ); + + const handleOnConnectorSelectionChange = useCallback( + (connectorId: string, provider: OpenAiProviderType) => { + if (selectedConversation != null) { + setUpdatedConversationSettings((prev) => ({ + ...prev, + [selectedConversation.id]: { + ...selectedConversation, + apiConfig: { + ...selectedConversation.apiConfig, + connectorId, + provider, + }, + }, + })); + } + }, + [selectedConversation, setUpdatedConversationSettings] + ); + + const selectedModel = useMemo( + () => selectedConversation?.apiConfig.model, + [selectedConversation?.apiConfig.model] + ); + + const handleOnModelSelectionChange = useCallback( + (model?: string) => { + if (selectedConversation != null) { + setUpdatedConversationSettings((prev) => ({ + ...prev, + [selectedConversation.id]: { + ...selectedConversation, + apiConfig: { + ...selectedConversation.apiConfig, + model, + }, + }, + })); + } + }, + [selectedConversation, setUpdatedConversationSettings] + ); + + return ( + <> + +

{i18n.SETTINGS_TITLE}

+
+ + {i18n.SETTINGS_DESCRIPTION} + + + + + + + + + + + + } + > + {}} + onConnectorSelectionChange={handleOnConnectorSelectionChange} + selectedConnectorId={selectedConnectorId} + /> + + + {selectedProvider === OpenAiProviderType.OpenAi && ( + + + + )} + + ); + } +); +ConversationSettings.displayName = 'ConversationSettings'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx new file mode 100644 index 00000000000000..5d0cc04aa9630b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx @@ -0,0 +1,33 @@ +/* + * 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 { Conversation, Prompt } from '../../../..'; + +/** + * Returns a conversation's default system prompt, or the default system prompt if the conversation does not have one. + * @param allSystemPrompts + * @param conversation + * @param defaultSystemPrompt + */ +export const getDefaultSystemPromptFromConversation = ({ + allSystemPrompts, + conversation, + defaultSystemPrompt, +}: { + conversation: Conversation | undefined; + allSystemPrompts: Prompt[]; + defaultSystemPrompt: Prompt; +}) => { + const convoDefaultSystemPromptId = conversation?.apiConfig.defaultSystemPromptId; + if (convoDefaultSystemPromptId && allSystemPrompts) { + return ( + allSystemPrompts.find((prompt) => prompt.id === convoDefaultSystemPromptId) ?? + defaultSystemPrompt + ); + } + return allSystemPrompts.find((prompt) => prompt.isNewConversationDefault) ?? defaultSystemPrompt; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/translations.ts new file mode 100644 index 00000000000000..ede5fd879664f1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/translations.ts @@ -0,0 +1,43 @@ +/* + * 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 SETTINGS_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.conversations.settings.settingsTitle', + { + defaultMessage: 'Conversations', + } +); + +export const SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.conversations.settings.settingsDescription', + { + defaultMessage: 'Create and manage conversations with the Elastic AI Assistant', + } +); + +export const CONNECTOR_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.conversations.settings.connectorTitle', + { + defaultMessage: 'Connector', + } +); + +export const SETTINGS_PROMPT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.conversations.settings.promptTitle', + { + defaultMessage: 'System Prompt', + } +); + +export const SETTINGS_PROMPT_HELP_TEXT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.conversations.settings.promptHelpTextTitle', + { + defaultMessage: 'Context provided as part of every conversation', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts index 8cf79866000413..4f671b4c9758cd 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { BASE_CONVERSATIONS, Conversation } from '../..'; import type { Message } from '../assistant_context/types'; +import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations'; +import { enterpriseMessaging } from './use_conversation/sample_conversations'; export const getMessageFromRawResponse = (rawResponse: string): Message => { const dateTimeString = new Date().toLocaleString(); // TODO: Pull from response @@ -23,3 +26,27 @@ export const getMessageFromRawResponse = (rawResponse: string): Message => { }; } }; + +export const getWelcomeConversation = (isAssistantEnabled: boolean): Conversation => { + const conversation = BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE]; + const doesConversationHaveMessages = conversation.messages.length > 0; + + if (!isAssistantEnabled) { + if ( + !doesConversationHaveMessages || + conversation.messages[conversation.messages.length - 1].content !== + enterpriseMessaging[0].content + ) { + return { + ...conversation, + messages: [...conversation.messages, ...enterpriseMessaging], + }; + } + return conversation; + } + + return { + ...conversation, + messages: BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages, + }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx index 44f50e26c1115a..adbdc0df860060 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -146,7 +146,7 @@ describe('Assistant', () => { }); describe('when no connectors are loaded', () => { - it('should clear conversation id in local storage', async () => { + it('should set welcome conversation id in local storage', async () => { const emptyConnectors: unknown[] = []; jest.mocked(useLoadConnectors).mockReturnValue({ @@ -157,7 +157,7 @@ describe('Assistant', () => { renderAssistant(); expect(persistToLocalStorage).toHaveBeenCalled(); - expect(persistToLocalStorage).toHaveBeenLastCalledWith(''); + expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE); }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index 58e084db643afc..a9ce41747aa17c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -16,7 +16,6 @@ import { EuiToolTip, EuiSwitchEvent, EuiSwitch, - EuiCallOut, EuiModalFooter, EuiModalHeader, EuiModalBody, @@ -29,20 +28,18 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/c import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; import { AssistantTitle } from './assistant_title'; import { UpgradeButtons } from '../upgrade/upgrade_buttons'; -import { getMessageFromRawResponse } from './helpers'; +import { getMessageFromRawResponse, getWelcomeConversation } from './helpers'; -import { ConversationSettingsPopover } from './conversation_settings_popover/conversation_settings_popover'; import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context'; -import { SettingsPopover } from '../data_anonymization/settings/settings_popover'; import { PromptTextArea } from './prompt_textarea'; import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; import { useConversation } from './use_conversation'; import { CodeBlockDetails } from './use_conversation/helpers'; import { useSendMessages } from './use_send_messages'; import type { Message } from '../assistant_context/types'; -import { ConversationSelector } from './conversation_selector'; +import { ConversationSelector } from './conversations/conversation_selector'; import { PromptEditor } from './prompt_editor'; import { getCombinedMessage } from './prompt/helpers'; import * as i18n from './translations'; @@ -50,7 +47,8 @@ import { QuickPrompts } from './quick_prompts/quick_prompts'; import { useLoadConnectors } from '../connectorland/use_load_connectors'; import { useConnectorSetup } from '../connectorland/connector_setup'; import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations'; -import { BASE_CONVERSATIONS, enterpriseMessaging } from './use_conversation/sample_conversations'; +import { AssistantSettingsButton } from './settings/assistant_settings_button'; +import { ConnectorMissingCallout } from '../connectorland/connector_missing_callout'; export interface Props { conversationId?: string; @@ -97,46 +95,7 @@ const AssistantComponent: React.FC = ({ useConversation(); const { isLoading, sendMessages } = useSendMessages(); - const [selectedConversationId, setSelectedConversationId] = useState(conversationId); - const currentConversation = useMemo( - () => conversations[selectedConversationId] ?? createConversation({ conversationId }), - [conversationId, conversations, createConversation, selectedConversationId] - ); - - // Welcome conversation is a special 'setup' case when no connector exists, mostly extracted to `ConnectorSetup` component, - // but currently a bit of state is littered throughout the assistant component. TODO: clean up/isolate this state - const welcomeConversation = useMemo(() => { - const conversation = - conversations[selectedConversationId] ?? BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE]; - const doesConversationHaveMessages = conversation.messages.length > 0; - if (!isAssistantEnabled) { - if ( - !doesConversationHaveMessages || - conversation.messages[conversation.messages.length - 1].content !== - enterpriseMessaging[0].content - ) { - return { - ...conversation, - messages: [...conversation.messages, ...enterpriseMessaging], - }; - } - return conversation; - } - - return doesConversationHaveMessages - ? { - ...conversation, - messages: [ - ...conversation.messages, - ...BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages, - ], - } - : { - ...conversation, - messages: BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages, - }; - }, [conversations, isAssistantEnabled, selectedConversationId]); - + // Connector details const { data: connectors, isSuccess: areConnectorsFetched, @@ -150,11 +109,30 @@ const AssistantComponent: React.FC = ({ [connectors] ); + // Welcome setup state + const isWelcomeSetup = useMemo(() => (connectors?.length ?? 0) === 0, [connectors?.length]); + const isDisabled = isWelcomeSetup || !isAssistantEnabled; + + // Welcome conversation is a special 'setup' case when no connector exists, mostly extracted to `ConnectorSetup` component, + // but currently a bit of state is littered throughout the assistant component. TODO: clean up/isolate this state + const welcomeConversation = useMemo( + () => getWelcomeConversation(isAssistantEnabled), + [isAssistantEnabled] + ); + + const [selectedConversationId, setSelectedConversationId] = useState( + isWelcomeSetup ? welcomeConversation.id : conversationId + ); + const currentConversation = useMemo( + () => conversations[selectedConversationId] ?? createConversation({ conversationId }), + [conversationId, conversations, createConversation, selectedConversationId] + ); + // Remember last selection for reuse after keyboard shortcut is pressed. // Clear it if there is no connectors useEffect(() => { if (areConnectorsFetched && !connectors?.length) { - return setLastConversationId(''); + return setLastConversationId(WELCOME_CONVERSATION_TITLE); } if (!currentConversation.excludeFromLastConversationStorage) { @@ -162,9 +140,6 @@ const AssistantComponent: React.FC = ({ } }, [areConnectorsFetched, connectors?.length, currentConversation, setLastConversationId]); - const isWelcomeSetup = (connectors?.length ?? 0) === 0; - const isDisabled = isWelcomeSetup || !isAssistantEnabled; - const { comments: connectorComments, prompt: connectorPrompt } = useConnectorSetup({ actionTypeRegistry, http, @@ -479,10 +454,10 @@ const AssistantComponent: React.FC = ({ `} > setSelectedConversationId(id)} + selectedConversationId={selectedConversationId} + setSelectedConversationId={setSelectedConversationId} shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys} isDisabled={isDisabled} /> @@ -511,26 +486,17 @@ const AssistantComponent: React.FC = ({ - + - {!isWelcomeSetup && showMissingConnectorCallout && ( - <> - -

{i18n.MISSING_CONNECTOR_CALLOUT_DESCRIPTION}

-
- - - )} )} @@ -550,7 +516,20 @@ const AssistantComponent: React.FC = ({ )} - {comments} + + {comments} + + {!isWelcomeSetup && showMissingConnectorCallout && ( + <> + + + + + + + + )} + = ({ /> - - - diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx index b14e69160f9c3c..b671d225bac4fe 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx @@ -42,7 +42,7 @@ const defaultProps: Props = { describe('PromptEditorComponent', () => { beforeEach(() => jest.clearAllMocks()); - it('renders the system prompt selector when isNewConversation is true', async () => { + it('renders the system prompt viewer when isNewConversation is true', async () => { render( @@ -50,7 +50,7 @@ describe('PromptEditorComponent', () => { ); await waitFor(() => { - expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument(); + expect(screen.getByTestId('systemPromptText')).toBeInTheDocument(); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx index c46116b88557d7..cb2e3870702f12 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx @@ -71,27 +71,27 @@ describe('SystemPrompt', () => { }); }); - describe('when conversation is undefined', () => { + describe('when conversation is undefined and default prompt is used', () => { const conversation = undefined; beforeEach(() => { render(); }); - it('renders the system prompt select', () => { - expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument(); + it('does render the system prompt fallback text', () => { + expect(screen.getByTestId('systemPromptText')).toBeInTheDocument(); }); - it('does NOT render the system prompt text', () => { - expect(screen.queryByTestId('systemPromptText')).not.toBeInTheDocument(); + it('does NOT render the system prompt select', () => { + expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument(); }); - it('does NOT render the edit button', () => { - expect(screen.queryByTestId('edit')).not.toBeInTheDocument(); + it('does render the edit button', () => { + expect(screen.getByTestId('edit')).toBeInTheDocument(); }); - it('does NOT render the clear button', () => { - expect(screen.queryByTestId('clear')).not.toBeInTheDocument(); + it('does render the clear button', () => { + expect(screen.getByTestId('clear')).toBeInTheDocument(); }); }); @@ -117,7 +117,8 @@ describe('SystemPrompt', () => { }); }); - describe('when a new prompt is saved', () => { + // TODO: To be implemented as part of the global settings tests instead of within the SystemPrompt component + describe.skip('when a new prompt is saved', () => { it('should save new prompt correctly', async () => { const customPromptName = 'custom prompt'; const customPromptText = 'custom prompt text'; @@ -420,18 +421,6 @@ describe('SystemPrompt', () => { expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument(); }); - it('clears the selected system prompt when the clear button is clicked', () => { - const apiConfig = { - apiConfig: { defaultSystemPromptId: undefined }, - conversationId: 'Default', - }; - render(); - - userEvent.click(screen.getByTestId('clear')); - - expect(mockUseConversation.setApiConfig).toHaveBeenCalledWith(apiConfig); - }); - it('shows the system prompt select when system prompt text is clicked', () => { render( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx index fd08b418605e19..8520f8da55c26e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx @@ -6,14 +6,13 @@ */ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { css } from '@emotion/react'; import { useAssistantContext } from '../../../assistant_context'; -import { Conversation } from '../../../..'; +import { Conversation, Prompt } from '../../../..'; import * as i18n from './translations'; import { SelectSystemPrompt } from './select_system_prompt'; -import { useConversation } from '../../use_conversation'; interface Props { conversation: Conversation | undefined; @@ -21,26 +20,24 @@ interface Props { const SystemPromptComponent: React.FC = ({ conversation }) => { const { allSystemPrompts } = useAssistantContext(); - const { setApiConfig } = useConversation(); - const selectedPrompt = useMemo( - () => allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId), - [allSystemPrompts, conversation] + const [selectedPrompt, setSelectedPrompt] = useState( + allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId) ?? + allSystemPrompts?.[0] ); + useEffect(() => { + setSelectedPrompt( + allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId) ?? + allSystemPrompts?.[0] + ); + }, [allSystemPrompts, conversation]); + const [isEditing, setIsEditing] = React.useState(false); const handleClearSystemPrompt = useCallback(() => { - if (conversation) { - setApiConfig({ - conversationId: conversation.id, - apiConfig: { - ...conversation.apiConfig, - defaultSystemPromptId: undefined, - }, - }); - } - }, [conversation, setApiConfig]); + setSelectedPrompt(undefined); + }, []); const handleEditSystemPrompt = useCallback(() => setIsEditing(true), []); @@ -48,6 +45,7 @@ const SystemPromptComponent: React.FC = ({ conversation }) => {
{selectedPrompt == null || isEditing ? ( void; - fullWidth?: boolean; isClearable?: boolean; isEditing?: boolean; + isDisabled?: boolean; isOpen?: boolean; - onSystemPromptModalVisibilityChange?: (isVisible: boolean) => void; setIsEditing?: React.Dispatch>; showTitles?: boolean; + onSystemPromptSelectionChange?: (promptId: string) => void; } const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT'; const SelectSystemPromptComponent: React.FC = ({ + allSystemPrompts, + compressed = false, conversation, selectedPrompt, clearSelectedSystemPrompt, - fullWidth = true, isClearable = false, isEditing = false, + isDisabled = false, isOpen = false, - onSystemPromptModalVisibilityChange, + onSystemPromptSelectionChange, setIsEditing, showTitles = false, }) => { - const { allSystemPrompts, setAllSystemPrompts, conversations, setConversations } = + const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } = useAssistantContext(); - const { setApiConfig } = useConversation(); const [isOpenLocal, setIsOpenLocal] = useState(isOpen); @@ -78,8 +81,6 @@ const SelectSystemPromptComponent: React.FC = ({ [conversation, setApiConfig] ); - // Connector Modal State - const [isSystemPromptModalVisible, setIsSystemPromptModalVisible] = useState(false); const addNewSystemPrompt = useMemo(() => { return { value: ADD_NEW_SYSTEM_PROMPT, @@ -100,29 +101,6 @@ const SelectSystemPromptComponent: React.FC = ({ }; }, []); - // Callback for modal onSave, saves to local storage on change - const onSystemPromptsChange = useCallback( - (newSystemPrompts: Prompt[], updatedConversations?: Conversation[]) => { - setAllSystemPrompts(newSystemPrompts); - setIsSystemPromptModalVisible(false); - onSystemPromptModalVisibilityChange?.(false); - - if (updatedConversations && updatedConversations.length > 0) { - const updatedConversationObject = updatedConversations?.reduce< - Record - >((updatedObj, currentConv) => { - updatedObj[currentConv.id] = currentConv; - return updatedObj; - }, {}); - setConversations({ - ...conversations, - ...updatedConversationObject, - }); - } - }, - [onSystemPromptModalVisibilityChange, setAllSystemPrompts, conversations, setConversations] - ); - // SuperSelect State/Actions const options = useMemo( () => getOptions({ prompts: allSystemPrompts, showTitles }), @@ -132,14 +110,26 @@ const SelectSystemPromptComponent: React.FC = ({ const onChange = useCallback( (selectedSystemPromptId) => { if (selectedSystemPromptId === ADD_NEW_SYSTEM_PROMPT) { - onSystemPromptModalVisibilityChange?.(true); - setIsSystemPromptModalVisible(true); + setIsSettingsModalVisible(true); + setSelectedSettingsTab(SYSTEM_PROMPTS_TAB); return; } - setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId)); + // Note: if callback is provided, this component does not persist. Extract to separate component + if (onSystemPromptSelectionChange != null) { + onSystemPromptSelectionChange(selectedSystemPromptId); + } else { + setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId)); + } setIsEditing?.(false); }, - [allSystemPrompts, onSystemPromptModalVisibilityChange, setIsEditing, setSelectedSystemPrompt] + [ + allSystemPrompts, + onSystemPromptSelectionChange, + setIsEditing, + setIsSettingsModalVisible, + setSelectedSettingsTab, + setSelectedSystemPrompt, + ] ); const clearSystemPrompt = useCallback(() => { @@ -170,16 +160,18 @@ const SelectSystemPromptComponent: React.FC = ({ // Limits popover z-index to prevent it from getting too high and covering tooltips. // If the z-index is not defined, when a popover is opened, it sets the target z-index + 2000 popoverProps={{ zIndex: euiThemeVars.euiZLevel8 }} + compressed={compressed} data-test-subj={TEST_IDS.PROMPT_SUPERSELECT} - fullWidth={fullWidth} + fullWidth hasDividers itemLayoutAlign="top" - isOpen={isOpenLocal && !isSystemPromptModalVisible} + disabled={isDisabled} + isOpen={isOpenLocal && !isSettingsModalVisible} onChange={onChange} onBlur={handleOnBlur} options={[...options, addNewSystemPrompt]} placeholder={i18n.SELECT_A_SYSTEM_PROMPT} - valueOfSelected={selectedPrompt?.id} + valueOfSelected={selectedPrompt?.id ?? allSystemPrompts[0].id} /> )} @@ -207,13 +199,6 @@ const SelectSystemPromptComponent: React.FC = ({ )} - {isSystemPromptModalVisible && ( - setIsSystemPromptModalVisible(false)} - onSystemPromptsChange={onSystemPromptsChange} - systemPrompts={allSystemPrompts} - /> - )} ); }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx index ea6cb525180b99..d53d3cc15e9e29 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx @@ -13,6 +13,7 @@ import { Conversation } from '../../../../../..'; import * as i18n from '../translations'; interface Props { + isDisabled?: boolean; onConversationSelectionChange: (currentPromptConversations: Conversation[]) => void; conversations: Conversation[]; selectedConversations?: Conversation[]; @@ -22,7 +23,12 @@ interface Props { * Selector for choosing multiple Conversations */ export const ConversationMultiSelector: React.FC = React.memo( - ({ onConversationSelectionChange, conversations, selectedConversations = [] }) => { + ({ + conversations, + isDisabled = false, + onConversationSelectionChange, + selectedConversations = [], + }) => { // ComboBox options const options = useMemo( () => @@ -64,8 +70,11 @@ export const ConversationMultiSelector: React.FC = React.memo( return ( | React.MouseEvent - ) => void; - onSystemPromptsChange: (systemPrompts: Prompt[], newConversation?: Conversation[]) => void; -} - -/** - * Modal for adding/removing system prompts. Configure name, prompt and default conversations. - */ -export const SystemPromptModal: React.FC = React.memo( - ({ systemPrompts, onClose, onSystemPromptsChange }) => { - const { conversations } = useAssistantContext(); - // Local state for quick prompts (returned to parent on save via onSystemPromptsChange()) - const [updatedSystemPrompts, setUpdatedSystemPrompts] = useState(systemPrompts); - - // Form options - const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); - // Prompt - const [prompt, setPrompt] = useState(''); - const handlePromptTextChange = useCallback((e: React.ChangeEvent) => { - setPrompt(e.target.value); - }, []); - // Conversations this system prompt should be a default for - const [selectedConversations, setSelectedConversations] = useState([]); - - const onConversationSelectionChange = useCallback( - (currentPromptConversations: Conversation[]) => { - setSelectedConversations(currentPromptConversations); - }, - [] - ); - - /* - * updatedConversationWithPrompts calculates the present of prompt for - * each conversation. Based on the values of selected conversation, it goes - * through each conversation adds/removed the selected prompt on each conversation. - * - * */ - const getUpdatedConversationWithPrompts = useCallback(() => { - const currentPromptConversationIds = selectedConversations.map((convo) => convo.id); - - const allConversations = Object.values(conversations).map((convo) => ({ - ...convo, - apiConfig: { - ...convo.apiConfig, - defaultSystemPromptId: currentPromptConversationIds.includes(convo.id) - ? selectedSystemPrompt?.id - : convo.apiConfig.defaultSystemPromptId === selectedSystemPrompt?.id - ? // remove the the default System Prompt if it is assigned to a conversation - // but that conversation is not in the currentPromptConversationList - // This means conversation was removed in the current transaction - undefined - : // leave it as it is .. if that conversation was neither added nor removed. - convo.apiConfig.defaultSystemPromptId, - }, - })); - - return allConversations; - }, [selectedSystemPrompt, conversations, selectedConversations]); - // Whether this system prompt should be the default for new conversations - const [isNewConversationDefault, setIsNewConversationDefault] = useState(false); - const handleNewConversationDefaultChange = useCallback( - (e) => { - const isChecked = e.target.checked; - setIsNewConversationDefault(isChecked); - if (selectedSystemPrompt != null) { - setUpdatedSystemPrompts((prev) => { - return prev.map((pp) => { - return { - ...pp, - isNewConversationDefault: selectedSystemPrompt.id === pp.id && isChecked, - }; - }); - }); - setSelectedSystemPrompt((prev) => - prev != null ? { ...prev, isNewConversationDefault: isChecked } : prev - ); - } - }, - [selectedSystemPrompt] - ); - - // When top level system prompt selection changes - const onSystemPromptSelectionChange = useCallback( - (systemPrompt?: Prompt | string) => { - const newPrompt: Prompt | undefined = - typeof systemPrompt === 'string' - ? { - id: systemPrompt ?? '', - content: '', - name: systemPrompt ?? '', - promptType: 'system', - } - : systemPrompt; - - setSelectedSystemPrompt(newPrompt); - setPrompt(newPrompt?.content ?? ''); - setIsNewConversationDefault(newPrompt?.isNewConversationDefault ?? false); - // Find all conversations that have this system prompt as a default - const currenlySelectedConversations = - newPrompt != null - ? Object.values(conversations).filter( - (conversation) => conversation?.apiConfig.defaultSystemPromptId === newPrompt?.id - ) - : []; - setSelectedConversations(currenlySelectedConversations); - }, - [conversations] - ); - - const onSystemPromptDeleted = useCallback((id: string) => { - setUpdatedSystemPrompts((prev) => prev.filter((sp) => sp.id !== id)); - }, []); - - const handleSave = useCallback(() => { - const updatedConversations = getUpdatedConversationWithPrompts(); - onSystemPromptsChange(updatedSystemPrompts, updatedConversations); - }, [onSystemPromptsChange, updatedSystemPrompts, getUpdatedConversationWithPrompts]); - - // useEffects - // Update system prompts on any field change since editing is in place - useEffect(() => { - if (selectedSystemPrompt != null) { - setUpdatedSystemPrompts((prev) => { - const alreadyExists = prev.some((sp) => sp.id === selectedSystemPrompt.id); - if (alreadyExists) { - return prev.map((sp) => { - if (sp.id === selectedSystemPrompt.id) { - return { - ...sp, - content: prompt, - promptType: 'system', - }; - } - return sp; - }); - } else { - return [ - ...prev, - { - ...selectedSystemPrompt, - content: prompt, - promptType: 'system', - }, - ]; - } - }); - } - }, [prompt, selectedSystemPrompt]); - - return ( - - - {i18n.ADD_SYSTEM_PROMPT_MODAL_TITLE} - - - - - - - - - - - - - - - - - {i18n.SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION} - - - - - } - checked={isNewConversationDefault} - onChange={handleNewConversationDefaultChange} - compressed - /> - - - - - - {i18n.CANCEL} - - - - {i18n.SAVE} - - - - ); - } -); - -SystemPromptModal.displayName = 'SystemPromptModal'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx index c357c46a56d837..acc99e06e85a56 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx @@ -29,6 +29,7 @@ interface Props { onSystemPromptDeleted: (systemPromptTitle: string) => void; onSystemPromptSelectionChange: (systemPrompt?: Prompt | string) => void; systemPrompts: Prompt[]; + autoFocus?: boolean; selectedSystemPrompt?: Prompt; } @@ -42,6 +43,7 @@ export type SystemPromptSelectorOption = EuiComboBoxOptionOption<{ */ export const SystemPromptSelector: React.FC = React.memo( ({ + autoFocus = false, systemPrompts, onSystemPromptDeleted, onSystemPromptSelectionChange, @@ -203,9 +205,11 @@ export const SystemPromptSelector: React.FC = React.memo( return ( = React.memo( onChange={onChange} onCreateOption={onCreateOption} renderOption={renderOption} - autoFocus + autoFocus={autoFocus} /> ); } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts index 34652bdef86565..41c4a4b535ce7c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const SYSTEM_PROMPT_SELECTOR = i18n.translate( 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.systemPromptSelector.ariaLabel', { - defaultMessage: 'Select to edit, or type to create new', + defaultMessage: 'Select or type to create new...', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx new file mode 100644 index 00000000000000..fa576e20fecbac --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx @@ -0,0 +1,264 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { + EuiFormRow, + EuiTextArea, + EuiCheckbox, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; + +import { keyBy } from 'lodash/fp'; + +import { css } from '@emotion/react'; +import { Conversation, Prompt } from '../../../../..'; +import * as i18n from './translations'; +import { UseAssistantContext } from '../../../../assistant_context'; +import { ConversationMultiSelector } from './conversation_multi_selector/conversation_multi_selector'; +import { SystemPromptSelector } from './system_prompt_selector/system_prompt_selector'; +import { TEST_IDS } from '../../../constants'; + +interface Props { + conversationSettings: UseAssistantContext['conversations']; + onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void; + selectedSystemPrompt: Prompt | undefined; + setUpdatedSystemPromptSettings: React.Dispatch>; + setUpdatedConversationSettings: React.Dispatch< + React.SetStateAction + >; + systemPromptSettings: Prompt[]; +} + +/** + * Settings for adding/removing system prompts. Configure name, prompt and default conversations. + */ +export const SystemPromptSettings: React.FC = React.memo( + ({ + conversationSettings, + onSelectedSystemPromptChange, + selectedSystemPrompt, + setUpdatedSystemPromptSettings, + setUpdatedConversationSettings, + systemPromptSettings, + }) => { + // Prompt + const promptContent = useMemo( + () => selectedSystemPrompt?.content ?? '', + [selectedSystemPrompt?.content] + ); + + const handlePromptContentChange = useCallback( + (e: React.ChangeEvent) => { + if (selectedSystemPrompt != null) { + setUpdatedSystemPromptSettings((prev): Prompt[] => { + const alreadyExists = prev.some((sp) => sp.id === selectedSystemPrompt.id); + + if (alreadyExists) { + return prev.map((sp): Prompt => { + if (sp.id === selectedSystemPrompt.id) { + return { + ...sp, + content: e.target.value, + }; + } + return sp; + }); + } + + return prev; + }); + } + }, + [selectedSystemPrompt, setUpdatedSystemPromptSettings] + ); + + // Conversations this system prompt should be a default for + const conversationOptions = useMemo( + () => Object.values(conversationSettings), + [conversationSettings] + ); + const selectedConversations = useMemo(() => { + return selectedSystemPrompt != null + ? Object.values(conversationSettings).filter( + (conversation) => + conversation.apiConfig.defaultSystemPromptId === selectedSystemPrompt.id + ) + : []; + }, [conversationSettings, selectedSystemPrompt]); + + const handleConversationSelectionChange = useCallback( + (currentPromptConversations: Conversation[]) => { + const currentPromptConversationIds = currentPromptConversations.map((convo) => convo.id); + + if (selectedSystemPrompt != null) { + setUpdatedConversationSettings((prev) => + keyBy( + 'id', + /* + * updatedConversationWithPrompts calculates the present of prompt for + * each conversation. Based on the values of selected conversation, it goes + * through each conversation adds/removed the selected prompt on each conversation. + * + * */ + Object.values(prev).map((convo) => ({ + ...convo, + apiConfig: { + ...convo.apiConfig, + defaultSystemPromptId: currentPromptConversationIds.includes(convo.id) + ? selectedSystemPrompt?.id + : convo.apiConfig.defaultSystemPromptId === selectedSystemPrompt?.id + ? // remove the default System Prompt if it is assigned to a conversation + // but that conversation is not in the currentPromptConversationList + // This means conversation was removed in the current transaction + undefined + : // leave it as it is .. if that conversation was neither added nor removed. + convo.apiConfig.defaultSystemPromptId, + }, + })) + ) + ); + } + }, + [selectedSystemPrompt, setUpdatedConversationSettings] + ); + + // Whether this system prompt should be the default for new conversations + const isNewConversationDefault = useMemo( + () => selectedSystemPrompt?.isNewConversationDefault ?? false, + [selectedSystemPrompt?.isNewConversationDefault] + ); + + const handleNewConversationDefaultChange = useCallback( + (e) => { + const isChecked = e.target.checked; + + if (selectedSystemPrompt != null) { + setUpdatedSystemPromptSettings((prev) => { + return prev.map((pp) => { + return { + ...pp, + isNewConversationDefault: selectedSystemPrompt.id === pp.id && isChecked, + }; + }); + }); + } + }, + [selectedSystemPrompt, setUpdatedSystemPromptSettings] + ); + + // When top level system prompt selection changes + const onSystemPromptSelectionChange = useCallback( + (systemPrompt?: Prompt | string) => { + const isNew = typeof systemPrompt === 'string'; + const newSelectedSystemPrompt: Prompt | undefined = isNew + ? { + id: systemPrompt ?? '', + content: '', + name: systemPrompt ?? '', + promptType: 'system', + } + : systemPrompt; + + if (newSelectedSystemPrompt != null) { + setUpdatedSystemPromptSettings((prev) => { + const alreadyExists = prev.some((sp) => sp.id === newSelectedSystemPrompt.id); + + if (!alreadyExists) { + return [...prev, newSelectedSystemPrompt]; + } + + return prev; + }); + } + + onSelectedSystemPromptChange(newSelectedSystemPrompt); + }, + [onSelectedSystemPromptChange, setUpdatedSystemPromptSettings] + ); + + const onSystemPromptDeleted = useCallback( + (id: string) => { + setUpdatedSystemPromptSettings((prev) => prev.filter((sp) => sp.id !== id)); + }, + [setUpdatedSystemPromptSettings] + ); + + return ( + <> + +

{i18n.SETTINGS_TITLE}

+
+ + {i18n.SETTINGS_DESCRIPTION} + + + + + + + + + + + + + + + {i18n.SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION} + + + + + } + checked={isNewConversationDefault} + onChange={handleNewConversationDefaultChange} + compressed + /> + + + ); + } +); + +SystemPromptSettings.displayName = 'SystemPromptSettings'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts index c57e84cd546939..6b7283977b1e22 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts @@ -7,49 +7,56 @@ import { i18n } from '@kbn/i18n'; -export const ADD_SYSTEM_PROMPT = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.addSystemPromptTitle', +export const SETTINGS_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.settingsTitle', { - defaultMessage: 'Add system prompt...', + defaultMessage: 'System Prompts', + } +); +export const SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.settingsDescription', + { + defaultMessage: + 'Create and manage System Prompts. System Prompts are configurable chunks of context that are always sent for a given conversations.', } ); export const ADD_SYSTEM_PROMPT_MODAL_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.modalTitle', + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.modalTitle', { defaultMessage: 'System Prompts', } ); export const SYSTEM_PROMPT_NAME = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.nameLabel', + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.nameLabel', { defaultMessage: 'Name', } ); export const SYSTEM_PROMPT_PROMPT = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.promptLabel', + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.promptLabel', { defaultMessage: 'Prompt', } ); export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultConversationsLabel', + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultConversationsLabel', { defaultMessage: 'Default conversations', } ); export const SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultNewConversationTitle', + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultNewConversationTitle', { defaultMessage: 'Use as default for all new conversations', } ); export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS_HELP_TEXT = i18n.translate( - 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultConversationsHelpText', + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultConversationsHelpText', { defaultMessage: 'Conversations that should use this System Prompt by default', } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx deleted file mode 100644 index db5243b3a1c12b..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/* - * 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, { useCallback, useEffect, useState } from 'react'; -import { - EuiButton, - EuiButtonEmpty, - EuiFormRow, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiColorPicker, - useColorPickerState, - EuiTextArea, -} from '@elastic/eui'; - -// eslint-disable-next-line @kbn/eslint/module_migration -import styled from 'styled-components'; - -import { EuiSetColorMethod } from '@elastic/eui/src/services/color_picker/color_picker'; -import { PromptContextTemplate } from '../../../..'; -import * as i18n from './translations'; -import { QuickPrompt } from '../types'; -import { QuickPromptSelector } from '../quick_prompt_selector/quick_prompt_selector'; -import { PromptContextSelector } from '../prompt_context_selector/prompt_context_selector'; - -const StyledEuiModal = styled(EuiModal)` - min-width: 400px; - max-width: 400px; - max-height: 80vh; -`; - -const DEFAULT_COLOR = '#D36086'; - -interface Props { - promptContexts: PromptContextTemplate[]; - quickPrompts: QuickPrompt[]; - onQuickPromptsChange: (quickPrompts: QuickPrompt[]) => void; -} - -/** - * Modal for adding/removing quick prompts. Configure name, color, prompt and category. - */ -export const AddQuickPromptModal: React.FC = React.memo( - ({ promptContexts, quickPrompts, onQuickPromptsChange }) => { - const [isModalVisible, setIsModalVisible] = useState(false); - - // Local state for quick prompts (returned to parent on save via onQuickPromptsChange()) - const [updatedQuickPrompts, setUpdatedQuickPrompts] = useState(quickPrompts); - - // Form options - const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); - // Prompt - const [prompt, setPrompt] = useState(''); - const handlePromptTextChange = useCallback((e: React.ChangeEvent) => { - setPrompt(e.target.value); - }, []); - // Color - const [color, setColor, errors] = useColorPickerState(DEFAULT_COLOR); - const handleColorChange = useCallback( - (text, { hex, isValid }) => { - if (selectedQuickPrompt != null) { - setSelectedQuickPrompt({ - ...selectedQuickPrompt, - color: text, - }); - } - setColor(text, { hex, isValid }); - }, - [selectedQuickPrompt, setColor] - ); - // Prompt Contexts/Categories - const [selectedPromptContexts, setSelectedPromptContexts] = useState( - [] - ); - const onPromptContextSelectionChange = useCallback((pc: PromptContextTemplate[]) => { - setSelectedPromptContexts(pc); - }, []); - - // When top level quick prompt selection changes - const onQuickPromptSelectionChange = useCallback( - (quickPrompt?: QuickPrompt | string) => { - const newQuickPrompt: QuickPrompt | undefined = - typeof quickPrompt === 'string' - ? { - title: quickPrompt ?? '', - prompt: '', - color: DEFAULT_COLOR, - categories: [], - } - : quickPrompt; - - setSelectedQuickPrompt(newQuickPrompt); - setPrompt(newQuickPrompt?.prompt ?? ''); - setColor(newQuickPrompt?.color ?? DEFAULT_COLOR, { - hex: newQuickPrompt?.color ?? DEFAULT_COLOR, - isValid: true, - }); - // Map back to PromptContextTemplate's from QuickPrompt.categories - setSelectedPromptContexts( - promptContexts.filter((bpc) => - newQuickPrompt?.categories?.some((cat) => bpc?.category === cat) - ) ?? [] - ); - }, - [promptContexts, setColor] - ); - - const onQuickPromptDeleted = useCallback((title: string) => { - setUpdatedQuickPrompts((prev) => prev.filter((qp) => qp.title !== title)); - }, []); - - // Modal control functions - const cleanupAndCloseModal = useCallback(() => { - setIsModalVisible(false); - }, []); - - const handleCloseModal = useCallback(() => { - cleanupAndCloseModal(); - }, [cleanupAndCloseModal]); - - const handleSave = useCallback(() => { - onQuickPromptsChange(updatedQuickPrompts); - cleanupAndCloseModal(); - }, [cleanupAndCloseModal, onQuickPromptsChange, updatedQuickPrompts]); - - // useEffects - // Update quick prompts on any field change since editing is in place - useEffect(() => { - if (selectedQuickPrompt != null) { - setUpdatedQuickPrompts((prev) => { - const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); - if (alreadyExists) { - return prev.map((qp) => { - const categories = selectedPromptContexts.map((pc) => pc.category); - if (qp.title === selectedQuickPrompt.title) { - return { - ...qp, - color, - prompt, - categories, - }; - } - return qp; - }); - } else { - return [ - ...prev, - { - ...selectedQuickPrompt, - color, - prompt, - categories: selectedPromptContexts.map((pc) => pc.category), - }, - ]; - } - }); - } - }, [color, prompt, selectedPromptContexts, selectedQuickPrompt]); - - // Reset local state on modal open - useEffect(() => { - if (isModalVisible) { - setUpdatedQuickPrompts(quickPrompts); - } - }, [isModalVisible, quickPrompts]); - - return ( - <> - setIsModalVisible(true)} iconType="plus" size="xs"> - {i18n.ADD_QUICK_PROMPT} - - {isModalVisible && ( - - - {i18n.ADD_QUICK_PROMPT_MODAL_TITLE} - - - - - - - - - - - - - - - - - - - - - - {i18n.CANCEL} - - - {i18n.SAVE} - - - - )} - - ); - } -); - -AddQuickPromptModal.displayName = 'AddQuickPromptModal'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx index c14d533ee96fa6..8cde7aceabbc90 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx @@ -12,6 +12,7 @@ import { PromptContextTemplate } from '../../../..'; import * as i18n from './translations'; interface Props { + isDisabled?: boolean; onPromptContextSelectionChange: (promptContexts: PromptContextTemplate[]) => void; promptContexts: PromptContextTemplate[]; selectedPromptContexts?: PromptContextTemplate[]; @@ -23,7 +24,7 @@ export type PromptContextSelectorOption = EuiComboBoxOptionOption<{ category: st * Selector for choosing multiple Prompt Context Categories */ export const PromptContextSelector: React.FC = React.memo( - ({ onPromptContextSelectionChange, promptContexts, selectedPromptContexts = [] }) => { + ({ isDisabled, onPromptContextSelectionChange, promptContexts, selectedPromptContexts = [] }) => { // ComboBox options const options = useMemo( () => @@ -85,6 +86,9 @@ export const PromptContextSelector: React.FC = React.memo( return ( void; onQuickPromptSelectionChange: (quickPrompt?: QuickPrompt | string) => void; quickPrompts: QuickPrompt[]; @@ -34,7 +35,13 @@ export type QuickPromptSelectorOption = EuiComboBoxOptionOption<{ isDefault: boo * Selector for choosing and deleting Quick Prompts */ export const QuickPromptSelector: React.FC = React.memo( - ({ quickPrompts, onQuickPromptDeleted, onQuickPromptSelectionChange, selectedQuickPrompt }) => { + ({ + isDisabled = false, + quickPrompts, + onQuickPromptDeleted, + onQuickPromptSelectionChange, + selectedQuickPrompt, + }) => { // Form options const [options, setOptions] = useState( quickPrompts.map((qp) => ({ @@ -169,6 +176,8 @@ export const QuickPromptSelector: React.FC = React.memo( return ( = React.memo( onChange={onChange} onCreateOption={onCreateOption} renderOption={renderOption} + fullWidth /> ); } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts index 9cff7417c181b4..066463c0eace6d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const QUICK_PROMPT_SELECTOR = i18n.translate( 'xpack.elasticAssistant.assistant.quickPrompts.quickPromptSelector.ariaLabel', { - defaultMessage: 'Select to edit, or type to create new', + defaultMessage: 'Select or type to create new...', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx new file mode 100644 index 00000000000000..53a7431f3dc3a4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx @@ -0,0 +1,235 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { + EuiFormRow, + EuiColorPicker, + EuiTextArea, + EuiTitle, + EuiText, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; + +import { EuiSetColorMethod } from '@elastic/eui/src/services/color_picker/color_picker'; +import { css } from '@emotion/react'; +import { PromptContextTemplate } from '../../../..'; +import * as i18n from './translations'; +import { QuickPrompt } from '../types'; +import { QuickPromptSelector } from '../quick_prompt_selector/quick_prompt_selector'; +import { PromptContextSelector } from '../prompt_context_selector/prompt_context_selector'; +import { useAssistantContext } from '../../../assistant_context'; + +const DEFAULT_COLOR = '#D36086'; + +interface Props { + onSelectedQuickPromptChange: (quickPrompt?: QuickPrompt) => void; + quickPromptSettings: QuickPrompt[]; + selectedQuickPrompt: QuickPrompt | undefined; + setUpdatedQuickPromptSettings: React.Dispatch>; +} + +/** + * Settings adding/removing quick prompts. Configure name, color, prompt and category. + */ +export const QuickPromptSettings: React.FC = React.memo( + ({ + onSelectedQuickPromptChange, + quickPromptSettings, + selectedQuickPrompt, + setUpdatedQuickPromptSettings, + }) => { + const { basePromptContexts } = useAssistantContext(); + + // Prompt + const prompt = useMemo(() => selectedQuickPrompt?.prompt ?? '', [selectedQuickPrompt?.prompt]); + + const handlePromptChange = useCallback( + (e: React.ChangeEvent) => { + if (selectedQuickPrompt != null) { + setUpdatedQuickPromptSettings((prev) => { + const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); + + if (alreadyExists) { + return prev.map((qp) => { + if (qp.title === selectedQuickPrompt.title) { + return { + ...qp, + prompt: e.target.value, + }; + } + return qp; + }); + } + + return prev; + }); + } + }, + [selectedQuickPrompt, setUpdatedQuickPromptSettings] + ); + + // Color + const selectedColor = useMemo( + () => selectedQuickPrompt?.color ?? DEFAULT_COLOR, + [selectedQuickPrompt?.color] + ); + + const handleColorChange = useCallback( + (color, { hex, isValid }) => { + if (selectedQuickPrompt != null) { + setUpdatedQuickPromptSettings((prev) => { + const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); + + if (alreadyExists) { + return prev.map((qp) => { + if (qp.title === selectedQuickPrompt.title) { + return { + ...qp, + color, + }; + } + return qp; + }); + } + return prev; + }); + } + }, + [selectedQuickPrompt, setUpdatedQuickPromptSettings] + ); + + // Prompt Contexts + const selectedPromptContexts = useMemo( + () => + basePromptContexts.filter((bpc) => + selectedQuickPrompt?.categories?.some((cat) => bpc?.category === cat) + ) ?? [], + [basePromptContexts, selectedQuickPrompt?.categories] + ); + + const onPromptContextSelectionChange = useCallback( + (pc: PromptContextTemplate[]) => { + if (selectedQuickPrompt != null) { + setUpdatedQuickPromptSettings((prev) => { + const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); + + if (alreadyExists) { + return prev.map((qp) => { + if (qp.title === selectedQuickPrompt.title) { + return { + ...qp, + categories: pc.map((p) => p.category), + }; + } + return qp; + }); + } + return prev; + }); + } + }, + [selectedQuickPrompt, setUpdatedQuickPromptSettings] + ); + + // When top level quick prompt selection changes + const onQuickPromptSelectionChange = useCallback( + (quickPrompt?: QuickPrompt | string) => { + const isNew = typeof quickPrompt === 'string'; + const newSelectedQuickPrompt: QuickPrompt | undefined = isNew + ? { + title: quickPrompt ?? '', + prompt: '', + color: DEFAULT_COLOR, + categories: [], + } + : quickPrompt; + + if (newSelectedQuickPrompt != null) { + setUpdatedQuickPromptSettings((prev) => { + const alreadyExists = prev.some((qp) => qp.title === newSelectedQuickPrompt.title); + + if (!alreadyExists) { + return [...prev, newSelectedQuickPrompt]; + } + + return prev; + }); + } + + onSelectedQuickPromptChange(newSelectedQuickPrompt); + }, + [onSelectedQuickPromptChange, setUpdatedQuickPromptSettings] + ); + + const onQuickPromptDeleted = useCallback( + (title: string) => { + setUpdatedQuickPromptSettings((prev) => prev.filter((qp) => qp.title !== title)); + }, + [setUpdatedQuickPromptSettings] + ); + + return ( + <> + +

{i18n.SETTINGS_TITLE}

+
+ + {i18n.SETTINGS_DESCRIPTION} + + + + + + + + + + + + + + + + + + + ); + } +); + +QuickPromptSettings.displayName = 'AddQuickPromptModal'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts similarity index 54% rename from x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/translations.ts rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts index 72ada2e7ad0fdb..e388f2fe1d884c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts @@ -7,49 +7,57 @@ import { i18n } from '@kbn/i18n'; -export const ADD_QUICK_PROMPT = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.addQuickPromptTitle', +export const SETTINGS_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.quickPrompts.settings.settingsTitle', { - defaultMessage: 'Add quick prompt...', + defaultMessage: 'Quick Prompts', + } +); + +export const SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.quickPrompts.settings.settingsDescription', + { + defaultMessage: + 'Create and manage Quick Prompts. Quick Prompts are shortcuts to common actions.', } ); export const ADD_QUICK_PROMPT_MODAL_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.modalTitle', + 'xpack.elasticAssistant.assistant.quickPrompts.settings.modalTitle', { defaultMessage: 'Quick Prompts', } ); export const QUICK_PROMPT_NAME = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.nameLabel', + 'xpack.elasticAssistant.assistant.quickPrompts.settings.nameLabel', { defaultMessage: 'Name', } ); export const QUICK_PROMPT_PROMPT = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.promptLabel', + 'xpack.elasticAssistant.assistant.quickPrompts.settings.promptLabel', { defaultMessage: 'Prompt', } ); export const QUICK_PROMPT_BADGE_COLOR = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.badgeColorLabel', + 'xpack.elasticAssistant.assistant.quickPrompts.settings.badgeColorLabel', { defaultMessage: 'Badge color', } ); -export const QUICK_PROMPT_CATEGORIES = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.categoriesLabel', +export const QUICK_PROMPT_CONTEXTS = i18n.translate( + 'xpack.elasticAssistant.assistant.quickPrompts.settings.contextsLabel', { - defaultMessage: 'Categories', + defaultMessage: 'Contexts', } ); -export const QUICK_PROMPT_CATEGORIES_HELP_TEXT = i18n.translate( - 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.categoriesHelpText', +export const QUICK_PROMPT_CONTEXTS_HELP_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.quickPrompts.settings.contextsHelpText', { defaultMessage: 'Select the Prompt Contexts that this Quick Prompt will be available for. Selecting none will make this Quick Prompt available at all times.', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx index d66ed24d2426a8..f4b858ee796f1f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx @@ -6,14 +6,13 @@ */ import React, { useCallback, useMemo, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; -import { QuickPrompt } from '../../..'; import * as i18n from './translations'; -import { AddQuickPromptModal } from './add_quick_prompt_modal/add_quick_prompt_modal'; import { useAssistantContext } from '../../assistant_context'; +import { QUICK_PROMPTS_TAB } from '../settings/assistant_settings'; const QuickPromptsFlexGroup = styled(EuiFlexGroup)` margin: 16px; @@ -30,7 +29,7 @@ interface QuickPromptsProps { * and localstorage for storing new and edited prompts. */ export const QuickPrompts: React.FC = React.memo(({ setInput }) => { - const { allQuickPrompts, basePromptContexts, promptContexts, setAllQuickPrompts } = + const { allQuickPrompts, promptContexts, setIsSettingsModalVisible, setSelectedSettingsTab } = useAssistantContext(); const contextFilteredQuickPrompts = useMemo(() => { @@ -62,13 +61,12 @@ export const QuickPrompts: React.FC = React.memo(({ setInput }, [closeOverflowPopover, setInput] ); - // Callback for manage modal, saves to local storage on change - const onQuickPromptsChange = useCallback( - (newQuickPrompts: QuickPrompt[]) => { - setAllQuickPrompts(newQuickPrompts); - }, - [setAllQuickPrompts] - ); + + const showQuickPromptSettings = useCallback(() => { + setIsSettingsModalVisible(true); + setSelectedSettingsTab(QUICK_PROMPTS_TAB); + }, [setIsSettingsModalVisible, setSelectedSettingsTab]); + return ( {contextFilteredQuickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => ( @@ -114,11 +112,9 @@ export const QuickPrompts: React.FC = React.memo(({ setInput )} - + + {i18n.ADD_QUICK_PROMPT} + ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts index e2d3fd5ef78247..38d5fdb0950c49 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts @@ -7,6 +7,12 @@ import { i18n } from '@kbn/i18n'; +export const ADD_QUICK_PROMPT = i18n.translate( + 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptTitle', + { + defaultMessage: 'Add quick prompt...', + } +); export const QUICK_PROMPT_OVERFLOW_ARIA = i18n.translate( 'xpack.elasticAssistant.assistant.quickPrompts.overflowAriaTitle', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/advanced_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/advanced_settings.tsx new file mode 100644 index 00000000000000..ded138fcc2b624 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/advanced_settings.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiFormRow, EuiTitle, EuiText, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface Props { + onAdvancedSettingsChange?: () => void; +} + +/** + * Advanced Settings -- your catch-all container for settings that don't have a home elsewhere. + */ +export const AdvancedSettings: React.FC = React.memo(({ onAdvancedSettingsChange }) => { + return ( + <> + +

{i18n.SETTINGS_TITLE}

+
+ + + {i18n.SETTINGS_DESCRIPTION} + + + + + <>{'Disable LocalStorage'} + + + <>{'Clear LocalStorage'} + + + <>{'Reset Something Else'} + + + ); +}); + +AdvancedSettings.displayName = 'AdvancedSettings'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/translations.ts new file mode 100644 index 00000000000000..64642e97b8cc53 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/advanced_settings/translations.ts @@ -0,0 +1,21 @@ +/* + * 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 SETTINGS_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.advancedSettings.settingsTitle', + { + defaultMessage: 'Advanced Settings', + } +); +export const SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.advancedSettings.settingsDescription', + { + defaultMessage: "They're not further along, they just have a different set of problems.", + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx new file mode 100644 index 00000000000000..7fb8d0cff7c3a1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx @@ -0,0 +1,315 @@ +/* + * 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, { useCallback, useEffect, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiIcon, + EuiModal, + EuiModalFooter, + EuiKeyPadMenu, + EuiKeyPadMenuItem, + EuiPage, + EuiPageBody, + EuiPageSidebar, + EuiSplitPanel, +} from '@elastic/eui'; + +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; +import { css } from '@emotion/react'; +import { Conversation, Prompt, QuickPrompt } from '../../..'; +import * as i18n from './translations'; +import { useAssistantContext } from '../../assistant_context'; +import { AnonymizationSettings } from '../../data_anonymization/settings/anonymization_settings'; +import { QuickPromptSettings } from '../quick_prompts/quick_prompt_settings/quick_prompt_settings'; +import { SystemPromptSettings } from '../prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings'; +import { AdvancedSettings } from './advanced_settings/advanced_settings'; +import { ConversationSettings } from '../conversations/conversation_settings/conversation_settings'; +import { TEST_IDS } from '../constants'; +import { useSettingsUpdater } from './use_settings_updater/use_settings_updater'; + +const StyledEuiModal = styled(EuiModal)` + width: 800px; + height: 575px; +`; + +export const CONVERSATIONS_TAB = 'CONVERSATION_TAB' as const; +export const QUICK_PROMPTS_TAB = 'QUICK_PROMPTS_TAB' as const; +export const SYSTEM_PROMPTS_TAB = 'SYSTEM_PROMPTS_TAB' as const; +export const ANONYMIZATION_TAB = 'ANONYMIZATION_TAB' as const; +export const FUNCTIONS_TAB = 'FUNCTIONS_TAB' as const; +export const ADVANCED_TAB = 'ADVANCED_TAB' as const; + +export type SettingsTabs = + | typeof CONVERSATIONS_TAB + | typeof QUICK_PROMPTS_TAB + | typeof SYSTEM_PROMPTS_TAB + | typeof ANONYMIZATION_TAB + | typeof FUNCTIONS_TAB + | typeof ADVANCED_TAB; +interface Props { + onClose: ( + event?: React.KeyboardEvent | React.MouseEvent + ) => void; + onSave: () => void; + selectedConversation: Conversation; + setSelectedConversationId: React.Dispatch>; +} + +/** + * Modal for overall Assistant Settings, including conversation settings, quick prompts, system prompts, + * anonymization, functions (coming soon!), and advanced settings. + */ +export const AssistantSettings: React.FC = React.memo( + ({ + onClose, + onSave, + selectedConversation: defaultSelectedConversation, + setSelectedConversationId, + }) => { + const { actionTypeRegistry, http, selectedSettingsTab, setSelectedSettingsTab } = + useAssistantContext(); + const { + conversationSettings, + defaultAllow, + defaultAllowReplacement, + quickPromptSettings, + systemPromptSettings, + setUpdatedConversationSettings, + setUpdatedDefaultAllow, + setUpdatedDefaultAllowReplacement, + setUpdatedQuickPromptSettings, + setUpdatedSystemPromptSettings, + saveSettings, + } = useSettingsUpdater(); + + // Local state for saving previously selected items so tab switching is friendlier + // Conversation Selection State + const [selectedConversation, setSelectedConversation] = useState( + () => { + return conversationSettings[defaultSelectedConversation.id]; + } + ); + const onHandleSelectedConversationChange = useCallback((conversation?: Conversation) => { + setSelectedConversation(conversation); + }, []); + useEffect(() => { + if (selectedConversation != null) { + setSelectedConversation(conversationSettings[selectedConversation.id]); + } + }, [conversationSettings, selectedConversation]); + + // Quick Prompt Selection State + const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); + const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: QuickPrompt) => { + setSelectedQuickPrompt(quickPrompt); + }, []); + useEffect(() => { + if (selectedQuickPrompt != null) { + setSelectedQuickPrompt( + quickPromptSettings.find((q) => q.title === selectedQuickPrompt.title) + ); + } + }, [quickPromptSettings, selectedQuickPrompt]); + + // System Prompt Selection State + const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); + const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: Prompt) => { + setSelectedSystemPrompt(systemPrompt); + }, []); + useEffect(() => { + if (selectedSystemPrompt != null) { + setSelectedSystemPrompt(systemPromptSettings.find((p) => p.id === selectedSystemPrompt.id)); + } + }, [selectedSystemPrompt, systemPromptSettings]); + + const handleSave = useCallback(() => { + // If the selected conversation is deleted, we need to select a new conversation to prevent a crash creating a conversation that already exists + const isSelectedConversationDeleted = + conversationSettings[defaultSelectedConversation.id] == null; + const newSelectedConversationId: string | undefined = Object.keys(conversationSettings)[0]; + if (isSelectedConversationDeleted && newSelectedConversationId != null) { + setSelectedConversationId(conversationSettings[newSelectedConversationId].id); + } + saveSettings(); + onSave(); + }, [ + conversationSettings, + defaultSelectedConversation.id, + onSave, + saveSettings, + setSelectedConversationId, + ]); + + return ( + + + + + setSelectedSettingsTab(CONVERSATIONS_TAB)} + > + <> + + + + + setSelectedSettingsTab(QUICK_PROMPTS_TAB)} + > + <> + + + + + setSelectedSettingsTab(SYSTEM_PROMPTS_TAB)} + > + + + + setSelectedSettingsTab(ANONYMIZATION_TAB)} + > + + + + + + + + {selectedSettingsTab === CONVERSATIONS_TAB && ( + + )} + {selectedSettingsTab === QUICK_PROMPTS_TAB && ( + + )} + {selectedSettingsTab === SYSTEM_PROMPTS_TAB && ( + + )} + {selectedSettingsTab === ANONYMIZATION_TAB && ( + + )} + {selectedSettingsTab === FUNCTIONS_TAB && <>} + {selectedSettingsTab === ADVANCED_TAB && } + + + + + {i18n.CANCEL} + + + + {i18n.SAVE} + + + + + + + + ); + } +); + +AssistantSettings.displayName = 'AssistantSettings'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx new file mode 100644 index 00000000000000..e3ba463b387dbc --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx @@ -0,0 +1,74 @@ +/* + * 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, { useCallback } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; + +import { Conversation } from '../../..'; +import { AssistantSettings, CONVERSATIONS_TAB } from './assistant_settings'; +import * as i18n from './translations'; +import { useAssistantContext } from '../../assistant_context'; + +interface Props { + selectedConversation: Conversation; + setSelectedConversationId: React.Dispatch>; + isDisabled?: boolean; +} + +/** + * Gear button that opens the assistant settings modal + */ +export const AssistantSettingsButton: React.FC = React.memo( + ({ isDisabled = false, selectedConversation, setSelectedConversationId }) => { + const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } = + useAssistantContext(); + + // Modal control functions + const cleanupAndCloseModal = useCallback(() => { + setIsSettingsModalVisible(false); + }, [setIsSettingsModalVisible]); + + const handleCloseModal = useCallback(() => { + cleanupAndCloseModal(); + }, [cleanupAndCloseModal]); + + const handleSave = useCallback(() => { + cleanupAndCloseModal(); + }, [cleanupAndCloseModal]); + + const handleShowConversationSettings = useCallback(() => { + setSelectedSettingsTab(CONVERSATIONS_TAB); + setIsSettingsModalVisible(true); + }, [setIsSettingsModalVisible, setSelectedSettingsTab]); + + return ( + <> + + + + + {isSettingsModalVisible && ( + + )} + + ); + } +); + +AssistantSettingsButton.displayName = 'AssistantSettingsButton'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/translations.ts new file mode 100644 index 00000000000000..157e9321a70c6f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/translations.ts @@ -0,0 +1,85 @@ +/* + * 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 SETTINGS = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsAriaLabel', + { + defaultMessage: 'Settings', + } +); + +export const SETTINGS_TOOLTIP = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsTooltip', + { + defaultMessage: 'Settings', + } +); + +export const CONVERSATIONS_MENU_ITEM = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsConversationsMenuItemTitle', + { + defaultMessage: 'Conversations', + } +); + +export const QUICK_PROMPTS_MENU_ITEM = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsQuickPromptsMenuItemTitle', + { + defaultMessage: 'Quick Prompts', + } +); + +export const SYSTEM_PROMPTS_MENU_ITEM = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsSystemPromptsMenuItemTitle', + { + defaultMessage: 'System Prompts', + } +); + +export const ANONYMIZATION_MENU_ITEM = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsAnonymizationMenuItemTitle', + { + defaultMessage: 'Anonymization', + } +); + +export const FUNCTIONS_MENU_ITEM = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsFunctionsMenuItemTitle', + { + defaultMessage: 'Functions', + } +); + +export const ADVANCED_MENU_ITEM = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.settingsAdvancedMenuItemTitle', + { + defaultMessage: 'Advanced', + } +); + +export const ADD_SYSTEM_PROMPT_MODAL_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.modalTitle', + { + defaultMessage: 'System Prompts', + } +); + +export const CANCEL = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.slCancelButtonTitle', + { + defaultMessage: 'Cancel', + } +); + +export const SAVE = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.slSaveButtonTitle', + { + defaultMessage: 'Save', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx new file mode 100644 index 00000000000000..e40ae00b69720c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx @@ -0,0 +1,108 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { Prompt, QuickPrompt } from '../../../..'; +import { UseAssistantContext, useAssistantContext } from '../../../assistant_context'; + +interface UseSettingsUpdater { + conversationSettings: UseAssistantContext['conversations']; + defaultAllow: string[]; + defaultAllowReplacement: string[]; + quickPromptSettings: QuickPrompt[]; + resetSettings: () => void; + systemPromptSettings: Prompt[]; + setUpdatedDefaultAllow: React.Dispatch>; + setUpdatedDefaultAllowReplacement: React.Dispatch>; + setUpdatedConversationSettings: React.Dispatch< + React.SetStateAction + >; + setUpdatedQuickPromptSettings: React.Dispatch>; + setUpdatedSystemPromptSettings: React.Dispatch>; + saveSettings: () => void; +} + +export const useSettingsUpdater = (): UseSettingsUpdater => { + // Initial state from assistant context + const { + allQuickPrompts, + allSystemPrompts, + conversations, + defaultAllow, + defaultAllowReplacement, + setAllQuickPrompts, + setAllSystemPrompts, + setConversations, + setDefaultAllow, + setDefaultAllowReplacement, + } = useAssistantContext(); + + /** + * Pending updating state + */ + // Conversations + const [updatedConversationSettings, setUpdatedConversationSettings] = + useState(conversations); + // Quick Prompts + const [updatedQuickPromptSettings, setUpdatedQuickPromptSettings] = + useState(allQuickPrompts); + // System Prompts + const [updatedSystemPromptSettings, setUpdatedSystemPromptSettings] = + useState(allSystemPrompts); + // Anonymization + const [updatedDefaultAllow, setUpdatedDefaultAllow] = useState(defaultAllow); + const [updatedDefaultAllowReplacement, setUpdatedDefaultAllowReplacement] = + useState(defaultAllowReplacement); + + /** + * Reset all pending settings + */ + const resetSettings = useCallback((): void => { + setUpdatedConversationSettings(conversations); + setUpdatedQuickPromptSettings(allQuickPrompts); + setUpdatedSystemPromptSettings(allSystemPrompts); + setUpdatedDefaultAllow(defaultAllow); + setUpdatedDefaultAllowReplacement(defaultAllowReplacement); + }, [allQuickPrompts, allSystemPrompts, conversations, defaultAllow, defaultAllowReplacement]); + + /** + * Save all pending settings + */ + const saveSettings = useCallback((): void => { + setAllQuickPrompts(updatedQuickPromptSettings); + setAllSystemPrompts(updatedSystemPromptSettings); + setConversations(updatedConversationSettings); + setDefaultAllow(updatedDefaultAllow); + setDefaultAllowReplacement(updatedDefaultAllowReplacement); + }, [ + setAllQuickPrompts, + setAllSystemPrompts, + setConversations, + setDefaultAllow, + setDefaultAllowReplacement, + updatedConversationSettings, + updatedDefaultAllow, + updatedDefaultAllowReplacement, + updatedQuickPromptSettings, + updatedSystemPromptSettings, + ]); + + return { + conversationSettings: updatedConversationSettings, + defaultAllow: updatedDefaultAllow, + defaultAllowReplacement: updatedDefaultAllowReplacement, + quickPromptSettings: updatedQuickPromptSettings, + resetSettings, + systemPromptSettings: updatedSystemPromptSettings, + saveSettings, + setUpdatedDefaultAllow, + setUpdatedDefaultAllowReplacement, + setUpdatedConversationSettings, + setUpdatedQuickPromptSettings, + setUpdatedSystemPromptSettings, + }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx index 33dca8c6e36097..83e6c3e64fd524 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx @@ -17,8 +17,10 @@ export interface StreamingTextProps { export const StreamingText: React.FC = React.memo( ({ text, children, chunkSize = 5, delay = 100, onStreamingComplete }) => { - const [displayText, setDisplayText] = useState(delay === 0 ? text : ''); - const [isStreamingComplete, setIsStreamingComplete] = useState(delay === 0); + const [displayText, setDisplayText] = useState(delay > 0 ? '' : text); + const [isStreamingComplete, setIsStreamingComplete] = useState( + delay == null || delay === 0 + ); useEffect(() => { if (delay === 0) { @@ -30,6 +32,7 @@ export const StreamingText: React.FC = React.memo { if (isStreamingComplete || delay === 0) { + setDisplayText(text); return; } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts index 1b5c24dff00d66..580e02247e3ee3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts @@ -18,46 +18,6 @@ export const DEFAULT_ASSISTANT_TITLE = i18n.translate( } ); -export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.missingConnectorCalloutTitle', - { - defaultMessage: 'The current conversation is missing a connector configuration', - } -); - -export const MISSING_CONNECTOR_CALLOUT_DESCRIPTION = i18n.translate( - 'xpack.elasticAssistant.assistant.missingConnectorCalloutDescription', - { - defaultMessage: 'Select a connector from the conversation settings to continue', - } -); - -// Settings -export const SETTINGS_TITLE = i18n.translate('xpack.elasticAssistant.assistant.settingsTitle', { - defaultMessage: 'Conversation settings', -}); - -export const SETTINGS_CONNECTOR_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.connectorTitle', - { - defaultMessage: 'Connector', - } -); - -export const SETTINGS_PROMPT_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.promptTitle', - { - defaultMessage: 'System prompt', - } -); - -export const SETTINGS_PROMPT_HELP_TEXT_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.promptHelpTextTitle', - { - defaultMessage: 'Context provided before every conversation', - } -); - export const SHOW_ANONYMIZED = i18n.translate( 'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleLabel', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx index 60bb89c4e355f8..033d81197ce940 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx @@ -126,11 +126,6 @@ export const BASE_CONVERSATIONS: Record = { stream: true, }, }, - // { - // role: 'assistant', - // content: i18n.WELCOME_NO_CONNECTOR_PRIVILEGES, - // timestamp: '', - // }, ], apiConfig: {}, }, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 3bce0e7f860411..f0a9b552a2be35 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -32,6 +32,7 @@ import { QUICK_PROMPT_LOCAL_STORAGE_KEY, SYSTEM_PROMPT_LOCAL_STORAGE_KEY, } from './constants'; +import { CONVERSATIONS_TAB, SettingsTabs } from '../assistant/settings/assistant_settings'; export interface ShowAssistantOverlayProps { showOverlay: boolean; @@ -74,7 +75,7 @@ interface AssistantProviderProps { title?: string; } -interface UseAssistantContext { +export interface UseAssistantContext { actionTypeRegistry: ActionTypeRegistryContract; augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; allQuickPrompts: QuickPrompt[]; @@ -100,16 +101,20 @@ interface UseAssistantContext { showAnonymizedValues: boolean; }) => EuiCommentProps[]; http: HttpSetup; + isSettingsModalVisible: boolean; localStorageLastConversationId: string | undefined; promptContexts: Record; nameSpace: string; registerPromptContext: RegisterPromptContext; + selectedSettingsTab: SettingsTabs; setAllQuickPrompts: React.Dispatch>; setAllSystemPrompts: React.Dispatch>; setConversations: React.Dispatch>>; setDefaultAllow: React.Dispatch>; setDefaultAllowReplacement: React.Dispatch>; + setIsSettingsModalVisible: React.Dispatch>; setLastConversationId: React.Dispatch>; + setSelectedSettingsTab: React.Dispatch>; setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void; showAssistantOverlay: ShowAssistantOverlay; title: string; @@ -204,6 +209,12 @@ export const AssistantProvider: React.FC = ({ (showAssistant) => {} ); + /** + * Settings State + */ + const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false); + const [selectedSettingsTab, setSelectedSettingsTab] = useState(CONVERSATIONS_TAB); + const [conversations, setConversationsInternal] = useState(getInitialConversations()); const conversationIds = useMemo(() => Object.keys(conversations).sort(), [conversations]); @@ -253,14 +264,18 @@ export const AssistantProvider: React.FC = ({ docLinks, getComments, http, + isSettingsModalVisible, promptContexts, nameSpace, registerPromptContext, + selectedSettingsTab, setAllQuickPrompts: setLocalStorageQuickPrompts, setAllSystemPrompts: setLocalStorageSystemPrompts, setConversations: onConversationsUpdated, setDefaultAllow, setDefaultAllowReplacement, + setIsSettingsModalVisible, + setSelectedSettingsTab, setShowAssistantOverlay, showAssistantOverlay, title, @@ -283,6 +298,7 @@ export const AssistantProvider: React.FC = ({ docLinks, getComments, http, + isSettingsModalVisible, localStorageLastConversationId, localStorageQuickPrompts, localStorageSystemPrompts, @@ -290,11 +306,14 @@ export const AssistantProvider: React.FC = ({ onConversationsUpdated, promptContexts, registerPromptContext, + selectedSettingsTab, setDefaultAllow, setDefaultAllowReplacement, + setIsSettingsModalVisible, setLocalStorageLastConversationId, setLocalStorageQuickPrompts, setLocalStorageSystemPrompts, + setSelectedSettingsTab, showAssistantOverlay, title, unRegisterPromptContext, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx index e0b0ff128cfa02..dd31844938e7fd 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx @@ -47,6 +47,7 @@ export interface Conversation { connectorId?: string; defaultSystemPromptId?: string; provider?: OpenAiProviderType; + model?: string; }; id: string; messages: Message[]; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx index 51150264c79885..a70ab2a16e5cba 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx @@ -13,7 +13,6 @@ import * as i18n from '../translations'; export interface ConnectorButtonProps { setIsConnectorModalVisible: React.Dispatch>; - connectorAdded?: boolean; } /** @@ -22,18 +21,15 @@ export interface ConnectorButtonProps { * connector add logic. */ export const ConnectorButton: React.FC = React.memo( - ({ setIsConnectorModalVisible, connectorAdded = false }) => { + ({ setIsConnectorModalVisible }) => { return ( } - title={connectorAdded ? i18n.CONNECTOR_ADDED_TITLE : i18n.ADD_CONNECTOR_TITLE} - isDisabled={connectorAdded} - description={ - connectorAdded ? i18n.CONNECTOR_ADDED_DESCRIPTION : i18n.ADD_CONNECTOR_DESCRIPTION - } + title={i18n.ADD_CONNECTOR_TITLE} + description={i18n.ADD_CONNECTOR_DESCRIPTION} onClick={() => setIsConnectorModalVisible(true)} /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx new file mode 100644 index 00000000000000..99e8d98b438b22 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx @@ -0,0 +1,58 @@ +/* + * 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, { useCallback } from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import * as i18n from '../translations'; +import { useAssistantContext } from '../../assistant_context'; +import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings'; + +/** + * Error callout to be displayed when there is no connector configured for a conversation. Includes deep-link + * to conversation settings to quickly resolve. + * + * TODO: Add 'quick fix' button to just pick a connector + * TODO: Add setting for 'default connector' so we can auto-resolve and not even show this + */ +export const ConnectorMissingCallout: React.FC = React.memo(() => { + const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } = + useAssistantContext(); + + const onConversationSettingsClicked = useCallback(() => { + if (!isSettingsModalVisible) { + setIsSettingsModalVisible(true); + setSelectedSettingsTab(CONVERSATIONS_TAB); + } + }, [isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab]); + + return ( + +

+ {' '} + + {i18n.MISSING_CONNECTOR_CONVERSATION_SETTINGS_LINK} + + ), + }} + /> +

+
+ ); +}); +ConnectorMissingCallout.displayName = 'ConnectorMissingCallout'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx index 010b4be5ad4b0f..870952dde56b5a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx @@ -20,17 +20,17 @@ import { GEN_AI_CONNECTOR_ID, OpenAiProviderType, } from '@kbn/stack-connectors-plugin/public/common'; -import { Conversation } from '../../assistant_context/types'; import { useLoadConnectors } from '../use_load_connectors'; -import { useConversation } from '../../assistant/use_conversation'; import * as i18n from '../translations'; import { useLoadActionTypes } from '../use_load_action_types'; export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR'; interface Props { actionTypeRegistry: ActionTypeRegistryContract; - conversation: Conversation; http: HttpSetup; + isDisabled?: boolean; + onConnectorSelectionChange: (connectorId: string, provider: OpenAiProviderType) => void; + selectedConnectorId?: string; onConnectorModalVisibilityChange?: (isVisible: boolean) => void; } @@ -39,9 +39,14 @@ interface Config { } export const ConnectorSelector: React.FC = React.memo( - ({ actionTypeRegistry, conversation, http, onConnectorModalVisibilityChange }) => { - const { setApiConfig } = useConversation(); - + ({ + actionTypeRegistry, + http, + isDisabled = false, + onConnectorModalVisibilityChange, + selectedConnectorId, + onConnectorSelectionChange, + }) => { // Connector Modal State const [isConnectorModalVisible, setIsConnectorModalVisible] = useState(false); const { data: actionTypes } = useLoadActionTypes({ http }); @@ -124,49 +129,33 @@ export const ConnectorSelector: React.FC = React.memo( const apiProvider = ( connectors?.find((c) => c.id === connectorId) as ActionConnectorProps )?.config.apiProvider as OpenAiProviderType; - setApiConfig({ - conversationId: conversation.id, - apiConfig: { - ...conversation.apiConfig, - connectorId, - provider: apiProvider, - }, - }); + onConnectorSelectionChange(connectorId, apiProvider); }, - [ - connectors, - conversation.apiConfig, - conversation.id, - setApiConfig, - onConnectorModalVisibilityChange, - ] + [connectors, onConnectorSelectionChange, onConnectorModalVisibilityChange] ); return ( <> {isConnectorModalVisible && ( { - setApiConfig({ - conversationId: conversation.id, - apiConfig: { - ...conversation.apiConfig, - connectorId: savedAction.id, - provider: (savedAction as ActionConnectorProps)?.config - .apiProvider as OpenAiProviderType, - }, - }); + onConnectorSelectionChange( + savedAction.id, + (savedAction as ActionConnectorProps)?.config + .apiProvider as OpenAiProviderType + ); refetchConnectors?.(); cleanupAndCloseModal(); }} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx index 063fc1961a2c06..e8a7940bca4d18 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx @@ -30,10 +30,8 @@ import * as i18n from '../translations'; import { useAssistantContext } from '../../assistant_context'; import { WELCOME_CONVERSATION_TITLE } from '../../assistant/use_conversation/translations'; -const MESSAGE_INDEX_BEFORE_CONNECTOR = 2; - const ConnectorButtonWrapper = styled.div` - margin-top: 20px; + margin-bottom: 10px; `; const SkipEuiText = styled(EuiText)` @@ -91,24 +89,40 @@ export const useConnectorSetup = ({ ); // User constants - const userName = conversation.theme?.user?.name ?? i18n.CONNECTOR_SETUP_USER_YOU; - const assistantName = conversation.theme?.assistant?.name ?? i18n.CONNECTOR_SETUP_USER_ASSISTANT; + const userName = useMemo( + () => conversation.theme?.user?.name ?? i18n.CONNECTOR_SETUP_USER_YOU, + [conversation.theme?.user?.name] + ); + const assistantName = useMemo( + () => conversation.theme?.assistant?.name ?? i18n.CONNECTOR_SETUP_USER_ASSISTANT, + [conversation.theme?.assistant?.name] + ); + const lastConversationMessageIndex = useMemo( + () => conversation.messages.length - 1, + [conversation.messages.length] + ); const [currentMessageIndex, setCurrentMessageIndex] = useState( // If connector is configured or conversation has already been replayed show all messages immediately isConnectorConfigured || conversationHasNoPresentationData(conversation) - ? MESSAGE_INDEX_BEFORE_CONNECTOR + ? lastConversationMessageIndex : 0 ); + const streamingTimeoutRef = useRef(undefined); + // Once streaming of previous message is complete, proceed to next message const onHandleMessageStreamingComplete = useCallback(() => { - const timeoutId = setTimeout(() => { + if (currentMessageIndex === lastConversationMessageIndex) { + clearTimeout(streamingTimeoutRef.current); + return; + } + streamingTimeoutRef.current = window.setTimeout(() => { bottomRef.current?.scrollIntoView({ block: 'end' }); return setCurrentMessageIndex(currentMessageIndex + 1); }, conversation.messages[currentMessageIndex]?.presentation?.delay ?? 0); - return () => clearTimeout(timeoutId); - }, [conversation.messages, currentMessageIndex]); + return () => clearTimeout(streamingTimeoutRef.current); + }, [conversation.messages, currentMessageIndex, lastConversationMessageIndex]); // Show button to add connector after last message has finished streaming const onHandleLastMessageStreamingComplete = useCallback(() => { @@ -120,8 +134,8 @@ export const useConnectorSetup = ({ // Show button to add connector after last message has finished streaming const handleSkipSetup = useCallback(() => { - setCurrentMessageIndex(MESSAGE_INDEX_BEFORE_CONNECTOR); - }, [setCurrentMessageIndex]); + setCurrentMessageIndex(lastConversationMessageIndex); + }, [lastConversationMessageIndex]); // Create EuiCommentProps[] from conversation messages const commentBody = useCallback( @@ -195,12 +209,9 @@ export const useConnectorSetup = ({ comments, prompt: (
- {(showAddConnectorButton || isConnectorConfigured) && ( + {showAddConnectorButton && ( - + )} {!showAddConnectorButton && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/model_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/model_selector.tsx new file mode 100644 index 00000000000000..bd585a6df1d61e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/model_selector.tsx @@ -0,0 +1,109 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import * as i18n from './translations'; + +export const MODEL_GPT_3_5_TURBO = 'gpt-3.5-turbo'; +export const MODEL_GPT_4 = 'gpt-4'; +const DEFAULT_MODELS = [MODEL_GPT_3_5_TURBO, MODEL_GPT_4]; + +interface Props { + onModelSelectionChange?: (model?: string) => void; + models?: string[]; + selectedModel?: string; +} + +/** + * Selector for choosing and deleting models + * + * TODO: Pull from API once connector supports it `GET https://api.openai.com/v1/models` as models are added/deprecated + */ +export const ModelSelector: React.FC = React.memo( + ({ models = DEFAULT_MODELS, onModelSelectionChange, selectedModel = DEFAULT_MODELS[0] }) => { + // Form options + const [options, setOptions] = useState( + models.map((model) => ({ + label: model, + })) + ); + const selectedOptions = useMemo(() => { + return selectedModel ? [{ label: selectedModel }] : []; + }, [selectedModel]); + + const handleSelectionChange = useCallback( + (modelSelectorOption: EuiComboBoxOptionOption[]) => { + const newModel = + modelSelectorOption.length === 0 + ? undefined + : models.find((model) => model === modelSelectorOption[0]?.label) ?? + modelSelectorOption[0]?.label; + onModelSelectionChange?.(newModel); + }, + [onModelSelectionChange, models] + ); + + // Callback for when user types to create a new model + const onCreateOption = useCallback( + (searchValue, flattenedOptions = []) => { + if (!searchValue || !searchValue.trim().toLowerCase()) { + return; + } + + const normalizedSearchValue = searchValue.trim().toLowerCase(); + const optionExists = + flattenedOptions.findIndex( + (option: EuiComboBoxOptionOption) => + option.label.trim().toLowerCase() === normalizedSearchValue + ) !== -1; + + const newOption = { + value: searchValue, + label: searchValue, + }; + + if (!optionExists) { + setOptions([...options, newOption]); + } + handleSelectionChange([newOption]); + }, + [handleSelectionChange, options] + ); + + // Callback for when user selects a model + const onChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + if (newOptions.length === 0) { + handleSelectionChange([]); + } else if (options.findIndex((o) => o.label === newOptions?.[0].label) !== -1) { + handleSelectionChange(newOptions); + } + }, + [handleSelectionChange, options] + ); + + return ( + + ); + } +); + +ModelSelector.displayName = 'ModelSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/translations.ts new file mode 100644 index 00000000000000..d379c8e1423335 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/models/model_selector/translations.ts @@ -0,0 +1,35 @@ +/* + * 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 PLACEHOLDER_TEXT = i18n.translate( + 'xpack.elasticAssistant.connectors.models.modelSelector.placeholderText', + { + defaultMessage: 'Select or type to create new...', + } +); +export const MODEL_TITLE = i18n.translate( + 'xpack.elasticAssistant.connectors.models.modelSelector.modelTitle', + { + defaultMessage: 'Model', + } +); + +export const HELP_LABEL = i18n.translate( + 'xpack.elasticAssistant.connectors.models.modelSelector.helpLabel', + { + defaultMessage: 'Model to use for this connector', + } +); + +export const CUSTOM_OPTION_TEXT = i18n.translate( + 'xpack.elasticAssistant.connectors.models.modelSelector.customOptionText', + { + defaultMessage: 'Create new Model named', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts index 923cb916df4e67..df6343f62b4e2e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts @@ -100,3 +100,17 @@ export const CONNECTOR_SETUP_SKIP = i18n.translate( defaultMessage: 'Click to skip...', } ); + +export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutTitle', + { + defaultMessage: 'The current conversation is missing a connector configuration', + } +); + +export const MISSING_CONNECTOR_CONVERSATION_SETTINGS_LINK = i18n.translate( + 'xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.conversationSettingsLink', + { + defaultMessage: 'Conversation Settings', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx index 583c0d8076a686..6bc8e93e3d5e23 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx @@ -10,6 +10,15 @@ import { render, fireEvent } from '@testing-library/react'; import { TestProviders } from '../../../mock/test_providers/test_providers'; import { AnonymizationSettings } from '.'; +import type { Props } from '.'; + +const props: Props = { + defaultAllow: ['foo', 'bar', 'baz', '@baz'], + defaultAllowReplacement: ['bar'], + pageSize: 5, + setUpdatedDefaultAllow: jest.fn(), + setUpdatedDefaultAllowReplacement: jest.fn(), +}; const mockUseAssistantContext = { allSystemPrompts: [ @@ -47,14 +56,12 @@ jest.mock('../../../assistant_context', () => { }); describe('AnonymizationSettings', () => { - const closeModal = jest.fn(); - beforeEach(() => jest.clearAllMocks()); it('renders the editor', () => { const { getByTestId } = render( - + ); @@ -64,11 +71,11 @@ describe('AnonymizationSettings', () => { it('does NOT call `setDefaultAllow` when `Reset` is clicked, because only local state is reset until the user clicks save', () => { const { getByTestId } = render( - + ); - fireEvent.click(getByTestId('reset')); + fireEvent.click(getByTestId('resetFields')); expect(mockUseAssistantContext.setDefaultAllow).not.toHaveBeenCalled(); }); @@ -76,11 +83,11 @@ describe('AnonymizationSettings', () => { it('does NOT call `setDefaultAllowReplacement` when `Reset` is clicked, because only local state is reset until the user clicks save', () => { const { getByTestId } = render( - + ); - fireEvent.click(getByTestId('reset')); + fireEvent.click(getByTestId('resetFields')); expect(mockUseAssistantContext.setDefaultAllowReplacement).not.toHaveBeenCalled(); }); @@ -88,7 +95,7 @@ describe('AnonymizationSettings', () => { it('renders the expected allowed stat content', () => { const { getByTestId } = render( - + ); @@ -100,7 +107,7 @@ describe('AnonymizationSettings', () => { it('renders the expected anonymized stat content', () => { const { getByTestId } = render( - + ); @@ -108,52 +115,4 @@ describe('AnonymizationSettings', () => { `${mockUseAssistantContext.defaultAllowReplacement.length}Anonymized` ); }); - - it('calls closeModal is called when the cancel button is clicked', () => { - const { getByTestId } = render( - - - - ); - fireEvent.click(getByTestId('cancel')); - expect(closeModal).toHaveBeenCalledTimes(1); - }); - - it('calls closeModal is called when the save button is clicked', () => { - const { getByTestId } = render( - - - - ); - fireEvent.click(getByTestId('cancel')); - expect(closeModal).toHaveBeenCalledTimes(1); - }); - - it('calls setDefaultAllow with the expected values when the save button is clicked', () => { - const { getByTestId } = render( - - - - ); - - fireEvent.click(getByTestId('save')); - - expect(mockUseAssistantContext.setDefaultAllow).toHaveBeenCalledWith( - mockUseAssistantContext.defaultAllow - ); - }); - - it('calls setDefaultAllowReplacement with the expected values when the save button is clicked', () => { - const { getByTestId } = render( - - - - ); - - fireEvent.click(getByTestId('save')); - - expect(mockUseAssistantContext.setDefaultAllowReplacement).toHaveBeenCalledWith( - mockUseAssistantContext.defaultAllowReplacement - ); - }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx index e833276d4850d7..31420e0452b1ba 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.tsx @@ -6,14 +6,14 @@ */ import { - EuiButton, - EuiButtonEmpty, - EuiCallOut, EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, EuiSpacer, + EuiText, + EuiTitle, } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; @@ -23,90 +23,71 @@ import type { BatchUpdateListItem } from '../../../data_anonymization_editor/con import { updateDefaults } from '../../../data_anonymization_editor/helpers'; import { AllowedStat } from '../../../data_anonymization_editor/stats/allowed_stat'; import { AnonymizedStat } from '../../../data_anonymization_editor/stats/anonymized_stat'; -import { CANCEL, SAVE } from '../anonymization_settings_modal/translations'; import * as i18n from './translations'; const StatFlexItem = styled(EuiFlexItem)` margin-right: ${({ theme }) => theme.eui.euiSizeL}; `; -interface Props { - closeModal?: () => void; +export interface Props { + defaultAllow: string[]; + defaultAllowReplacement: string[]; + pageSize?: number; + setUpdatedDefaultAllow: React.Dispatch>; + setUpdatedDefaultAllowReplacement: React.Dispatch>; } -const AnonymizationSettingsComponent: React.FC = ({ closeModal }) => { - const { - baseAllow, - baseAllowReplacement, - defaultAllow, - defaultAllowReplacement, - setDefaultAllow, - setDefaultAllowReplacement, - } = useAssistantContext(); - - // Local state for default allow and default allow replacement to allow for intermediate changes - const [localDefaultAllow, setLocalDefaultAllow] = useState(defaultAllow); - const [localDefaultAllowReplacement, setLocalDefaultAllowReplacement] = - useState(defaultAllowReplacement); +const AnonymizationSettingsComponent: React.FC = ({ + defaultAllow, + defaultAllowReplacement, + pageSize, + setUpdatedDefaultAllow, + setUpdatedDefaultAllowReplacement, +}) => { + const { baseAllow, baseAllowReplacement } = useAssistantContext(); const onListUpdated = useCallback( (updates: BatchUpdateListItem[]) => { updateDefaults({ - defaultAllow: localDefaultAllow, - defaultAllowReplacement: localDefaultAllowReplacement, - setDefaultAllow: setLocalDefaultAllow, - setDefaultAllowReplacement: setLocalDefaultAllowReplacement, + defaultAllow, + defaultAllowReplacement, + setDefaultAllow: setUpdatedDefaultAllow, + setDefaultAllowReplacement: setUpdatedDefaultAllowReplacement, updates, }); }, - [localDefaultAllow, localDefaultAllowReplacement] + [ + defaultAllow, + defaultAllowReplacement, + setUpdatedDefaultAllow, + setUpdatedDefaultAllowReplacement, + ] ); const onReset = useCallback(() => { - setLocalDefaultAllow(baseAllow); - setLocalDefaultAllowReplacement(baseAllowReplacement); - }, [baseAllow, baseAllowReplacement]); - - const onSave = useCallback(() => { - setDefaultAllow(localDefaultAllow); - setDefaultAllowReplacement(localDefaultAllowReplacement); - closeModal?.(); - }, [ - closeModal, - localDefaultAllow, - localDefaultAllowReplacement, - setDefaultAllow, - setDefaultAllowReplacement, - ]); + setUpdatedDefaultAllow(baseAllow); + setUpdatedDefaultAllowReplacement(baseAllowReplacement); + }, [baseAllow, baseAllowReplacement, setUpdatedDefaultAllow, setUpdatedDefaultAllowReplacement]); const anonymized: number = useMemo(() => { - const allowSet = new Set(localDefaultAllow); + const allowSet = new Set(defaultAllow); - return localDefaultAllowReplacement.reduce( - (acc, field) => (allowSet.has(field) ? acc + 1 : acc), - 0 - ); - }, [localDefaultAllow, localDefaultAllowReplacement]); + return defaultAllowReplacement.reduce((acc, field) => (allowSet.has(field) ? acc + 1 : acc), 0); + }, [defaultAllow, defaultAllowReplacement]); return ( <> - -

{i18n.CALLOUT_PARAGRAPH1}

- - {i18n.RESET} - -
- - + +

{i18n.SETTINGS_TITLE}

+
+ + {i18n.SETTINGS_DESCRIPTION} + + - + @@ -117,27 +98,13 @@ const AnonymizationSettingsComponent: React.FC = ({ closeModal }) => { - - - {closeModal != null && ( - - - {CANCEL} - - - )} - - - - {SAVE} - - - ); }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts index e7f82289dff786..82d0b7ec477013 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/translations.ts @@ -28,9 +28,16 @@ export const CALLOUT_TITLE = i18n.translate( } ); -export const RESET = i18n.translate( - 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.resetButton', +export const SETTINGS_TITLE = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.settingsTitle', { - defaultMessage: 'Reset', + defaultMessage: 'Anonymization', + } +); +export const SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.settingsDescription', + { + defaultMessage: + "When adding Prompt Context throughout the Security App that may contain sensitive information, you can choose which fields are sent, and whether to enable anonymization for these fields. This will replace the field's value with a random string before sending the conversation. Helpful defaults are provided below.", } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx deleted file mode 100644 index 35ee1a1bde473c..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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, fireEvent, screen } from '@testing-library/react'; - -import { AnonymizationSettingsModal } from '.'; -import { TestProviders } from '../../../mock/test_providers/test_providers'; - -describe('AnonymizationSettingsModal', () => { - const closeModal = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - render( - - - - ); - }); - - it('renders the anonymizationSettings', () => { - expect(screen.getByTestId('anonymizationSettingsCallout')).toBeInTheDocument(); - }); - - it('calls closeModal when Cancel is clicked', () => { - fireEvent.click(screen.getByTestId('cancel')); - - expect(closeModal).toHaveBeenCalledTimes(1); - }); - - it('calls closeModal when Save is clicked', () => { - fireEvent.click(screen.getByTestId('save')); - - expect(closeModal).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx deleted file mode 100644 index ae4b395ca0d0e4..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { EuiModal, EuiModalBody, EuiModalHeader } from '@elastic/eui'; -import React from 'react'; - -import { AnonymizationSettings } from '../anonymization_settings'; - -interface Props { - closeModal: () => void; -} - -const AnonymizationSettingsModalComponent: React.FC = ({ closeModal }) => ( - - - - - - -); - -export const AnonymizationSettingsModal = React.memo(AnonymizationSettingsModalComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.ts deleted file mode 100644 index d3da99dcf5052d..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_modal/translations.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 ANONYMIZATION = i18n.translate( - 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.anonymizationModalTitle', - { - defaultMessage: 'Anonymization', - } -); - -export const CANCEL = i18n.translate( - 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.cancelButton', - { - defaultMessage: 'Cancel', - } -); - -export const SAVE = i18n.translate( - 'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.saveButton', - { - defaultMessage: 'Save', - } -); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx deleted file mode 100644 index 3168c6a6b28dca..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { TestProviders } from '../../../mock/test_providers/test_providers'; -import * as i18n from './translations'; -import { SettingsPopover } from '.'; - -describe('SettingsPopover', () => { - beforeEach(() => { - render( - - - - ); - }); - - it('renders the settings button', () => { - const settingsButton = screen.getByTestId('settings'); - - expect(settingsButton).toBeInTheDocument(); - }); - - it('opens the popover when the settings button is clicked', () => { - const settingsButton = screen.getByTestId('settings'); - - userEvent.click(settingsButton); - - const popover = screen.queryByText(i18n.ANONYMIZATION); - expect(popover).toBeInTheDocument(); - }); -}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx deleted file mode 100644 index 634ccb50861784..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 { - EuiButtonIcon, - EuiContextMenu, - EuiContextMenuPanelDescriptor, - EuiPopover, - useGeneratedHtmlId, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import { AnonymizationSettingsModal } from '../anonymization_settings_modal'; - -import * as i18n from './translations'; - -const SettingsPopoverComponent: React.FC<{ isDisabled?: boolean }> = ({ isDisabled = false }) => { - const [showAnonymizationSettingsModal, setShowAnonymizationSettingsModal] = useState(false); - const closeAnonymizationSettingsModal = useCallback( - () => setShowAnonymizationSettingsModal(false), - [] - ); - - const contextMenuPopoverId = useGeneratedHtmlId(); - - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); - - const onButtonClick = useCallback(() => setIsPopoverOpen((prev) => !prev), []); - const button = useMemo( - () => ( - - ), - [isDisabled, onButtonClick] - ); - - const panels: EuiContextMenuPanelDescriptor[] = useMemo( - () => [ - { - id: 0, - items: [ - { - icon: 'eyeClosed', - name: i18n.ANONYMIZATION, - onClick: () => { - closePopover(); - - setShowAnonymizationSettingsModal(true); - }, - }, - ], - size: 's', - width: 150, - }, - ], - [closePopover] - ); - - return ( - <> - - - - - {showAnonymizationSettingsModal && ( - - )} - - ); -}; - -SettingsPopoverComponent.displayName = 'SettingsPopoverComponent'; - -export const SettingsPopover = React.memo(SettingsPopoverComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts deleted file mode 100644 index 4fcbcfcfa596b0..00000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/settings_popover/translations.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 ANONYMIZATION = i18n.translate( - 'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.anonymizationMenuItem', - { - defaultMessage: 'Anonymization', - } -); - -export const SETTINGS = i18n.translate( - 'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.settingsAriaLabel', - { - defaultMessage: 'Settings', - } -); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx index 1aab52a0d64327..5e9e0b960f5776 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/index.tsx @@ -17,11 +17,6 @@ import { BatchUpdateListItem, ContextEditorRow, FIELDS, SortConfig } from './typ export const DEFAULT_PAGE_SIZE = 10; -const pagination = { - initialPageSize: DEFAULT_PAGE_SIZE, - pageSizeOptions: [5, DEFAULT_PAGE_SIZE, 25, 50], -}; - const defaultSort: SortConfig = { sort: { direction: 'desc', @@ -33,7 +28,9 @@ export interface Props { allow: string[]; allowReplacement: string[]; onListUpdated: (updates: BatchUpdateListItem[]) => void; + onReset?: () => void; rawData: Record | null; + pageSize?: number; } const search: EuiSearchBarProps = { @@ -58,7 +55,9 @@ const ContextEditorComponent: React.FC = ({ allow, allowReplacement, onListUpdated, + onReset, rawData, + pageSize = DEFAULT_PAGE_SIZE, }) => { const [selected, setSelection] = useState([]); const selectionValue: EuiTableSelectionType = useMemo( @@ -89,17 +88,25 @@ const ContextEditorComponent: React.FC = ({ setTimeout(() => setSelection(rows), 0); // updates selection in the component state }, [rows]); + const pagination = useMemo(() => { + return { + initialPageSize: pageSize, + pageSizeOptions: [5, DEFAULT_PAGE_SIZE, 25, 50], + }; + }, [pageSize]); + const toolbar = useMemo( () => ( ), - [onListUpdated, onSelectAll, rawData, rows.length, selected] + [onListUpdated, onReset, onSelectAll, rawData, rows.length, selected] ); return ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx index 11b2488c096ad4..fc2fce0cf1bed7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.test.tsx @@ -40,6 +40,7 @@ describe('Toolbar', () => { const defaultProps = { onListUpdated: jest.fn(), onlyDefaults: false, + onReset: jest.fn(), onSelectAll: jest.fn(), selected: [], // no rows selected totalFields: 5, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx index 476005c8da5bae..5a1568693e6f38 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/toolbar/index.tsx @@ -15,6 +15,7 @@ import { BatchUpdateListItem, ContextEditorRow } from '../types'; export interface Props { onListUpdated: (updates: BatchUpdateListItem[]) => void; onlyDefaults: boolean; + onReset?: () => void; onSelectAll: () => void; selected: ContextEditorRow[]; totalFields: number; @@ -23,6 +24,7 @@ export interface Props { const ToolbarComponent: React.FC = ({ onListUpdated, onlyDefaults, + onReset, onSelectAll, selected, totalFields, @@ -54,6 +56,28 @@ const ToolbarComponent: React.FC = ({ selected={selected} /> + + {onReset != null && ( + + + + + {i18n.RESET} + + + + + )} ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts index a48a52ee8092ac..7aeb655a5b3438 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/context_editor/translations.ts @@ -116,6 +116,12 @@ export const SELECTED_FIELDS = (selected: number) => values: { selected }, defaultMessage: 'Selected {selected} fields', }); +export const RESET = i18n.translate( + 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.resetButton', + { + defaultMessage: 'Reset', + } +); export const UNANONYMIZE = i18n.translate( 'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.unanonymizeAction', diff --git a/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts b/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts index 660b71c857e8f6..90c75a74055265 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts +++ b/x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts @@ -41,7 +41,7 @@ export const FORMAT_OUTPUT_CORRECTLY = i18n.translate( 'xpack.securitySolution.assistant.content.prompts.system.outputFormatting', { defaultMessage: - 'If you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline, please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.', + 'If you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts index 73baeb60ce4f47..f8daba8330a43f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts @@ -25,13 +25,6 @@ export const ANALYZER_TAB = i18n.translate( } ); -export const ASSISTANT_TAB = i18n.translate( - 'xpack.securitySolution.timeline.tabs.assistantTabTitle', - { - defaultMessage: 'Security assistant', - } -); - export const NOTES_TAB = i18n.translate( 'xpack.securitySolution.timeline.tabs.notesTabTimelineTitle', { @@ -49,7 +42,7 @@ export const PINNED_TAB = i18n.translate( export const SECURITY_ASSISTANT = i18n.translate( 'xpack.securitySolution.timeline.tabs.securityAssistantTimelineTitle', { - defaultMessage: 'Security Assistant', + defaultMessage: 'Elastic AI Assistant', } ); From 2e2da698570e9d0836c772c243ba243f138bd1f8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Jul 2023 09:51:50 +0200 Subject: [PATCH 02/20] Update dependency react-hook-form to ^7.44.2 (main) (#152895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [react-hook-form](https://www.react-hook-form.com) ([source](https://togithub.com/react-hook-form/react-hook-form)) | [`^7.43.2` -> `^7.44.2`](https://renovatebot.com/diffs/npm/react-hook-form/7.43.2/7.44.2) | [![age](https://badges.renovateapi.com/packages/npm/react-hook-form/7.44.2/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/react-hook-form/7.44.2/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/react-hook-form/7.44.2/compatibility-slim/7.43.2)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/react-hook-form/7.44.2/confidence-slim/7.43.2)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
react-hook-form/react-hook-form ### [`v7.44.2`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.44.2): Version 7.44.2 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.44.1...v7.44.2) 🪟 fix [#​10456](https://togithub.com/react-hook-form/react-hook-form/issues/10456) `object.hasown` replaced with `hasOwnProperty` ([#​10458](https://togithub.com/react-hook-form/react-hook-form/issues/10458)) ### [`v7.44.1`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.44.1): Version 7.44.1 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.44.0...v7.44.1) 🐞 fix `
` component content-type json type missing ([#​10454](https://togithub.com/react-hook-form/react-hook-form/issues/10454)) ### [`v7.44.0`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.44.0): Version 7.44.0 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.9...v7.44.0) 📄 New `` Component ([https://github.com/react-hook-form/react-hook-form/pull/9735](https://togithub.com/react-hook-form/react-hook-form/pull/9735)) https://react-hook-form.com/docs/useform/form - add try and catch for JSON.stringify ```tsx // Send post request with formData { alert("Great"); }} /> // Send post request with json form data {errors.root?.server.type === 500 && 'Error message'} {errors.root?.server.type === 400 && 'Error message'}
// Send post request with formData with fetch
{ await fetch("api", { method: "post", body: formData, }); }} /> ``` 🗝️ support TransformedValues with useFormContext ([https://github.com/react-hook-form/react-hook-form/pull/10322](https://togithub.com/react-hook-form/react-hook-form/pull/10322)) ```tsx useFormContext() ``` 🚔 added TTransformedValues to FormProvider ([https://github.com/react-hook-form/react-hook-form/pull/10368](https://togithub.com/react-hook-form/react-hook-form/pull/10368)) ```tsx FormProviderProps ``` 🐞 fix [https://github.com/react-hook-form/react-hook-form/issues/10139](https://togithub.com/react-hook-form/react-hook-form/issues/10139) with errors diff from the previous with field array action ([https://github.com/react-hook-form/react-hook-form/pull/10216](https://togithub.com/react-hook-form/react-hook-form/pull/10216)) 🐞 related [https://github.com/react-hook-form/react-hook-form/issues/10238](https://togithub.com/react-hook-form/react-hook-form/issues/10238) return default values in watch and useWatch when reset is called with an empty object 🦮 remove unnecessary as unknown as cast ([https://github.com/react-hook-form/react-hook-form/pull/10300](https://togithub.com/react-hook-form/react-hook-form/pull/10300)) 🧛‍♂️ close [https://github.com/react-hook-form/react-hook-form/issues/10277](https://togithub.com/react-hook-form/react-hook-form/issues/10277) remove pattern empty string check ([https://github.com/react-hook-form/react-hook-form/pull/10279](https://togithub.com/react-hook-form/react-hook-form/pull/10279)) 🐞 fix [https://github.com/react-hook-form/react-hook-form/issues/9037](https://togithub.com/react-hook-form/react-hook-form/issues/9037) bugs that occur in the presence of Array polyfills ([https://github.com/react-hook-form/react-hook-form/pull/10328](https://togithub.com/react-hook-form/react-hook-form/pull/10328)) 🫥 close [https://github.com/react-hook-form/react-hook-form/issues/10348](https://togithub.com/react-hook-form/react-hook-form/issues/10348) stop shouldUseNativeValidation pass down constraint props ([https://github.com/react-hook-form/react-hook-form/pull/10350](https://togithub.com/react-hook-form/react-hook-form/pull/10350)) 😵‍💫 close [https://github.com/react-hook-form/react-hook-form/issues/10386](https://togithub.com/react-hook-form/react-hook-form/issues/10386) re-register controller input to fix strict mode ([https://github.com/react-hook-form/react-hook-form/pull/10418](https://togithub.com/react-hook-form/react-hook-form/pull/10418)) ✍️ update form.tsx for TSdoc ([https://github.com/react-hook-form/react-hook-form/pull/10399](https://togithub.com/react-hook-form/react-hook-form/pull/10399)) thanks to [@​yasamoka](https://togithub.com/yasamoka) & [@​Torvin](https://togithub.com/Torvin) [@​ryota-murakami](https://togithub.com/ryota-murakami) & [@​devakrishna33](https://togithub.com/devakrishna33) ### [`v7.43.9`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.9): Version 7.43.9 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.8...v7.43.9) 🍄 close [#​10195](https://togithub.com/react-hook-form/react-hook-form/issues/10195) close async defaultValues not load ([#​10203](https://togithub.com/react-hook-form/react-hook-form/issues/10203)) Revert "🐞 fix [#​10139](https://togithub.com/react-hook-form/react-hook-form/issues/10139) useFieldArray array error not updating in some cases ([#​10150](https://togithub.com/react-hook-form/react-hook-form/issues/10150))" ### [`v7.43.8`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.8): Version 7.43.8 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.7...v7.43.8) 🪡 related [#​10143](https://togithub.com/react-hook-form/react-hook-form/issues/10143) did not update dirty until interacted ([#​10157](https://togithub.com/react-hook-form/react-hook-form/issues/10157)) 🐞 fix [#​10139](https://togithub.com/react-hook-form/react-hook-form/issues/10139) useFieldArray array error not updating in some cases ([#​10150](https://togithub.com/react-hook-form/react-hook-form/issues/10150)) thanks to [@​kylemclean](https://togithub.com/kylemclean) ### [`v7.43.7`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.7): Version 7.43.7 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.6...v7.43.7) 🐞 fix [#​10131](https://togithub.com/react-hook-form/react-hook-form/issues/10131) regression on NaN data type ([#​10132](https://togithub.com/react-hook-form/react-hook-form/issues/10132)) 🐞 fix [#​10129](https://togithub.com/react-hook-form/react-hook-form/issues/10129) useFieldArray unmount fieldArray wihtout register ([#​10130](https://togithub.com/react-hook-form/react-hook-form/issues/10130)) 🦶 upgrade to TS 5.0.0 ([#​9834](https://togithub.com/react-hook-form/react-hook-form/issues/9834)) ### [`v7.43.6`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.6): Version 7.43.6 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.5...v7.43.6) 🐞 fix(appendErrors): incorrect type, it can take an array of errors ([#​10125](https://togithub.com/react-hook-form/react-hook-form/issues/10125)) ✍️ close [#​10096](https://togithub.com/react-hook-form/react-hook-form/issues/10096) react strict mode with mounted field value ([#​10102](https://togithub.com/react-hook-form/react-hook-form/issues/10102)) 🦮 fix: isLoading form state ([#​10095](https://togithub.com/react-hook-form/react-hook-form/issues/10095)) 📝 fix: typos in tsdoc ([#​10088](https://togithub.com/react-hook-form/react-hook-form/issues/10088)) 🩴 close [#​10078](https://togithub.com/react-hook-form/react-hook-form/issues/10078) prevent stabled aysnc validation ([#​10082](https://togithub.com/react-hook-form/react-hook-form/issues/10082)) 🐞 fix [#​10064](https://togithub.com/react-hook-form/react-hook-form/issues/10064) native validation when subscribe to isValid ([#​10072](https://togithub.com/react-hook-form/react-hook-form/issues/10072)) 📝 correct typo in field array type declaration ([#​10066](https://togithub.com/react-hook-form/react-hook-form/issues/10066)) thanks to [@​jorisre](https://togithub.com/jorisre) [@​chrisbarless](https://togithub.com/chrisbarless) [@​mjw-isp](https://togithub.com/mjw-isp) and [@​adamtowle](https://togithub.com/adamtowle) ### [`v7.43.5`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.5): Version 7.43.5 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.4...v7.43.5) 🐰 prevent runtime error with subscribe function ([#​10052](https://togithub.com/react-hook-form/react-hook-form/issues/10052)) 🪔 close [#​10045](https://togithub.com/react-hook-form/react-hook-form/issues/10045) improve `useController` defaultValue restore under strict mode with double `useEffect` ([#​10049](https://togithub.com/react-hook-form/react-hook-form/issues/10049)) 📷 improve form values state update ([#​10029](https://togithub.com/react-hook-form/react-hook-form/issues/10029)) ### [`v7.43.4`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.4): Version 7.43.4 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.3...v7.43.4) 🐞 fix [#​10021](https://togithub.com/react-hook-form/react-hook-form/issues/10021) issue with `.next` runtime error and fix cypress action ([#​10026](https://togithub.com/react-hook-form/react-hook-form/issues/10026)) ### [`v7.43.3`](https://togithub.com/react-hook-form/react-hook-form/releases/tag/v7.43.3): Version 7.43.3 [Compare Source](https://togithub.com/react-hook-form/react-hook-form/compare/v7.43.2...v7.43.3) 📐 fix `resetField` defaultValue type and reduce any type ([#​10024](https://togithub.com/react-hook-form/react-hook-form/issues/10024)) 🐞 fix [#​9997](https://togithub.com/react-hook-form/react-hook-form/issues/9997) issue on the mounted state is updated with values prop ([#​10001](https://togithub.com/react-hook-form/react-hook-form/issues/10001)) Revert "🏍 delete dirty fields node instead of marking as false ([#​9156](https://togithub.com/react-hook-form/react-hook-form/issues/9156))" ([#​9996](https://togithub.com/react-hook-form/react-hook-form/issues/9996)) 💅 improve state subscription consistency ([#​9984](https://togithub.com/react-hook-form/react-hook-form/issues/9984))
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://app.renovatebot.com/dashboard#github/elastic/kibana). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Patryk Kopyciński --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 173d45abad2477..559f93a4d53601 100644 --- a/package.json +++ b/package.json @@ -931,7 +931,7 @@ "react-fast-compare": "^2.0.4", "react-focus-on": "^3.7.0", "react-grid-layout": "^1.3.4", - "react-hook-form": "^7.43.2", + "react-hook-form": "^7.44.2", "react-intl": "^2.8.0", "react-is": "^17.0.2", "react-markdown": "^6.0.3", diff --git a/yarn.lock b/yarn.lock index ce26e201449287..0a81b7d2e5cafe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24474,10 +24474,10 @@ react-grid-layout@^1.3.4: react-draggable "^4.0.0" react-resizable "^3.0.4" -react-hook-form@^7.43.2: - version "7.43.2" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.2.tgz#d8ff71956dc3de258dce19d4b1c7e1c6a0188e67" - integrity sha512-NvD3Oe2Y9hhqo2R4I4iJigDzSLpdMnzUpNMxlnzTbdiT7NT3BW0GxWCzEtwPudZMUPbZhNcSy1EcGAygyhDORg== +react-hook-form@^7.44.2: + version "7.44.3" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.44.3.tgz#a99e560c6ef2b668db1daaebc4f98267331b6828" + integrity sha512-/tHId6p2ViAka1wECMw8FEPn/oz/w226zehHrJyQ1oIzCBNMIJCaj6ZkQcv+MjDxYh9MWR7RQic7Qqwe4a5nkw== react-input-autosize@^3.0.0: version "3.0.0" From 9a7cc5a1d1fceed933c3714037d81c7ba067ea97 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Wed, 12 Jul 2023 09:53:41 +0200 Subject: [PATCH 03/20] [Fleet] Add support for Runtime Fields (#161129) ## Summary Closes https://github.com/elastic/kibana/issues/155255 Closes https://github.com/elastic/package-spec/issues/39 Add support in Fleet for Runtime fields, based on these docs: - Defining runtime fields: - https://www.elastic.co/guide/en/elasticsearch/reference/8.8/runtime-mapping-fields.html - https://www.elastic.co/guide/en/elasticsearch/reference/8.8/runtime-retrieving-fields.html - Mapping runtime fields in dynamic templates: - https://www.elastic.co/guide/en/elasticsearch/reference/current/dynamic-templates.html#dynamic-mapping-runtime-fields - Adding runtime fields under groups Given these field definitions in packages: ```yaml - name: bar type: boolean - name: uptime type: keyword - name: runtime_boolean type: boolean runtime: true - name: runtime.day type: keyword runtime: >- emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT)) - name: to_be_long type: long runtime: true - name: runtime.date type: date date_format: 'yyyy-MM-dd' runtime: >- emit(doc['@timestamp'].value.toEpochMilli()) - name: runtime.epoch_milli type: long runtime: >- emit(doc['@timestamp'].value.toEpochMilli()) - name: lowercase type: keyword runtime: >- emit(doc['uppercase'].value.toLowerCase()) - name: labels.* type: long object_type_mapping_type: double runtime: true - name: responses type: group fields: - name: runtime_group_boolean type: boolean runtime: true - name: foo type: boolean ``` and this definition in the manifest ```yaml elasticsearch: index_template: mappings: runtime: day_of_week_two: type: keyword script: source: "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))" ``` This PR adds the required fields into the `mappings` key when installing the package. For this example, the resulting mappings are (just showing the relevant data for these changes): ```json { ".ds-logs-runtime_fields.foo-default-2023.07.10-000001": { "mappings": { "dynamic_templates": [ { "labels.*": { "path_match": "labels.*", "match_mapping_type": "double", "runtime": { "type": "long" } } } ], "runtime": { "day_of_week_two": { "type": "keyword", "script": { "source": "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", "lang": "painless" } }, "labels.a": { "type": "long" }, "labels.b": { "type": "long" }, "lowercase": { "type": "keyword", "script": { "source": "emit(doc['uppercase'].value.toLowerCase())", "lang": "painless" } }, "responses.runtime_group_boolean": { "type": "boolean" }, "runtime.date": { "type": "date", "script": { "source": "emit(doc['@timestamp'].value.toEpochMilli())", "lang": "painless" }, "format": "yyyy-MM-dd" }, "runtime.day": { "type": "keyword", "script": { "source": "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", "lang": "painless" } }, "runtime.epoch_milli": { "type": "long", "script": { "source": "emit(doc['@timestamp'].value.toEpochMilli())", "lang": "painless" } }, "runtime_boolean": { "type": "boolean" }, "to_be_long": { "type": "long" } }, "properties": { "@timestamp": { "type": "date", "ignore_malformed": false }, "bar": { "type": "boolean" }, "data_stream": { "properties": { "dataset": { "type": "constant_keyword" }, "namespace": { "type": "constant_keyword" }, "type": { "type": "constant_keyword" } } }, "labels": { "type": "object" }, "message": { "type": "keyword", "ignore_above": 1024 }, "responses": { "properties": { "foo": { "type": "boolean" } } }, "uppercase": { "type": "keyword", "ignore_above": 1024 }, "user": { "properties": { "id": { "type": "keyword", "ignore_above": 1024 } } } } } } } ``` Tested manually installing a package containing runtime field definitions as the example above. Tested also indexing some documents and retrieving the runtime fields: - Indexing documents: ```json POST /logs-runtime_fields.foo-default/_doc/ { "@timestamp": "2023-07-07T13:32:09.000Z", "datastream": { "dataset": "logs-runtime_fields.foo", "namespace": "default", "type": "logs" }, "user": { "id": "8a4f500d" }, "message": "Login successful", "labels": { "a": 1.6, "b": 2.5 }, "uppercase": "SOMETHING", "to_be_long": 1.6, "runtime_boolean": true, "responses.runtime_group_boolean": false } ``` - Retrieving runtime fields (`_source` disabled): ```json GET logs-runtime_fields.foo-default/_search { "fields": [ "@timestamp", "runtime_boolean", "responses.runtime_group_boolean", "runtime.day", "runtime.date", "runtime.epoch_milli", "labels.*", "uppercase", "lowercase", "to_be_long" ], "_source": false } ``` - Output: ```json ... "hits": [ { "_index": ".ds-logs-runtime_fields.foo-default-2023.07.10-000001", "_id": "_7p1P4kBtEvrlGnsxiFN", "_score": 1, "fields": { "uppercase": [ "SOMETHING" ], "runtime.date": [ "2023-07-10" ], "@timestamp": [ "2023-07-10T09:33:09.000Z" ], "lowercase": [ "something" ], "to_be_long": [ 1 ], "runtime_boolean": [ true ], "runtime.day": [ "Monday" ], "labels.a": [ 1 ], "labels.b": [ 2 ], "responses.runtime_group_boolean": [ false ], "runtime.epoch_milli": [ 1688981589000 ] } } ] ... ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios (https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/fleet/common/types/models/epm.ts | 1 + .../elasticsearch/template/install.test.ts | 55 +++++++- .../epm/elasticsearch/template/install.ts | 7 +- .../elasticsearch/template/template.test.ts | 101 +++++++++++++++ .../epm/elasticsearch/template/template.ts | 107 ++++++++++++++-- .../fleet/server/services/epm/fields/field.ts | 1 + .../fleet_api_integration/apis/epm/index.js | 1 + .../apis/epm/install_runtime_field.ts | 118 ++++++++++++++++++ .../runtime_fields/0.0.1/LICENSE.txt | 93 ++++++++++++++ .../runtime_fields/0.0.1/changelog.yml | 6 + .../foo/agent/stream/stream.yml.hbs | 7 ++ .../elasticsearch/ingest_pipeline/default.yml | 13 ++ .../data_stream/foo/fields/base-fields.yml | 12 ++ .../0.0.1/data_stream/foo/fields/fields.yml | 39 ++++++ .../0.0.1/data_stream/foo/manifest.yml | 21 ++++ .../runtime_fields/0.0.1/docs/README.md | 84 +++++++++++++ .../runtime_fields/0.0.1/img/sample-logo.svg | 1 + .../0.0.1/img/sample-screenshot.png | Bin 0 -> 18849 bytes .../runtime_fields/0.0.1/manifest.yml | 33 +++++ 19 files changed, 689 insertions(+), 11 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/epm/install_runtime_field.ts create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/LICENSE.txt create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/changelog.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/agent/stream/stream.yml.hbs create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/elasticsearch/ingest_pipeline/default.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/base-fields.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/fields.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/manifest.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-logo.svg create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-screenshot.png create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/manifest.yml diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 759fa02229ef05..ec9f7f3d2fd42c 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -582,6 +582,7 @@ export interface PackageAssetReference { export interface IndexTemplateMappings { properties: any; dynamic_templates?: any; + runtime?: any; } // This is an index template v2, see https://github.com/elastic/elasticsearch/issues/53101 diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts index fbb289a984eb04..2b185e28104928 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts @@ -320,11 +320,64 @@ describe('EPM index template install', () => { const packageTemplate = componentTemplates['logs-package.dataset@package'].template; if (!('settings' in packageTemplate)) { - throw new Error('no mappings on package template'); + throw new Error('no settings on package template'); } expect(packageTemplate.settings?.index?.mapping).toEqual( expect.objectContaining({ ignored_malformed: true }) ); }); + + it('test prepareTemplate to set a runtime field in index_template.mappings', () => { + const dataStream = { + type: 'logs', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + elasticsearch: { + 'index_template.mappings': { + runtime: { + day_of_week: { + type: 'keyword', + script: { + source: + "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + }, + }, + }, + }, + }, + } as RegistryDataStream; + + const pkg = { + name: 'package', + version: '0.0.1', + }; + + const { componentTemplates } = prepareTemplate({ + pkg, + dataStream, + }); + + const packageTemplate = componentTemplates['logs-package.dataset@package'].template; + + if (!('mappings' in packageTemplate)) { + throw new Error('no mappings on package template'); + } + + expect(packageTemplate.mappings?.runtime).toEqual( + expect.objectContaining({ + day_of_week: { + type: 'keyword', + script: { + source: + "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + }, + }, + }) + ); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 1780d3e55169a3..b20499daeec5ca 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -332,6 +332,8 @@ export function buildComponentTemplates(params: { (dynampingTemplate) => Object.keys(dynampingTemplate)[0] ); + const mappingsRuntimeFields = merge(mappings.runtime, indexTemplateMappings.runtime ?? {}); + const isTimeSeriesEnabledByDefault = registryElasticsearch?.index_mode === 'time_series'; const isSyntheticSourceEnabledByDefault = registryElasticsearch?.source_mode === 'synthetic'; @@ -359,8 +361,11 @@ export function buildComponentTemplates(params: { }, mappings: { properties: mappingsProperties, + ...(Object.keys(mappingsRuntimeFields).length > 0 + ? { runtime: mappingsRuntimeFields } + : {}), dynamic_templates: mappingsDynamicTemplates.length ? mappingsDynamicTemplates : undefined, - ...omit(indexTemplateMappings, 'properties', 'dynamic_templates', '_source'), + ...omit(indexTemplateMappings, 'properties', 'dynamic_templates', '_source', 'runtime'), ...(indexTemplateMappings?._source || sourceModeSynthetic ? { _source: { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index e42106afda2c2a..707727f5586975 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -1021,6 +1021,107 @@ describe('EPM template', () => { expect(JSON.stringify(mappings)).toEqual(JSON.stringify(fieldMapping)); }); + it('tests processing runtime fields without script', () => { + const textWithRuntimeFieldsLiteralYml = ` +- name: runtime_field + type: boolean + runtime: true +`; + const runtimeFieldMapping = { + properties: {}, + runtime: { + runtime_field: { + type: 'boolean', + }, + }, + }; + const fields: Field[] = safeLoad(textWithRuntimeFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(runtimeFieldMapping); + }); + + it('tests processing runtime fields with painless script', () => { + const textWithRuntimeFieldsLiteralYml = ` +- name: day_of_week + type: date + runtime: | + emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT)) +`; + const runtimeFieldMapping = { + properties: {}, + runtime: { + day_of_week: { + type: 'date', + script: { + source: + "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + }, + }, + }, + }; + const fields: Field[] = safeLoad(textWithRuntimeFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(runtimeFieldMapping); + }); + + it('tests processing runtime fields defined in a group', () => { + const textWithRuntimeFieldsLiteralYml = ` +- name: responses + type: group + fields: + - name: day_of_week + type: date + date_format: date_optional_time + runtime: | + emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT)) +`; + const runtimeFieldMapping = { + properties: {}, + runtime: { + 'responses.day_of_week': { + type: 'date', + format: 'date_optional_time', + script: { + source: + "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + }, + }, + }, + }; + const fields: Field[] = safeLoad(textWithRuntimeFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(runtimeFieldMapping); + }); + + it('tests processing runtime fields in a dynamic template', () => { + const textWithRuntimeFieldsLiteralYml = ` +- name: labels.* + type: keyword + runtime: true +`; + const runtimeFieldMapping = { + properties: {}, + dynamic_templates: [ + { + 'labels.*': { + match_mapping_type: 'string', + path_match: 'labels.*', + runtime: { + type: 'keyword', + }, + }, + }, + ], + }; + const fields: Field[] = safeLoad(textWithRuntimeFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(runtimeFieldMapping); + }); + it('tests priority and index pattern for data stream without dataset_is_prefix', () => { const dataStreamDatasetIsPrefixUnset = { type: 'metrics', diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 7aebf1ba7eb83b..2592e41862b181 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -38,6 +38,10 @@ interface MultiFields { [key: string]: object; } +interface RuntimeFields { + [key: string]: any; +} + export interface IndexTemplateMapping { [key: string]: any; } @@ -115,6 +119,7 @@ export function getTemplate({ export function generateMappings(fields: Field[]): IndexTemplateMappings { const dynamicTemplates: Array> = []; const dynamicTemplateNames = new Set(); + const runtimeFields: RuntimeFields = {}; const { properties } = _generateMappings(fields, { addDynamicMapping: (dynamicMapping: { @@ -122,15 +127,19 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { matchingType: string; pathMatch: string; properties: string; + runtimeProperties?: string; }) => { const name = dynamicMapping.path; if (dynamicTemplateNames.has(name)) { return; } - const dynamicTemplate: Properties = { - mapping: dynamicMapping.properties, - }; + const dynamicTemplate: Properties = {}; + if (dynamicMapping.runtimeProperties !== undefined) { + dynamicTemplate.runtime = dynamicMapping.runtimeProperties; + } else { + dynamicTemplate.mapping = dynamicMapping.properties; + } if (dynamicMapping.matchingType) { dynamicTemplate.match_mapping_type = dynamicMapping.matchingType; @@ -139,17 +148,23 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { if (dynamicMapping.pathMatch) { dynamicTemplate.path_match = dynamicMapping.pathMatch; } + dynamicTemplateNames.add(name); dynamicTemplates.push({ [dynamicMapping.path]: dynamicTemplate }); }, + addRuntimeField: (runtimeField: { path: string; properties: Properties }) => { + runtimeFields[`${runtimeField.path}`] = runtimeField.properties; + }, }); - return dynamicTemplates.length - ? { - properties, - dynamic_templates: dynamicTemplates, - } - : { properties }; + const indexTemplateMappings: IndexTemplateMappings = { properties }; + if (dynamicTemplates.length > 0) { + indexTemplateMappings.dynamic_templates = dynamicTemplates; + } + if (Object.keys(runtimeFields).length > 0) { + indexTemplateMappings.runtime = runtimeFields; + } + return indexTemplateMappings; } /** @@ -164,6 +179,7 @@ function _generateMappings( fields: Field[], ctx: { addDynamicMapping: any; + addRuntimeField: any; groupFieldName?: string; } ): { @@ -179,6 +195,55 @@ function _generateMappings( // If type is not defined, assume keyword const type = field.type || 'keyword'; + if (field.runtime !== undefined) { + const path = ctx.groupFieldName ? `${ctx.groupFieldName}.${field.name}` : field.name; + let runtimeFieldProps: Properties = getDefaultProperties(field); + + // Is it a dynamic template? + if (type === 'object' && field.object_type) { + const pathMatch = path.includes('*') ? path : `${path}.*`; + + let dynProperties: Properties = getDefaultProperties(field); + let matchingType: string | undefined; + switch (field.object_type) { + case 'keyword': + dynProperties.type = field.object_type; + matchingType = field.object_type_mapping_type ?? 'string'; + break; + case 'double': + case 'long': + case 'boolean': + dynProperties = { + type: field.object_type, + time_series_metric: field.metric_type, + }; + matchingType = field.object_type_mapping_type ?? field.object_type; + default: + break; + } + + // get the runtime properies of this field assuming type equals to object_type + const _field = { ...field, type: field.object_type }; + const fieldProps = generateRuntimeFieldProps(_field); + + if (dynProperties && matchingType) { + ctx.addDynamicMapping({ + path, + pathMatch, + matchingType, + properties: dynProperties, + runtimeProperties: fieldProps, + }); + } + return; + } + const fieldProps = generateRuntimeFieldProps(field); + runtimeFieldProps = { ...runtimeFieldProps, ...fieldProps }; + + ctx.addRuntimeField({ path, properties: runtimeFieldProps }); + return; // runtime fields should not be added as a property + } + if (type === 'object' && field.object_type) { const path = ctx.groupFieldName ? `${ctx.groupFieldName}.${field.name}` : field.name; const pathMatch = path.includes('*') ? path : `${path}.*`; @@ -435,6 +500,30 @@ function generateDateMapping(field: Field): IndexTemplateMapping { return mapping; } +function generateRuntimeFieldProps(field: Field): IndexTemplateMapping { + let mapping: IndexTemplateMapping = {}; + const type = field.type || keyword; + switch (type) { + case 'integer': + mapping.type = 'long'; + break; + case 'date': + const dateMappings = generateDateMapping(field); + mapping = { ...mapping, ...dateMappings, type: 'date' }; + break; + default: + mapping.type = type; + } + + if (typeof field.runtime === 'string') { + const scriptObject = { + source: field.runtime.trim(), + }; + mapping.script = scriptObject; + } + return mapping; +} + /** * Generates the template name out of the given information */ diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index 893d1c678d86e1..ee8af0d303a381 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -39,6 +39,7 @@ export interface Field { null_value?: string; dimension?: boolean; default_field?: boolean; + runtime?: boolean | string; // Fields specific of the aggregate_metric_double type metrics?: string[]; diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 06caa68bbbe619..70b655a59424bb 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -43,5 +43,6 @@ export default function loadTests({ loadTestFile, getService }) { loadTestFile(require.resolve('./install_hidden_datastreams')); loadTestFile(require.resolve('./bulk_get_assets')); loadTestFile(require.resolve('./install_dynamic_template_metric')); + loadTestFile(require.resolve('./install_runtime_field')); }); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_runtime_field.ts b/x-pack/test/fleet_api_integration/apis/epm/install_runtime_field.ts new file mode 100644 index 00000000000000..76038063146e23 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/install_runtime_field.ts @@ -0,0 +1,118 @@ +/* + * 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. + */ +/* eslint-disable no-console */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; +import { setupFleetAndAgents } from '../agents/services'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const dockerServers = getService('dockerServers'); + const es = getService('es'); + + const testPackage = 'runtime_fields'; + const testPackageVersion = '0.0.1'; + const server = dockerServers.get('registry'); + + const deletePackage = async (name: string, version: string) => { + await supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx'); + }; + + describe('package with runtime fields', async () => { + skipIfNoDockerRegistry(providerContext); + setupFleetAndAgents(providerContext); + + after(async () => { + if (server.enabled) { + await deletePackage(testPackage, testPackageVersion); + } + }); + + it('should install with runtime fields added in component template', async function () { + const templateName = 'logs-runtime_fields.foo@package'; + + await supertest + .post(`/api/fleet/epm/packages/${testPackage}/${testPackageVersion}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + + const { body: resp } = await es.transport.request( + { + method: 'GET', + path: `/_component_template/${templateName}`, + }, + { meta: true } + ); + + const template = resp.component_templates[0].component_template; + const runtimeFieldDefinitions = template.template.mappings.runtime; + console.log(JSON.stringify(runtimeFieldDefinitions, null, 2)); + + expect(runtimeFieldDefinitions).to.eql({ + day_of_week_two: { + script: { + source: + "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + }, + type: 'keyword', + }, + 'responses.runtime_group_boolean': { + type: 'boolean', + }, + 'runtime.date': { + format: 'yyyy-MM-dd', + script: { + source: "emit(doc['@timestamp'].value.toEpochMilli())", + }, + type: 'date', + }, + 'runtime.day': { + script: { + source: + "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + }, + type: 'keyword', + }, + 'runtime.epoch_milli': { + type: 'long', + script: { + source: "emit(doc['@timestamp'].value.toEpochMilli())", + }, + }, + runtime_boolean: { + type: 'boolean', + }, + to_be_long: { + type: 'long', + }, + lowercase: { + type: 'keyword', + script: { + source: "emit(doc['uppercase'].value.toLowerCase())", + }, + }, + }); + + const dynamicTemplates = template.template.mappings.dynamic_templates; + expect(dynamicTemplates).to.eql([ + { + 'labels.*': { + path_match: 'labels.*', + match_mapping_type: 'double', + runtime: { + type: 'long', + }, + }, + }, + ]); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/LICENSE.txt b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/LICENSE.txt new file mode 100644 index 00000000000000..809108b857ffd2 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/LICENSE.txt @@ -0,0 +1,93 @@ +Elastic License 2.0 + +URL: https://www.elastic.co/licensing/elastic-license + +## Acceptance + +By using the software, you agree to all of the terms and conditions below. + +## Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, +non-sublicensable, non-transferable license to use, copy, distribute, make +available, and prepare derivative works of the software, in each case subject to +the limitations and conditions below. + +## Limitations + +You may not provide the software to third parties as a hosted or managed +service, where the service provides users with access to any substantial set of +the features or functionality of the software. + +You may not move, change, disable, or circumvent the license key functionality +in the software, and you may not remove or obscure any functionality in the +software that is protected by the license key. + +You may not alter, remove, or obscure any licensing, copyright, or other notices +of the licensor in the software. Any use of the licensor’s trademarks is subject +to applicable law. + +## Patents + +The licensor grants you a license, under any patent claims the licensor can +license, or becomes able to license, to make, have made, use, sell, offer for +sale, import and have imported the software, in each case subject to the +limitations and conditions in this license. This license does not cover any +patent claims that you cause to be infringed by modifications or additions to +the software. If you or your company make any written claim that the software +infringes or contributes to infringement of any patent, your patent license for +the software granted under these terms ends immediately. If your company makes +such a claim, your patent license ends immediately for work on behalf of your +company. + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you +also gets a copy of these terms. + +If you modify the software, you must include in any modified copies of the +software prominent notices stating that you have modified the software. + +## No Other Rights + +These terms do not imply any licenses other than those expressly granted in +these terms. + +## Termination + +If you use the software in violation of these terms, such use is not licensed, +and your licenses will automatically terminate. If the licensor provides you +with a notice of your violation, and you cease all violation of this license no +later than 30 days after you receive that notice, your licenses will be +reinstated retroactively. However, if you violate these terms after such +reinstatement, any additional violation of these terms will cause your licenses +to terminate automatically and permanently. + +## No Liability + +*As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of +legal claim.* + +## Definitions + +The **licensor** is the entity offering these terms, and the **software** is the +software the licensor makes available under these terms, including any portion +of it. + +**you** refers to the individual or entity agreeing to these terms. + +**your company** is any legal entity, sole proprietorship, or other kind of +organization that you work for, plus all organizations that have control over, +are under the control of, or are under common control with that +organization. **control** means ownership of substantially all the assets of an +entity, or the power to direct its management and policies by vote, contract, or +otherwise. Control can be direct or indirect. + +**your licenses** are all the licenses granted to you for the software under +these terms. + +**use** means anything you do with the software requiring one of your licenses. + +**trademark** means trademarks, service marks, and similar rights. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/changelog.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/changelog.yml new file mode 100644 index 00000000000000..bb0320a5243f73 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/changelog.yml @@ -0,0 +1,6 @@ +# newer versions go on top +- version: "0.0.1" + changes: + - description: Initial draft of the package + type: enhancement + link: https://github.com/elastic/integrations/pull/1 # FIXME Replace with the real PR link diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/agent/stream/stream.yml.hbs b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/agent/stream/stream.yml.hbs new file mode 100644 index 00000000000000..5845510de80916 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/agent/stream/stream.yml.hbs @@ -0,0 +1,7 @@ +paths: +{{#each paths as |path i|}} + - {{path}} +{{/each}} +exclude_files: [".gz$"] +processors: + - add_locale: ~ diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/elasticsearch/ingest_pipeline/default.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/elasticsearch/ingest_pipeline/default.yml new file mode 100644 index 00000000000000..133040687bbfd2 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/elasticsearch/ingest_pipeline/default.yml @@ -0,0 +1,13 @@ +--- +description: Pipeline for processing sample logs +processors: +- set: + field: sample_field + value: "1" +- set: + field: my_date + copy_from: "@timestamp" +on_failure: +- set: + field: error.message + value: '{{ _ingest.on_failure_message }}' \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/base-fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/base-fields.yml new file mode 100644 index 00000000000000..7c798f4534ca5e --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/base-fields.yml @@ -0,0 +1,12 @@ +- name: data_stream.type + type: constant_keyword + description: Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: Data stream namespace. +- name: '@timestamp' + type: date + description: Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/fields.yml new file mode 100644 index 00000000000000..43e2060dc22966 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/fields/fields.yml @@ -0,0 +1,39 @@ +- name: bar + type: boolean +- name: uppercase + type: keyword +- name: runtime_boolean + type: boolean + runtime: true +- name: to_be_long + type: long + runtime: true +- name: runtime.day + type: keyword + runtime: >- + emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT)) +- name: lowercase + type: keyword + runtime: >- + emit(doc['uppercase'].value.toLowerCase()) +- name: runtime.date + type: date + date_format: 'yyyy-MM-dd' + runtime: >- + emit(doc['@timestamp'].value.toEpochMilli()) +- name: runtime.epoch_milli + type: long + runtime: >- + emit(doc['@timestamp'].value.toEpochMilli()) +- name: labels.* + type: long + object_type_mapping_type: double + runtime: true +- name: responses + type: group + fields: + - name: runtime_group_boolean + type: boolean + runtime: true + - name: foo + type: boolean diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/manifest.yml new file mode 100644 index 00000000000000..20c70816edc494 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/data_stream/foo/manifest.yml @@ -0,0 +1,21 @@ +title: "Foo" +type: logs +streams: + - input: logfile + title: Sample logs + description: Collect sample logs + vars: + - name: paths + type: text + title: Paths + multi: true + default: + - /var/log/*.log +elasticsearch: + index_template: + mappings: + runtime: + day_of_week_two: + type: keyword + script: + source: "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/docs/README.md new file mode 100644 index 00000000000000..df19b78da8dd97 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/docs/README.md @@ -0,0 +1,84 @@ + + + +# Runtime fields + + + +## Data streams + + + + + + + + + + + +## Requirements + +You need Elasticsearch for storing and searching your data and Kibana for visualizing and managing it. +You can use our hosted Elasticsearch Service on Elastic Cloud, which is recommended, or self-manage the Elastic Stack on your own hardware. + + + +## Setup + + + +For step-by-step instructions on how to set up an integration, see the +[Getting started](https://www.elastic.co/guide/en/welcome-to-elastic/current/getting-started-observability.html) guide. + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-logo.svg b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-logo.svg new file mode 100644 index 00000000000000..6268dd88f3b3db --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-screenshot.png b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/img/sample-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..d7a56a3ecc078c38636698cefba33f86291dd178 GIT binary patch literal 18849 zcmeEu^S~#!E#4Tq;}?6chqwB{?k=6jc5D4>l%v(rleJ2Y%tW zDj9g7px}|*e;{M?LDwiK3@FNS(lDRTd-MJYIyUJCN948~OJk1M(DrJyI#iV;P4k~& zFZo35IfQt0RwlUN`48^6(1dv_wm(y1xhEdMld=Y?!%u=fPT_*{3( zwBwz3#qR}_)t>C*jp5@U)Ti~B)Y;qq*TRxZJ7ZRN_^A3TDAEM*@7Ve%(Ro7=1%1B< zVj6GBUTxXev>_^SFA zgKZ=g4aTS}9>Ofj7cSB0WO?gQ)x=+!hs_)b$6#>ScFZ>XAoIX)%Bc|BDC~JFBk0f0 z0NY}6gb)&!qx^FWC(!ji+Kl$V$2|ocA=vN0TM0Y`U?tX+T)c*C zA!IL(T2Vm%MCLa85^if@J@Kkprx8QN5!6eCR@4Oa5S?4-4|ou?90mFCM8D!;n(5xz zO}-*t!TntN>|a$s(kGQg1P-U?hqvGF2_fGvd&~yZ_l3Qf&j~XWa=;>N3#-~#zjzcc z*m18L`A-K2o!d@J>a8SRbm4P&-q1(H>|JgIymDbnJF&@008`=X!P?4DGgZb>voUl^ zNJKgPR4S={)3vuk_{n@=M8q;;aJL>q+VLdTnO=}`&x;1DKjJA3*f*idS{jP5?+;!W zn-^7021Z4zv`Aq`hmX1aid997RNh3fa-@PG(W7TzKa1W&5^y3|lPeETP7j9qXpo4)7%(W0_2 z^Nmq;t@rb1eP3?%kOkH`P%!zTC7ZHjSfNN3*Sb#=3#jB*KpNGNfnRZ{N(6DrW(;B2Bwom<%m?VQP%K+ zsFeF1-(DY}oP@)w^Kw~gPg03q?N;)Ec6^|nikA34T~RynX*z}H>R~qgT$`Zbhn8wzZs$j2fsGN&rOK-mIBBvzD@a8FgbLpL!h5N^u&0wG} zq!#md3MHITv?3@$37J?lc_5*LWJTTjel;IiU-Yq;(g9I^D&KN_NKVS0O~GvB~FzPM6}=4d%fG4Nw4pZshcyLqK@`b8?RhD38haIyr@+8+0r5TC1*C7^WleJ zZN3_ngTD#RQvNL*;qD2H@cBWJbCC#d!}=oKfod5SE9a?!?j%DVt1z@inN}Iy$r+96 zM@P?AC+(`cM;z6J94BYGJ;+P-N#yj$?`G26ydS&OVH?~JY(N4l()Fh+x+DoJ@r<+i zhm^ck@QP`=fLApr62@KyOef~}zuG;(VbDQmw|Wb+oSHSw=%w9R)=et0cY*~ytX)#M zEXlK^p;zM@vTnXn+C1vwP)~TJv|TvDE2($;;EzC5_5IL#H;u z)#CO8)TSzbt8)wHB8$I8KcIojx&GoE)3QNu{CQ+_xBmQ&`mL5-u=BX(hs^hMY^ zae!!*Q;Tr$@(0~GoBJAohGw*d{l8~!aXop87aaSUb2jm)Tk>#$1*cdo5Sl+?oD!l4Og~yX+soottl4 zp4OartUuAN(dD~yLJ}`A1*!D4-|L^hM;`_DM^1KYs-VF(}h(BjRO``b+xV~%O=-)?p z7ciJH7Fnl?V&=ay_AB{oQoa2iR;6$^tiE|-eRCFy|3F@%j#6gUxkZX@?K`F$u#;T< z4IZORpUthmB?U`;zrOkp?P(Rvd5TFRWrBJmVg;KEZvJ+;Q}FRY%QZ?c^&$oPXW+C5 zdN#c>v%U?QuE+hMQdzxS1Q(BT90;29qu#^A?a^)Ui;{TJ;%`nLgm2ew$J4NvREjCJ z$`C7&?tH$CrVG@M3J1-KJw_*9BKeL*JX{ zN+Vg_TXb9^jJO$ZGkXO6BBFDjt~w5`w2TB*z$&1W5Il3IiDs=ZMDt|9iRtKET*wF6 z0Z+|N87p-5Fh)^(*l>OVr5^aY5LW(@PuM>Qo@&)yj6XRkPm1>eTF#Y_c*aRF^ZY5A z9FAU7lKEHG@i{wJMPg;n6z2|69d-)q9@<7t()d-zPy&X zdXG7{Uw{k23)CzzQAXw#iqj<1u~W@K_Ljc#?ukh;fRKHeJ2l~Z+52b2n^bGiDF2oX zm25FLx|4AP8>rAi@koY03lrtS#X?zK591c?2iZ_jjc>0y>q9>fU<08o6zG%z9WK+S zDwZMW4~28wu#ye#V*@#5t^S@NiAA`3{SF$xINmc_WW^u-C9M=H>RQ1>WM=|R!660{ z6E6%DwX`eu<3pkmz7Z=FCRd$(vhDkc3yMnSr)5C*aho)DZ<12$`$TXj<8Z70)|rK7 zXFD8QzksfWZU`qL2K8X{C~TcF{KVW`3Y{IMb&)T9%1V`tv(HY1 z+LXkLyM|3mtLD{x-#hOw-U?sr-iLeHFA|=-sGZ4#hX)atL!a91(tWJc+og&5W}VfZ zpgE7`{5D`~?yGR++y7~xA&eU0N*ZezDjF$> zUeK&1aTFQRg*?v^Z2e7u<`lk$czR6}b6Cl-qA9%A`#A6q0*zyTu)X`3rhjR86NK3= zLdw{+-F}+b2gxd-qF7>Rla}dFkj|L#c|pg5Ni+MRA|BZH(@ME*o<1ijKcoXb%PVfJ ztp_uf=G%kvU((pHcw90Xut=}atA!giM-5By)f40nKp zv7Wdb{;^<}VRvruH~rYr~wEuYY2ov-5Q|p@u3Da9+z7PeIpBAwi?RxnxN3Kt+N9L(LUS%wxY` z>e&1VV;{CYw8DNRlvBH)>!I49SU4R!t3I4=y;mCevPZh!-}~G+F>6hcL_Rli4r zC4(WN)`j$>^S=~GMGR=^)A6wrqi(-x{xK37&Vx!OS6t=KQ2JVZo#GrSODtTe=TVh%*qfF%91nqsMNLNL^Gp|_ zz%I*HUkMQGqb!1eh{{bp|0GSCDbkG_D_d)8<(0r<6-%Qi7qDa7xZjcdZ$?Rth9L!f z$erCcs3<~mtupywbaT8NWZF#v?iZkvqSz3@p`RiXs7P!GUa~-U9hEG(NgI#3BzO-# z!9JWf(;r!*A=@g$f}>wi|6Q@9z8AmYf~x8G%sp>C5cfuJY;hs1o3Ozu^{pH0AFbs%yU)Xy5>Cf?qXiHn*-PAfKDRiy`U0sFSKFsgEZ6_ z9#ma!<#Izr^}_z*>PRSt564u6We*XmZUx^jv*dK; z4zyFZ*ZFSE!00<6!|+#33&R)@RA8V9YRjp$HS9?CGq*xDSDRbX#i;}mateEF{fqTI zt?X}Efkq_Ap*_ETgaikOBbQ|;47}hwX44K`(DUI@C)QiG&6UJ1UmRn*Q@6%e`+x(gpQp74O{;yli8YLCV}qD z4gIyZd_(8ED~WWaeXOb0^r=9=AiDT}by~+$KVF~M{ywbQl zng-h?a_E;yX?DCr4|_h7JMc7>xgWf7Ek-VmH^hCYunVp3{(d{---&%-GZ=rK#V5Jo zJvP8b!2AA5?9)G8gwzB6ze3TU<5*Pqms^Q-?C9-CN~4hb-`U0D@kAkTWn23``cao^ z8IWAp8h7`%ZA+eI?w$sJktq5m>e&0@mQn>2BdpKAxbj1$m$8Z;`!iFvl9($Lb9Ff? zT^6cTZ~HgIeR6R*;G(rzpgsJP41Fx9Df;G6{;k6T(i}&8hX(jHSC@~#X@70h#)g(( z*9vUC+a*b%oAdf1$}Z3NR;|c5nY4^Z51pfqk(tmJbB;Q#ka#tf5eae;-kq$I{xO3<(TI$0lSe-JQzJ*es;il=Kn_?&?E zfLbs{qErPqm)-*ZfwbA*D-shgb|1;X;cH*yA|q8gS=HiosF=-kbdk6--SR+`F^H_` z0*i`J==@XSe=HT;_``G}ulE=H@*3GU*?gVd@h*`eT^GKjI;C@8+h~;(u3bA#b&bN{ zYw>dJ$(;RfHDLlndS`CWOE=g0jOocCc&;w(dOzrLf4-DK*MD@P_;u&CbfMw=#Q-B` zDq8hGwKN-O7(hQA_bP3f5XrZH+@*FGw~ppmDgNWcf|Lf*Pc%e5dw1DcJ1BWm!z7z3 zr^toEU*P(>G#;_1X}Rz(5lbDtCui%hY^d3lm)kw0vyk zX~K4$AG#7cG`6s2%9g9zsaQ9o?;3yzW4Pt!;NlS zzI#G7tiq&@eV&}qDtY(e$1JwscAfle%Al{3>Nr%``n?`Jac^CdOXUbFgI3;m{RkA~ zokl+lxuw9=%W&MmzA+G%ZdFMMP&N2^6BWjG2Lt|xKx)lMCR@b0n+xgw<)&Dwi?}>- z+$_e|@M;uW@3z6)q&L7bYitZ%huzGqH_qHOr&G5o!?(8TJv_MN1ka|&c6_!Q>#PgHSFoPWiLg|k_{ zQd#Zy&BPkU(0OE5S35!B5qb6%T3Wd#J(zBl8dw6I#xIDDF-LBPi-jXv1E?!gE|1OIdTejK)+U3ooC^otSIRsWZf-`&K}6}s!407Y58zH zK(oYx*7sN1O|Z_1YIJS_H$E@DH(hB4QKNCGQT3PTvwYoe2&8WKi5`5tU-r4!>_V3XUT}N)>8V;+z-!@-IGCKiD>E9RC(K`NMx=;Qp zf$2g^t?)zpU0L!BZi(oE#)^Z_biT*Svh>r#%1=O+Wo37G`Q)4@k#Pe?^mgBIugC)8 zyEICH=`{A~^x#X&%tr-$j|(nXrIrGQYNY+C3M+LO;yUU4-|v>a5#P)XYp>_|C0f0n{_p0mvwWmghfd%!Cm}$qBDxOqA3htLs~ghSA1>6^dVgd~ zVHHBBy6;Pp=El;dkTE=ttp~BoOJ$L@EB3Z37T1kTNG3tm4PY5O-7hP5DA$-k=vV&6 z?RiAm;W~*o)R7!x9>u$&@|&D4xMmJ*y+^-6t!F0u8G~78t&Bs#W>w_NbW>W9M3tXWXRf zI86FWVx%iXXh6MJ>dg#?lNu{K@S#nzMIG4PXQd%!Bvc*H0c7F_Y=adptJr*cHevMQ z%?Xu~q8CFw>^L*S_83kVhq=)hf0%_Lq}SE*g(Da_A{kXVZfAd*YCwp~bG32wi&SNM z#QZ7}Ug5-=+s^uqAh_|}gzya<(&E?XAZ%0ybd9nraj?|z1YfPr*{N?Q{ji}YG`T#| z=uwJZHIMlsmevnenT#-)t$L*=2wh|1EYXW?_36TR?L!sUItJVxaC0$Gb|gq4{|4gA z(v0ODFj!T)jc5>65ys)* z7$aBHfbKdz@QJq1b`NT`344*g()$>5*Ey`TPB7WI;|_8o8t9-_4ikFub|I{66>ge> zHA+6onzFKY*eaiA!77SD*^&LyumAR6gSvxY6Q?;!AvI{rZ##!G$%ZfIgce4F`aF;e z?jVh%+B-vj69ei~bh_zA9w}S4B4rzRKQ1~u$gwVu_x5PlRKDXX2(_2Mm7fs%6{SS7Qh1gWT8xaxc=f8`mW38ukIZxwU;lmHABwFSg50*o zrj%f%j~IKR?N5Dxwrq|sTa?!pd{b3sFM&~{4~_^YH4$bI^Fq2W4-y`))^|7fS?i0) zJ&Z9wY!8%l7@gAr`2{fqA;L;ptQR*X2|xUtrT47KK%XN+dydN$*M?65LuXTRabgERR{n>;E;(&vS0_@COY!p<%5LsRqGpER%~YjkSK zwBo9-2|-ZFiU3TT&S+@}3gDT35t0IXTzX@yHA(v>Y8;-mZNySQ&fE7RJ1^tzJfvdApX& z*!+tE)Y{oR%jk8A)3EiI3i*(TOwP!;B3hAOj?KQ6^h-q~1V^166uYS~mH*2Hh*0}r z`R3u1#^LG9IW|^QT^|61H(T1Jz?n;(Z>52lU0BO>Q6*zgpP*gTFk2Uw)!3zt>3F~_ ztil4!R*-j}wjh%&(kSB%}X=u4RbFRp@^l+$SmM@nW9B;yGbf@nasjFMEE{m9Oe

}qal5$moSACwfNXLXG5|3R0AtBcN` z?%yS)&>O>sqxU64U~C3&Q^>z-Zt}WuX4Wh3dKj9EO zfSbV!c3e;EOeKHQmWEw#NM4;*tw-2o@x&kKT?rsmy-F|$jw-F>WgA7?C@{O1qPg*J zf92|RTBMh&ptHADFc{T+cB?+mOj>h2HKgwkxq6w&XBxPc?>=JKvU2K9aU93@vp-R% z{5T=P$9U}AYZ5QU{3%7}YZ+ACWXw#-U zWyxU(OP#Q9-2AeGmCwcp`zWghf2hvsOjWjDQbU?U`v0&a--f1`v0Bd8HLiLmo)PKz5!A1|XVO+89 zm3h2~6yI~cpWor!_yt-?Lt>z`c0a7cJAW)#d8N8nNIf0H<+v;s4{0guDD(?T7Z<~$ zd`$vpZ_QQgFaMT0_d5&+(jwGU?M1FqUu6wjA-9z?mRM}(CmSdK;2e$Na}F-8jbhgN z9)@AIQeghf{xCC^{9P%VdYW1PP#}2BJwWt z0Hd8%st1NK5%h+)UB^mVwh{e#8TIm$xxgGo6I5;e{~VUeeMGRpM_Z%=eH5$X1}?Z5 z`|*_Vp~K&ziz45-Ih9y>EOr(Buy0&n$dbQ4$5eSr=Ti z#~7^n8dmem;$0D4+6eV7&G2D~d@ z+R#u8+nw_N%7_U_1e53P?~&10^m|ZUXrZhVp04lQLsGos%0fRDhS=@>8TOAAxK;Cy z9GZw_1pfSxD5~xoR!INI?tU0wrKDd6^Tv{jL>`Xb49kBaNPlhMaIfh_nq_)zB7NcX z05XeQKz`@BDUx7*i!V~%dc8XQ#ngBw0A2tSr(npSCrNy5Z7>48v&Zz?0{%FRElh_h zN2|?#EhJL5HQMIu6m1=ypTR?tVymHK)xQvS9ir7FzMp?CjlND39PK`od#GytVhZWp zQ1@>MTE1*Ip>hnXSWa?XbMH#708@j12yPbm`JfcqIgmJepn$5YgkJn_%5I)mr`Q(k z-a0yFR3A`houhvf&|wNpIsV{2p%MqhR@`@R(l6`}iufEgI*UxWq~26?WTpZCV{JtG zYL?&#I98fyf_;2S0?_V{=Aa4t^x%vy$pF$_Lh7W2f*~5uPvGYh;vZhMv|u+Z?2t0~ zcYPXdxbg6OS*LUjR_=jLDt)ab6;?g1IuySLG@UE;jLpt-wjLX&RlY>fnd@f&?0NyT zht5vhP^};k6`U76$%&I)iWPNxG6KPjdh`S6>g9GN@;KObQsLG zKyjfrPR0PU1B0a0=)3@9eCDl?mB9rFdlTMtTAeZv2}F*|@JWleq2+H1bt>>x!^wTk z+I)cgsZwzCMwoRpW_*!3IySTQu!`HWugAXe(Ai(a9Rsu;*0#o6torxwNMxPzEAjt` z>70Vw;HCQ?AnP`RKQ;2R8h%;LI#tx^(MO*lMWJe4_?)Q571P`kTmN#(ez21V!<6+S z@Uap+y%#8&cGgdf+E@y$dUx3g#)=#5k31Vqv0p!%L`*=-PiQAiSg-d9lKRZQDuJ-| zA96zwwomG+4}X$vR*IU=NC!vL<`rUTbf_uRJC4FS;k&HtV<=<)p(qymH)=MDV^aqK z#%sid7K|~!H`J!7hRr~Z!emxgWq6#GpQs%c#BM+scvNGz|Gi4G`;8Z~dP8)+51iB8 zw)0fazNz5(iK$LJeC_4e^8&@wT(DZ~~>SStz3P(>V8CLNlZqgv=2K-|Lu~si@XFwMN>QE^k zVS2U_A?Q$?M`NkU}^!M8m%O&T=kW>dG}1s2I~hxp9Y=a=1XX-(fB5) zej3`e5Et~R^r%?CZK0)UZsF_+tSOGIBMdrtMf#oJjGF9U`*P8t>i*TWed$Z2WNUZ* z_1Qw4Yr+Q0@bD?hD0P-^v}?FpPBg~zz5~g@J#J76C695|P>1l;OS8%~hZh5&-9Ji# z50%&56ZK4FC9}{jHL0!=qo9Yd(GGHCEX2|-F(f}q6@NMT4P3rQd{Q!=bz-8N(Z^!N;;ZzAWRf@C?X>mG=_NgyQX_?Jv$m(9$W>P;+e}O|&w&DjbsJPdWp0A2$yLr*!BY73Z z5d*BCaTI)w=sTlofc>n}@v_tSXIK?8(g`G_06u>SD*fOZJ~visq3lBVS2+cf-r$UQ zZ(8A0g&5M$IV7w5nqL(m$VS0X?=yy-e6>S>Ca3wZNT)b{GF39_gJdONflqc-j$b~o z2l@@h{$KVfC)V?#We*)@xYC;L^<@cHo>8axRMbSzw|eYTl|8pkabsQJ(3`z{>5H}c z`psz_Y6t)hvzL^=}P#++XUl6v`-j)SuXd6BynjNZ!&c2hnyE&4*K$nXn31Zk)cm+lx;> zya{T?{MRtSu?^3Y9bS&O$*mW^vRUpv!J3Tz12?3&Y62b_oiZ$24O(75Z)JWb+Rj)ACbK`f<&tSwtT$|Sy z$41kRPiM-jnPY9PKrLyI`pHm6LusMsrO*HpmE){Kp1^u2t%6nW^;GB|!4k!Ik8oav zjM?DBKh9G@W0gEwiU-M}0B)}olvoM71RccgiZBCs)L?q_GX&JDhegx4k2&cNatr5w zU)1#2USb8&`etO5Vk z?0}K+*2*@a5yt*X{qg0@8jEz~jcylVj>-042p1PBnabI#xUiCRD!ouw3?u-wwsqwF z8(@m8-Lk7q@v154g6yvx_tRDa>}oqpVda)wfI9(;ZVGt1v^{<|X?vC_(i@IJC+2I_lusrT=$h zF1lPc*Neb`;Xgrdf`p$w)~MzQW0M3_FYRKu{2$VU82J^B=X1#^<&P$_`=S$Ey04WU zTxG;hrFNLhWC*p+sH3x=JVcBJ9*7>eO20)n671SxQhZQlHMRP8FyO}yai~OTsbms0 zQ3b$C1Cn!>jMHDq{VX1ab^~_Q!z+f75+_AuwiN0*wA_#M#0|rU{+NlB%>Y+TNT0Gj z`3^LKMSJjz2(?lwg~ixDl_5%rzzZ}o_6Fj9e)T7gpH4=BgT1zmwJpC@g(f%&0`}8B z%7Y&qlP3aFmI#nmT`|R3+Lwzp+PLXt|5g%vlY_$fvse7zjus0D0fA##r+i4G4K-2Y zC#H95NGoYfWP#ZF_v$^Li{PZpm}fc&)aL?5doPcb835Cr6`T+EzzcEvLtmXcbAb<^ zw!_Zgk6Az7YA@*vb)(G{_W-B|zrf76z^`X%jOgqIIaqi~5nUup3vugzzg&rA^w(zR z+qCzvIV~nGR=47pDOcNTzuBw#5a=<=DMvGa)g zPw$^pmq9Fg&b#BZrPSoml(149rZS!fioV*Dy$z440U3MXDJmI?RZqLy0}IKSxN)o( z8+8wIZs#q(|KTg6y;Z(=96>xfpUsr@SP}I^v zN^R;ZVrDaWmNrM5-<X@k6JyjvA3;jHhma|Y|7!Vk& zgf(UK_6~cC;!|b!YTjke=nBiUqQdb#I9TY}!s5P)H+^c;9cW(QO8O%n5J^8Xfktd*qrn)+?-gP`m%B&q zi^}7jKm`yMW8ITFOMN#!QIB6$SWx*75tnCMaNg*_J*WuwBh~AT>0($nS8%&zmFQDp z$dL65niDtTV%!Kg1`6epWoQGNG`$`doy;Zjaa`keyL0F6iJMae6FIgnhAfzU%m@V+ zm5rQihLwS~b6{-bVR1ZSzBI7(Yj+V6T-8V*7I`ptWArGdy~8pnV>fALpi~NQLZ7;^ zpaj35=md<~-(tNmF69UX3?ua}A7UIn)q5i1iPYEGlhYSbkfeX`5epkxtzk3Qbu| zlgA`7ts%IvF4HJ}-98akyRnjCo{u-`A4&b+r?s|o`4wdYAHs-yh91p$7C_|+EdYH5 z10`!*=n+W9g>V&dfU1H!J}ASZi&-?`2IlDOAHnu306rD`y>jT)4^@S(X4XhN2{g9i zj-ym98+RT|d0ejIFJCM5>S{mT-8uGmRRqkJ3sMO_AQDrv77Q zv$t>zaVpVF6eBguE%9M2u?E-Oleft8z5+~W`G}KXD(Yc;7m4{Op>Le(k`g1UK7(1# zt6g}$n=Tdn{T4pu>v!c;xRCd_WI$Ali13x=U_0T!Ga-U~9W88q-lU+RLn2`N8Ouho z^0@SvC>$DguHWx)?^*ms-{PVq%dn(U3vrLj9zITDqQZ`H>Wsp@Gf%}SG=m)Vh}F$ztQAbwVGdDgd!28j&yX9wLW&s! zNR~6`nYg;ULAq8zi<;gUchAV5ib67Y##l2 zy+%gaD(|~G4@||{A;TYDSoS>q2o{t23t-^!NDSDEm8j3ao7Ei>KYLEpb$jz}7ciAM zD}trDN+AVVT_lXW<++~>8>Cj8fzJo@R;>%nGq)6+w?(#mNc#1J4W+!hA}?g$0Xqo? zn67qJmss)e%k(xO*&K@z6+}nHA(lCkb6n-|{pSztys$8HiOWTVR)tCO*Q9~if%3n7`uxGzE+OCu zwcVV|tgQdq60952$>85-GHk$lwM(uI+CU1?i{sVnKd0+UNq#eSSKjUKfDDgLnBG1y z^v?f#MRFkph~TgkoKBvM`L_~we8__xpLcjh`GwV|87q`vazJq?SX=mXhdvK>VqUf~ z4sYoTIpt5S)KrE-?>&=cRoBumD7;b5pq!Y07)#I$`)<@U+mo*dE*P~773p*u^6waO z2#thJahX_ySlYMpjx%h<)i43ao~Is`^Ya zMNZkuChEA7+ZJe6$>-C*dzTYf3#1SY82yFG?S&Q)5rTbKS-XLjckTLEc7>^sFcntQ zBeNXCSg&q1N3Bi^4zlQ%mcEBQ%2ab$?(;t-$HYd2%cnX$uuwU#I_6D3($m zR(>gHzM9ODf;r8b0l5LuEIQVZiQ0-|3Y_xzJkZc*CD=bPJ+&J+>>se%D4uTq?Ny{l z0Z5~og*Wa1O&anlcRWu_%o)(x?IZ0CfUNk_R-ik>GyvdFmpu1wHZaKTDGhL zqxsji)n<+)VKbV0_BRq9E;Kb`f=&vn(BK0Ba-gL?ZN;^^b3YFg6R=!q#zM;tcX0dM zdy5PPx@6pJPXHzH7$dGjM|6@6777nXPWV;CIQdNf(*Znv)sMy&Xcq> zhCq+6h6&v8<0}vd2(sKqU3j>fr7&#Xy%qZHcMU3m{wld^Nstkz8GagB?Y=SI&H z&{&BSA-|(i35$9(l6LpFyLm$0M0fK`Dz!~ezL?yEInsXAFR!bHe;ZL>Gd(#Hv?<$%`^b)oi?x%(jkylCPb=juPlF znMo&o961=NZ_$gd{xp1ZY2dNDOS!=XVj!M^A z+$z`EK4v=m{Bs{&I4W)({`&<5*^BV#z{IBAI_d+9Qx;~ zby?2zEjzUUeZWBDo5cz>%;z||z)<+6UtC)y60yD5J5`oo_zSM;l21@CY<0_|)NME5 zs)kHCMBa5YzB#N=W2aR?y9((~WuYwwf+HAc2mvU>NYlxOTvGf^Ye3za?*f-qUs^`a zT3>RPh9*Jf%3*bf|kqtnD_Buxv!<9N>BbuD#uYv-q^ z%RDnd7a3O4M9Y~TNISS@9K}JDkdg@>x8E6@n8jF=6qiDV+}{!V)(o?ykcr0sxBGEx zo!X;pc=r{H^vw6ztV5VZXBa4~(ujB$rZQ|AaGN@J7#q%2nU9gJ)g6dcj}zYB1& z@iFE0vMQVxa|v7tDHS$gwX$Ihc#M^DXRC>J@Zk?dC(3uB_s~*W&m-01DFMQGWjj5x z5po1@1gPl!v1Yra@qPG{D;$bYLM3qOwpl~7f~l)#n< zP+6`!NYe3EE~4RFR#_e=7YctPRBt6$He@`%e5m}f$M%yzC2S0<1}hRPjO>HJY~ z*dx(nbMbjv*;o&k{qzBdF|lS;UNVKziV=gbLq}UOCwr8GT5E9oRYQ}+>DhbQ1R=lj zgcNJN8|D)$Mx3#c+t@lhqcDUnHGVt0&EyQ{b5)=52B(VTzw=pQ^ba3`JB@BU^lS`_ zJEiLzgU#Acd_!}FMxCWC**FP^i#P}bYzNs78)#uSejEtYLbG>JJ7Igtho2oKQ;XW~ z4eMGO+t!_;G^V6c&R`5Tg+Pz2ToN(aybq4Q0ssie_{`t*DO%V7FaZ`{MBobFc9|pV z70o5ayHGJo9$$&Pgbs)pWNzduAcbh?~U?_P)(ve0S*3H%eNF&a5XR=!J#4c z;t992n7ZJr{*%`^dU1d-ALE8!3i#v;3r4r%j+JFCe=%3Vj=8{aXe zs)jrcUBZ=;LudcTUXj2ub>K5!{HHFHJ}Trx(PYugbQ8yK7&sqX;(;|UWjk3tGs3zuceeX)i4i_jA8Qz2Bc%DxN8 zXw!$+9jBtEHd1y90bYG4f8DcJM)Ab!M39tH5zz94*MAvnhA377@buNupSOUU3j8~> zd6&hk^ENRCp9T?_QUHk<=(&9Q^MJ^pi;nKOYNR@?L=RCSmKMJ5UQJQ`X!i~(gD*P! zs`RobzJG3Ra_Pg+WZUXUmMU$ilpwfcEti6)mw(~MZ0q!^sza>#jv!-+7B6F3QuMWg zVO!rXwD+lF1BBTito?ml-CV3vxuek~TKuOX^N6sol$v*{_%nAuD7i81eXm^Lz(Z~I z2Xj_Dts#G0&C;PV_Wkq*1QvB7+Post4={v;gk7b9u%#DC_bh(iJm$rqog^{JEx6NE zrs5^2SEL$|98#2WV#iG@L6cq|)SuTMSfGocPl65wUd^|5Lbpnb(;t>-Qu2jvANLgv zdte0vED-3C@^BdyHWLL(7{G$WA02z@JG!T-U^Q7HZ(7Bs&vchkh(p&}KvnS{MG^i6 z4r){gJp9p7WyWOEiKA2Cm6EXIn&&gk|Fc6^78OpPrX4ExCFE=SD$xcH;C2eB^{XTI zaxz_Cef*Yj==w_i_BTGXP;8C&f? z*QEM>={jFM8)lWAR870pG4XEWsl%%K|82S5b=9hVz7p_6i-d(Iyvq76&a#PV zR;VbQV|n?mg}&(ehClg%tK%IjgtnTR-u)lxH06XxXqH0soAZbB_Rm)XX=6Nge1uoG7 z9vQM_S~2h53n|W`y{{R9+=08rv~MohI_v4-BU^7fZ0-A}#b5{AOSTJm+(J;9yw%pD zX6u62GJ&@HKX5zQwq~j8T!Hrv-Mk^QSB5cu09L03{ToDO7jikM0WAcsjW>D}^jqCF zT0DEZ@K^KO_MD*%M!+V)lGVU6?LpX)eQVXEmq}R`NIJv;kBitJ!nW?0OxTVlu2ADf zE{A!*0g3%nwVcBD+AgT5bGx@WOnQk{zRpiZ4HhP`3BF%N|HdqPbbiV5)7x)kzC3ID zZ;27>0^mrMgWc7evsbQY`l`l})wr+e;=8U_!2&B77;1qL!N8y)eTJ2lf#CvhR~!Qa mc;sM|90DP5A*JW%f2r=u1xt!e4gwD_V(@hJb6Mw<&;$SznOm^{ literal 0 HcmV?d00001 diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/manifest.yml new file mode 100644 index 00000000000000..4dac02c33371a7 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/runtime_fields/0.0.1/manifest.yml @@ -0,0 +1,33 @@ +format_version: 2.8.0 +name: runtime_fields +title: "Runtime fields" +version: 0.0.1 +source: + license: "Elastic-2.0" +description: "This is a new package." +type: integration +categories: + - custom +conditions: + kibana.version: "^8.10.0" + elastic.subscription: "basic" +screenshots: + - src: /img/sample-screenshot.png + title: Sample screenshot + size: 600x600 + type: image/png +icons: + - src: /img/sample-logo.svg + title: Sample logo + size: 32x32 + type: image/svg+xml +policy_templates: + - name: sample + title: Sample logs + description: Collect sample logs + inputs: + - type: logfile + title: Collect sample logs from instances + description: Collecting sample logs +owner: + github: elastic/integrations From ea1e6ed240a419f5f4f705c980d34c19c29af47c Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 12 Jul 2023 11:01:06 +0200 Subject: [PATCH 04/20] [root] add more logs for bootstrap and shutdown (#161629) ## Summary Looking at the logs, I realized we were not logging anything in `info` level when Kibana is starting up or shutting down at the `Root` level, making it quite awkward when trying to understand when an instance / pod was started or shut down and even why. Also, we were not logging the stack trace of the shutdown reason when present. FWIW, this is (the exhaustive list of) what's displayed in some shutdown scenarios (most recent to least recent): Screenshot 2023-07-11 at 11 41 27 As you can see: 1. We have no idea why Kibana was shut down 2. We don't know where this `no element in sequence` error even comes from This PR adds a few logs: - `Kibana is starting` during `bootstrap` - `Kibana is shutting down` during `shutdown` - The shutdown reason's stack when provided - `SIGINT received - initiating shutdown` and `SIGTERM received - initiating shutdown` when receiving the associated signals --- .../core-root-server-internal/src/bootstrap.ts | 15 ++++++++++++--- .../core-root-server-internal/src/root/index.ts | 12 ++++++++++-- ...h_size_bytes_exceeds_es_content_length.test.ts | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/core/root/core-root-server-internal/src/bootstrap.ts b/packages/core/root/core-root-server-internal/src/bootstrap.ts index 90e603fb20d48e..5c35e03ecf6565 100644 --- a/packages/core/root/core-root-server-internal/src/bootstrap.ts +++ b/packages/core/root/core-root-server-internal/src/bootstrap.ts @@ -49,6 +49,9 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot const root = new Root(rawConfigService, env, onRootShutdown); const cliLogger = root.logger.get('cli'); + const rootLogger = root.logger.get('root'); + + rootLogger.info('Kibana is starting'); cliLogger.debug('Kibana configurations evaluated in this order: ' + env.configs.join(', ')); @@ -77,8 +80,14 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot cliLogger.info(`Reloaded Kibana configuration (reason: ${reason}).`, { tags: ['config'] }); } - process.on('SIGINT', () => shutdown()); - process.on('SIGTERM', () => shutdown()); + process.on('SIGINT', () => { + rootLogger.info('SIGINT received - initiating shutdown'); + shutdown(); + }); + process.on('SIGTERM', () => { + rootLogger.info('SIGTERM received - initiating shutdown'); + shutdown(); + }); function shutdown(reason?: Error) { rawConfigService.stop(); @@ -96,7 +105,7 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot } if (isSetupOnHold) { - root.logger.get().info('Holding setup until preboot stage is completed.'); + rootLogger.info('Holding setup until preboot stage is completed.'); const { shouldReloadConfig } = await preboot.waitUntilCanSetup(); if (shouldReloadConfig) { await reloadConfiguration('configuration might have changed during preboot stage'); diff --git a/packages/core/root/core-root-server-internal/src/root/index.ts b/packages/core/root/core-root-server-internal/src/root/index.ts index 82c7d8feaec782..42949fda6b31db 100644 --- a/packages/core/root/core-root-server-internal/src/root/index.ts +++ b/packages/core/root/core-root-server-internal/src/root/index.ts @@ -81,7 +81,7 @@ export class Root { } public async shutdown(reason?: any) { - this.log.debug('shutting root down'); + this.log.info('Kibana is shutting down'); if (reason) { if (reason.code === 'EADDRINUSE' && Number.isInteger(reason.port)) { @@ -91,7 +91,7 @@ export class Root { } if (reason.code !== MIGRATION_EXCEPTION_CODE) { - this.log.fatal(reason); + this.log.fatal(formatShutdownReason(reason)); } } @@ -159,3 +159,11 @@ export class Root { this.loggingConfigSubscription.add(connectSubscription); } } + +const formatShutdownReason = (reason: any): string => { + let message = `Reason: ${reason.message ?? reason}`; + if (reason.stack) { + message = `${message}\n${reason.stack}`; + } + return message; +}; diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/batch_size_bytes_exceeds_es_content_length.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/batch_size_bytes_exceeds_es_content_length.test.ts index 26a26764c04b44..95baed313bd8c2 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/batch_size_bytes_exceeds_es_content_length.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/batch_size_bytes_exceeds_es_content_length.test.ts @@ -82,7 +82,7 @@ describe('migration v2', () => { expect( records.find((rec) => rec.message.startsWith( - `Unable to complete saved object migrations for the [.kibana] index: While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option.` + `Reason: Unable to complete saved object migrations for the [.kibana] index: While indexing a batch of saved objects, Elasticsearch returned a 413 Request Entity Too Large exception. Ensure that the Kibana configuration option 'migrations.maxBatchSizeBytes' is set to a value that is lower than or equal to the Elasticsearch 'http.max_content_length' configuration option.` ) ) ).toBeDefined(); From 47d099e22f537df820858a31e00f05343c026e25 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Wed, 12 Jul 2023 12:09:41 +0300 Subject: [PATCH 05/20] [Cloud Security] Adding vuln filter to the scores index (#161648) --- .../server/tasks/findings_stats_task.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts index 756cf739711d40..14bb41964e8b6b 100644 --- a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts +++ b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts @@ -14,6 +14,7 @@ import { import { SearchRequest } from '@kbn/data-plugin/common'; import { ElasticsearchClient } from '@kbn/core/server'; import type { Logger } from '@kbn/core/server'; +import { getSafeVulnerabilitiesQueryFilter } from '../../common/utils/get_safe_vulnerabilities_query_filter'; import { getSafePostureTypeRuntimeMapping } from '../../common/runtime_mappings/get_safe_posture_type_runtime_mapping'; import { getIdentifierRuntimeMapping } from '../../common/runtime_mappings/get_identifier_runtime_mapping'; import { @@ -181,9 +182,7 @@ const getScoreQuery = (): SearchRequest => ({ const getVulnStatsTrendQuery = (): SearchRequest => ({ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, size: 0, - query: { - match_all: {}, - }, + query: getSafeVulnerabilitiesQueryFilter(), aggs: { critical: { filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.CRITICAL } }, From 5fca22a79db0ad72d539d7ee297211653e607026 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 12 Jul 2023 11:10:03 +0200 Subject: [PATCH 06/20] [Synthetics] Refactor e2e tests for silent failures (#161638) --- .../journeys/services/synthetics_services.ts | 69 ++++--------------- 1 file changed, 14 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts b/x-pack/plugins/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts index 9f815a2970f7f9..47199b38f0a3b4 100644 --- a/x-pack/plugins/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts +++ b/x-pack/plugins/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts @@ -200,70 +200,29 @@ export class SyntheticsServices { }); } - async cleaUp(things: Array<'monitors' | 'alerts' | 'rules'> = ['monitors', 'alerts', 'rules']) { - const promises = []; - if (things.includes('monitors')) { - promises.push(this.cleanTestMonitors()); - } - if (things.includes('alerts')) { - promises.push(this.cleaUpAlerts()); - } - - if (things.includes('rules')) { - promises.push(this.cleaUpRules()); - } - - await Promise.all(promises); - } - - async cleaUpAlerts() { - const getService = this.params.getService; - const es: Client = getService('es'); - const listOfIndices = await es.cat.indices({ format: 'json' }); - for (const index of listOfIndices) { - if (index.index?.startsWith('.internal.alerts-observability.uptime.alerts')) { - await es.deleteByQuery({ index: index.index, query: { match_all: {} } }); - } - } - } - - async cleaUpRules() { + async cleaUp() { try { - const { data: response } = await this.requester.request({ - description: 'get monitors by name', - path: `/internal/alerting/rules/_find`, - query: { - per_page: 10, - page: 1, - }, - method: 'GET', - }); - const { data = [] } = response as any; - - if (data.length > 0) { - // eslint-disable-next-line no-console - console.log(`Deleting ${data.length} rules`); + const getService = this.params.getService; + const server = getService('kibanaServer'); - await axios.patch( - this.kibanaUrl + '/internal/alerting/rules/_bulk_delete', - { - ids: data.map((rule: any) => rule.id), - }, - { auth: { username: 'elastic', password: 'changeme' }, headers: { 'kbn-xsrf': 'true' } } - ); - } + await server.savedObjects.clean({ types: ['synthetics-monitor', 'alert'] }); + await this.cleaUpAlerts(); } catch (e) { // eslint-disable-next-line no-console console.log(e); } } - async cleanTestMonitors() { - const getService = this.params.getService; - const server = getService('kibanaServer'); - + async cleaUpAlerts() { try { - await server.savedObjects.clean({ types: ['synthetics-monitor'] }); + const getService = this.params.getService; + const es: Client = getService('es'); + const listOfIndices = await es.cat.indices({ format: 'json' }); + for (const index of listOfIndices) { + if (index.index?.startsWith('.internal.alerts-observability.uptime.alerts')) { + await es.deleteByQuery({ index: index.index, query: { match_all: {} } }); + } + } } catch (e) { // eslint-disable-next-line no-console console.log(e); From 707a637f426dbef9cb3201b356cab5af118c8850 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 12 Jul 2023 12:27:34 +0200 Subject: [PATCH 07/20] [Synthetics] Fix parsing of response check json expression (#161634) --- .../synthetics/server/synthetics_service/formatters/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/common.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/common.ts index 1812917af9d7d5..3a8776dd0cf05f 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/common.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/common.ts @@ -10,8 +10,10 @@ import { ConfigKey } from '../../../common/constants/monitor_management'; export const PARAMS_KEYS_TO_SKIP = [ 'secrets', 'fields', + ConfigKey.MONITOR_QUERY_ID, ConfigKey.LOCATIONS, ConfigKey.TLS_VERSION, ConfigKey.SOURCE_PROJECT_CONTENT, ConfigKey.SOURCE_INLINE, + ConfigKey.RESPONSE_JSON_CHECK, ]; From 90b7b1a3c8aafbe9fbafd9f4d28732c69912fdbb Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Wed, 12 Jul 2023 18:34:14 +0800 Subject: [PATCH 08/20] [Enterprise Search] Add reasonable error handling for connector index absence (#161729) This adds reasonable error handling and returns for connectors in case the indices haven't been created yet. Specifically: - Creating a connector or sync job targets the concrete index so the index template can be triggered. - Fetches of connectors or sync jobs return undefined or empty arrays if the index doesn't exist. --- .../enterprise_search/common/constants.ts | 14 + .../plugins/enterprise_search/server/index.ts | 1 + .../index_management/setup_indices.test.ts | 399 ------------------ .../server/index_management/setup_indices.ts | 340 --------------- .../lib/connectors/add_connector.test.ts | 153 +------ .../server/lib/connectors/add_connector.ts | 31 +- .../connectors/fetch_connector_index_names.ts | 22 +- .../lib/connectors/fetch_connectors.test.ts | 111 ++--- .../server/lib/connectors/fetch_connectors.ts | 14 +- .../lib/connectors/fetch_sync_jobs.test.ts | 12 +- .../server/lib/connectors/fetch_sync_jobs.ts | 5 +- .../server/lib/connectors/start_sync.test.ts | 10 +- .../server/lib/connectors/start_sync.ts | 4 +- .../server/lib/indices/fetch_index.ts | 50 ++- .../lib/pipelines/get_default_pipeline.ts | 38 +- .../lib/pipelines/update_default_pipeline.ts | 11 +- .../server/lib/stats/get_sync_jobs.ts | 203 ++++----- 17 files changed, 258 insertions(+), 1160 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts delete mode 100644 x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 0e1e7be69f492b..14da6aeca49190 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -199,6 +199,20 @@ export const DEFAULT_PIPELINE_VALUES: IngestPipelineParams = { run_ml_inference: false, }; +export interface DefaultConnectorsPipelineMeta { + default_extract_binary_content: boolean; + default_name: string; + default_reduce_whitespace: boolean; + default_run_ml_inference: boolean; +} + +export const defaultConnectorsPipelineMeta: DefaultConnectorsPipelineMeta = { + default_extract_binary_content: DEFAULT_PIPELINE_VALUES.extract_binary_content, + default_name: DEFAULT_PIPELINE_NAME, + default_reduce_whitespace: DEFAULT_PIPELINE_VALUES.reduce_whitespace, + default_run_ml_inference: DEFAULT_PIPELINE_VALUES.run_ml_inference, +}; + export enum INGESTION_METHOD_IDS { API = 'api', CONNECTOR = 'connector', diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index 29186418143417..1ba45845179121 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -51,5 +51,6 @@ export const config: PluginConfigDescriptor = { export const CONNECTORS_INDEX = '.elastic-connectors'; export const CURRENT_CONNECTORS_INDEX = '.elastic-connectors-v1'; export const CONNECTORS_JOBS_INDEX = '.elastic-connectors-sync-jobs'; +export const CURRENT_CONNECTORS_JOB_INDEX = '.elastic-connectors-v1'; export const CONNECTORS_VERSION = 1; export const CRAWLERS_INDEX = '.ent-search-actastic-crawler2_configurations_v2'; diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts deleted file mode 100644 index 95902da5fdac74..00000000000000 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -/* - * 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 { CONNECTORS_INDEX, CONNECTORS_JOBS_INDEX, CONNECTORS_VERSION } from '..'; - -import { defaultConnectorsPipelineMeta, setupConnectorsIndices } from './setup_indices'; - -describe('Setup Indices', () => { - const mockClient = { - asCurrentUser: { - indices: { - create: jest.fn(), - get: jest.fn(), - stats: jest.fn(), - updateAliases: jest.fn(), - }, - search: jest.fn(), - }, - asInternalUser: {}, - }; - - const connectorsIndexName = `${CONNECTORS_INDEX}-v${CONNECTORS_VERSION}`; - const jobsIndexName = `${CONNECTORS_JOBS_INDEX}-v${CONNECTORS_VERSION}`; - - const connectorsMappings = { - _meta: { - version: CONNECTORS_VERSION, - pipeline: defaultConnectorsPipelineMeta, - }, - dynamic: false, - properties: { - api_key_id: { - type: 'keyword', - }, - configuration: { - type: 'object', - }, - custom_scheduling: { - type: 'object', - }, - description: { type: 'text' }, - error: { type: 'keyword' }, - features: { - properties: { - filtering_advanced_config: { type: 'boolean' }, - filtering_rules: { type: 'boolean' }, - incremental_sync: { - properties: { - enabled: { type: 'boolean' }, - }, - }, - sync_rules: { - properties: { - basic: { - properties: { - enabled: { type: 'boolean' }, - }, - }, - advanced: { - properties: { - enabled: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - filtering: { - properties: { - active: { - properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - rules: { - properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, - }, - }, - validation: { - properties: { - errors: { - properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, - }, - }, - state: { type: 'keyword' }, - }, - }, - }, - }, - domain: { type: 'keyword' }, - draft: { - properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - rules: { - properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, - }, - }, - validation: { - properties: { - errors: { - properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, - }, - }, - state: { type: 'keyword' }, - }, - }, - }, - }, - }, - }, - index_name: { type: 'keyword' }, - is_native: { type: 'boolean' }, - language: { type: 'keyword' }, - last_access_control_sync_error: { type: 'keyword' }, - last_access_control_sync_scheduled_at: { type: 'date' }, - last_access_control_sync_status: { type: 'keyword' }, - last_deleted_document_count: { type: 'long' }, - last_incremental_sync_scheduled_at: { type: 'date' }, - last_indexed_document_count: { type: 'long' }, - last_seen: { type: 'date' }, - last_sync_error: { type: 'keyword' }, - last_sync_scheduled_at: { type: 'date' }, - last_sync_status: { type: 'keyword' }, - last_synced: { type: 'date' }, - name: { type: 'keyword' }, - pipeline: { - properties: { - extract_binary_content: { type: 'boolean' }, - name: { type: 'keyword' }, - reduce_whitespace: { type: 'boolean' }, - run_ml_inference: { type: 'boolean' }, - }, - }, - scheduling: { - properties: { - access_control: { - properties: { - enabled: { type: 'boolean' }, - interval: { type: 'text' }, - }, - }, - incremental: { - properties: { - enabled: { type: 'boolean' }, - interval: { type: 'text' }, - }, - }, - full: { - properties: { - enabled: { type: 'boolean' }, - interval: { type: 'text' }, - }, - }, - }, - }, - service_type: { type: 'keyword' }, - status: { type: 'keyword' }, - sync_cursor: { type: 'object' }, - sync_now: { type: 'boolean' }, - }, - }; - - const connectorsJobsMappings = { - _meta: { - version: CONNECTORS_VERSION, - }, - dynamic: false, - properties: { - cancelation_requested_at: { type: 'date' }, - canceled_at: { type: 'date' }, - completed_at: { type: 'date' }, - connector: { - properties: { - configuration: { type: 'object' }, - filtering: { - properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - domain: { type: 'keyword' }, - rules: { - properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, - }, - }, - warnings: { - properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, - }, - }, - }, - }, - id: { type: 'keyword' }, - index_name: { type: 'keyword' }, - language: { type: 'keyword' }, - pipeline: { - properties: { - extract_binary_content: { type: 'boolean' }, - name: { type: 'keyword' }, - reduce_whitespace: { type: 'boolean' }, - run_ml_inference: { type: 'boolean' }, - }, - }, - service_type: { type: 'keyword' }, - sync_cursor: { type: 'object' }, - }, - }, - created_at: { type: 'date' }, - deleted_document_count: { type: 'integer' }, - error: { type: 'keyword' }, - indexed_document_count: { type: 'integer' }, - indexed_document_volume: { type: 'integer' }, - job_type: { type: 'keyword' }, - last_seen: { type: 'date' }, - metadata: { type: 'object' }, - started_at: { type: 'date' }, - status: { - type: 'keyword', - }, - total_document_count: { type: 'integer' }, - trigger_method: { type: 'keyword' }, - worker_hostname: { type: 'keyword' }, - }, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - describe('setupConnectorsIndices', () => { - it('should do nothing if indices exist', async () => { - const result = { - [connectorsIndexName]: { - mappings: { - _meta: { - version: CONNECTORS_VERSION, - }, - }, - }, - [jobsIndexName]: { - mappings: { - _meta: { - version: CONNECTORS_VERSION, - }, - }, - }, - }; - mockClient.asCurrentUser.indices.get.mockImplementation(() => Promise.resolve(result)); - mockClient.asCurrentUser.indices.create.mockImplementation(() => Promise.resolve()); - mockClient.asCurrentUser.indices.updateAliases.mockImplementation(() => Promise.resolve()); - await expect(setupConnectorsIndices(mockClient.asCurrentUser as any)).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.indices.create).not.toHaveBeenCalled(); - expect(mockClient.asCurrentUser.indices.updateAliases).not.toHaveBeenCalled(); - }); - it('should do nothing if it hits race condition exist', async () => { - const result = { - [connectorsIndexName]: { - mappings: { - _meta: { - version: CONNECTORS_VERSION, - }, - }, - }, - [jobsIndexName]: { - mappings: { - _meta: { - version: CONNECTORS_VERSION, - }, - }, - }, - }; - mockClient.asCurrentUser.indices.get.mockImplementation(() => Promise.resolve(result)); - mockClient.asCurrentUser.indices.create.mockImplementation(() => - Promise.reject({ meta: { body: { error: { type: 'resource_already_exists_exception' } } } }) - ); - mockClient.asCurrentUser.indices.updateAliases.mockImplementation(() => Promise.resolve()); - await expect(setupConnectorsIndices(mockClient.asCurrentUser as any)).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.indices.create).not.toHaveBeenCalled(); - expect(mockClient.asCurrentUser.indices.updateAliases).not.toHaveBeenCalled(); - }); - it('should create new index and update alias if connectors index does not exist', async () => { - const result = { - [jobsIndexName]: { - mappings: { - _meta: { - version: CONNECTORS_VERSION, - }, - }, - }, - }; - mockClient.asCurrentUser.indices.get.mockImplementation(() => Promise.resolve(result)); - mockClient.asCurrentUser.indices.create.mockImplementation(() => Promise.resolve()); - mockClient.asCurrentUser.indices.updateAliases.mockImplementation(() => Promise.resolve()); - await expect(setupConnectorsIndices(mockClient.asCurrentUser as any)).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ - index: connectorsIndexName, - mappings: connectorsMappings, - settings: { auto_expand_replicas: '0-3', hidden: true, number_of_replicas: 0 }, - }); - expect(mockClient.asCurrentUser.indices.updateAliases).toHaveBeenCalledWith({ - actions: [ - { - add: { - aliases: [CONNECTORS_INDEX], - index: `${CONNECTORS_INDEX}-v${CONNECTORS_VERSION}`, - is_hidden: true, - is_write_index: true, - }, - }, - ], - }); - }); - it('should create new jobs index and update alias if jobs index does not exist', async () => { - const result = { - [connectorsIndexName]: { - mappings: { - _meta: { - version: CONNECTORS_VERSION, - }, - }, - }, - }; - mockClient.asCurrentUser.indices.get.mockImplementation(() => Promise.resolve(result)); - mockClient.asCurrentUser.indices.create.mockImplementation(() => Promise.resolve()); - mockClient.asCurrentUser.indices.updateAliases.mockImplementation(() => Promise.resolve()); - await expect(setupConnectorsIndices(mockClient.asCurrentUser as any)).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ - index: jobsIndexName, - mappings: connectorsJobsMappings, - settings: { auto_expand_replicas: '0-3', hidden: true, number_of_replicas: 0 }, - }); - expect(mockClient.asCurrentUser.indices.updateAliases).toHaveBeenCalledWith({ - actions: [ - { - add: { - aliases: [CONNECTORS_JOBS_INDEX], - index: `${CONNECTORS_JOBS_INDEX}-v${CONNECTORS_VERSION}`, - is_hidden: true, - is_write_index: true, - }, - }, - ], - }); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts deleted file mode 100644 index f9c124bbb56dc3..00000000000000 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - * 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 { - IndicesIndexSettings, - MappingProperty, - MappingTypeMapping, -} from '@elastic/elasticsearch/lib/api/types'; -import { ElasticsearchClient } from '@kbn/core/server'; - -import { CONNECTORS_INDEX } from '..'; -import { isResourceAlreadyExistsException } from '../utils/identify_exceptions'; - -export enum SETUP_ERRORS { - 'insufficient_permissions', - 'index_already_exists', -} - -interface IndexDefinition { - aliases: string[]; - mappings: MappingTypeMapping; - name: string; - settings: IndicesIndexSettings; -} - -const connectorMappingsProperties: Record = { - api_key_id: { type: 'keyword' }, - configuration: { type: 'object' }, - custom_scheduling: { type: 'object' }, - description: { type: 'text' }, - error: { type: 'keyword' }, - features: { - properties: { - filtering_advanced_config: { type: 'boolean' }, - filtering_rules: { type: 'boolean' }, - incremental_sync: { - properties: { - enabled: { type: 'boolean' }, - }, - }, - sync_rules: { - properties: { - basic: { - properties: { - enabled: { type: 'boolean' }, - }, - }, - advanced: { - properties: { - enabled: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - filtering: { - properties: { - active: { - properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - rules: { - properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, - }, - }, - validation: { - properties: { - errors: { - properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, - }, - }, - state: { type: 'keyword' }, - }, - }, - }, - }, - domain: { type: 'keyword' }, - draft: { - properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - rules: { - properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, - }, - }, - validation: { - properties: { - errors: { - properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, - }, - }, - state: { type: 'keyword' }, - }, - }, - }, - }, - }, - }, - index_name: { type: 'keyword' }, - is_native: { type: 'boolean' }, - language: { type: 'keyword' }, - last_access_control_sync_error: { type: 'keyword' }, - last_access_control_sync_scheduled_at: { type: 'date' }, - last_access_control_sync_status: { type: 'keyword' }, - last_deleted_document_count: { type: 'long' }, - last_incremental_sync_scheduled_at: { type: 'date' }, - last_indexed_document_count: { type: 'long' }, - last_seen: { type: 'date' }, - last_sync_error: { type: 'keyword' }, - last_sync_scheduled_at: { type: 'date' }, - last_sync_status: { type: 'keyword' }, - last_synced: { type: 'date' }, - name: { type: 'keyword' }, - pipeline: { - properties: { - extract_binary_content: { type: 'boolean' }, - name: { type: 'keyword' }, - reduce_whitespace: { type: 'boolean' }, - run_ml_inference: { type: 'boolean' }, - }, - }, - scheduling: { - properties: { - access_control: { - properties: { - enabled: { type: 'boolean' }, - interval: { type: 'text' }, - }, - }, - incremental: { - properties: { - enabled: { type: 'boolean' }, - interval: { type: 'text' }, - }, - }, - full: { - properties: { - enabled: { type: 'boolean' }, - interval: { type: 'text' }, - }, - }, - }, - }, - service_type: { type: 'keyword' }, - status: { type: 'keyword' }, - sync_cursor: { type: 'object' }, - sync_now: { type: 'boolean' }, -}; - -const defaultSettings: IndicesIndexSettings = { - auto_expand_replicas: '0-3', - hidden: true, - number_of_replicas: 0, -}; - -export interface DefaultConnectorsPipelineMeta { - default_extract_binary_content: boolean; - default_name: string; - default_reduce_whitespace: boolean; - default_run_ml_inference: boolean; -} - -export const defaultConnectorsPipelineMeta: DefaultConnectorsPipelineMeta = { - default_extract_binary_content: true, - default_name: 'ent-search-generic-ingestion', - default_reduce_whitespace: true, - default_run_ml_inference: true, -}; - -const indices: IndexDefinition[] = [ - { - aliases: ['.elastic-connectors'], - mappings: { - _meta: { - pipeline: defaultConnectorsPipelineMeta, - version: 1, - }, - dynamic: false, - properties: connectorMappingsProperties, - }, - name: '.elastic-connectors-v1', - settings: defaultSettings, - }, - { - aliases: ['.elastic-connectors-sync-jobs'], - mappings: { - _meta: { - version: 1, - }, - dynamic: false, - properties: { - cancelation_requested_at: { type: 'date' }, - canceled_at: { type: 'date' }, - completed_at: { type: 'date' }, - connector: { - properties: { - configuration: { type: 'object' }, - filtering: { - properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - domain: { type: 'keyword' }, - rules: { - properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, - }, - }, - warnings: { - properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, - }, - }, - }, - }, - id: { type: 'keyword' }, - index_name: { type: 'keyword' }, - language: { type: 'keyword' }, - pipeline: { - properties: { - extract_binary_content: { type: 'boolean' }, - name: { type: 'keyword' }, - reduce_whitespace: { type: 'boolean' }, - run_ml_inference: { type: 'boolean' }, - }, - }, - service_type: { type: 'keyword' }, - sync_cursor: { type: 'object' }, - }, - }, - created_at: { type: 'date' }, - deleted_document_count: { type: 'integer' }, - error: { type: 'keyword' }, - indexed_document_count: { type: 'integer' }, - indexed_document_volume: { type: 'integer' }, - job_type: { type: 'keyword' }, - last_seen: { type: 'date' }, - metadata: { type: 'object' }, - started_at: { type: 'date' }, - status: { type: 'keyword' }, - total_document_count: { type: 'integer' }, - trigger_method: { type: 'keyword' }, - worker_hostname: { type: 'keyword' }, - }, - }, - name: '.elastic-connectors-sync-jobs-v1', - settings: defaultSettings, - }, -]; - -const createConnectorsIndex = async ( - client: ElasticsearchClient, - indexDefinition: IndexDefinition -) => { - try { - const { aliases, mappings, name: index, settings } = indexDefinition; - await client.indices.create({ - index, - mappings, - settings, - }); - await client.indices.updateAliases({ - actions: [ - { - add: { - aliases, - index, - is_hidden: true, - is_write_index: true, - }, - }, - ], - }); - } catch (error) { - if (isResourceAlreadyExistsException(error)) { - // We hit a race condition, do nothing - return; - } - return error; - } -}; - -export const setupConnectorsIndices = async (client: ElasticsearchClient) => { - const connectorsIndexResponse = await client.indices.get({ - index: `${CONNECTORS_INDEX}*`, - }); - for (const indexDefinition of indices) { - if (!connectorsIndexResponse[indexDefinition.name]) { - await createConnectorsIndex(client, indexDefinition); - } - // TODO handle migrations once we start migrating stuff - } -}; diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts index fb373ed440dced..5f9168bffe5e7e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts @@ -7,12 +7,10 @@ import { IScopedClusterClient } from '@kbn/core/server'; -import { CONNECTORS_INDEX } from '../..'; +import { CURRENT_CONNECTORS_INDEX } from '../..'; import { ConnectorStatus } from '../../../common/types/connectors'; import { ErrorCode } from '../../../common/types/error_codes'; -import { setupConnectorsIndices } from '../../index_management/setup_indices'; - import { fetchCrawlerByIndexName } from '../crawler/fetch_crawlers'; import { textAnalysisSettings } from '../indices/text_analysis'; @@ -20,10 +18,6 @@ import { addConnector } from './add_connector'; import { deleteConnectorById } from './delete_connector'; import { fetchConnectorByIndexName } from './fetch_connectors'; -jest.mock('../../index_management/setup_indices', () => ({ - setupConnectorsIndices: jest.fn(), -})); - jest.mock('./fetch_connectors', () => ({ fetchConnectorByIndexName: jest.fn() })); jest.mock('./delete_connector', () => ({ deleteConnectorById: jest.fn() })); jest.mock('../crawler/fetch_crawlers', () => ({ fetchCrawlerByIndexName: jest.fn() })); @@ -41,11 +35,6 @@ describe('addConnector lib function', () => { asInternalUser: {}, }; - const createConnectorsIndexExistsFn = - (connectorsIndexExists: boolean, defaultValue: boolean) => - ({ index }: { index: string }) => - index === CONNECTORS_INDEX ? connectorsIndexExists : defaultValue; - beforeEach(() => { jest.clearAllMocks(); }); @@ -68,9 +57,7 @@ describe('addConnector lib function', () => { it('should add connector', async () => { mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(true, false) - ); + mockClient.asCurrentUser.indices.exists.mockImplementation(() => false); (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => undefined); (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => undefined); mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); @@ -169,7 +156,7 @@ describe('addConnector lib function', () => { status: ConnectorStatus.CREATED, sync_now: false, }, - index: CONNECTORS_INDEX, + index: CURRENT_CONNECTORS_INDEX, refresh: 'wait_for', }); expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ @@ -181,9 +168,7 @@ describe('addConnector lib function', () => { it('should reject if index already exists', async () => { mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(true, true) - ); + mockClient.asCurrentUser.indices.exists.mockImplementation(() => true); (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => undefined); (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => undefined); mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); @@ -200,9 +185,7 @@ describe('addConnector lib function', () => { it('should reject if connector already exists', async () => { mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(true, false) - ); + mockClient.asCurrentUser.indices.exists.mockImplementation(() => false); (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => true); (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => undefined); mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); @@ -219,9 +202,7 @@ describe('addConnector lib function', () => { it('should reject if crawler already exists', async () => { mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(true, false) - ); + mockClient.asCurrentUser.indices.exists.mockImplementation(() => false); (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => undefined); (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => true); mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); @@ -238,9 +219,7 @@ describe('addConnector lib function', () => { it('should reject with index already exists if connector and index already exist', async () => { mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(true, true) - ); + mockClient.asCurrentUser.indices.exists.mockImplementation(() => true); (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => true); (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => undefined); mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); @@ -257,9 +236,7 @@ describe('addConnector lib function', () => { it('should replace connector if deleteExistingConnector flag is true', async () => { mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(true, false) - ); + mockClient.asCurrentUser.indices.exists.mockImplementation(() => false); (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => ({ id: 'connectorId' })); (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => undefined); mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); @@ -360,7 +337,7 @@ describe('addConnector lib function', () => { status: ConnectorStatus.CREATED, sync_now: false, }, - index: CONNECTORS_INDEX, + index: CURRENT_CONNECTORS_INDEX, refresh: 'wait_for', }); expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ @@ -373,116 +350,4 @@ describe('addConnector lib function', () => { }, }); }); - - it('should create index if no connectors index exists', async () => { - mockClient.asCurrentUser.indices.exists.mockImplementation( - createConnectorsIndexExistsFn(false, false) - ); - (fetchConnectorByIndexName as jest.Mock).mockImplementation(() => false); - (fetchCrawlerByIndexName as jest.Mock).mockImplementation(() => undefined); - mockClient.asCurrentUser.indices.getMapping.mockImplementation(() => connectorsIndicesMapping); - await expect( - addConnector(mockClient as unknown as IScopedClusterClient, { - index_name: 'search-index_name', - is_native: false, - language: 'en', - }) - ).resolves.toEqual({ id: 'fakeId', index_name: 'search-index_name' }); - expect(setupConnectorsIndices as jest.Mock).toHaveBeenCalledWith(mockClient.asCurrentUser); - expect(mockClient.asCurrentUser.index).toHaveBeenCalledWith({ - document: { - api_key_id: null, - configuration: {}, - custom_scheduling: {}, - description: null, - error: null, - features: null, - filtering: [ - { - active: { - advanced_snippet: { - created_at: expect.any(String), - updated_at: expect.any(String), - value: {}, - }, - rules: [ - { - created_at: expect.any(String), - field: '_', - id: 'DEFAULT', - order: 0, - policy: 'include', - rule: 'regex', - updated_at: expect.any(String), - value: '.*', - }, - ], - validation: { - errors: [], - state: 'valid', - }, - }, - domain: 'DEFAULT', - draft: { - advanced_snippet: { - created_at: expect.any(String), - updated_at: expect.any(String), - value: {}, - }, - rules: [ - { - created_at: expect.any(String), - field: '_', - id: 'DEFAULT', - order: 0, - policy: 'include', - rule: 'regex', - updated_at: expect.any(String), - value: '.*', - }, - ], - validation: { - errors: [], - state: 'valid', - }, - }, - }, - ], - index_name: 'search-index_name', - is_native: false, - language: 'en', - last_access_control_sync_error: null, - last_access_control_sync_scheduled_at: null, - last_access_control_sync_status: null, - last_incremental_sync_scheduled_at: null, - last_seen: null, - last_sync_error: null, - last_sync_scheduled_at: null, - last_sync_status: null, - last_synced: null, - name: 'index_name', - pipeline: { - extract_binary_content: true, - name: 'ent-search-generic-ingestion', - reduce_whitespace: true, - run_ml_inference: false, - }, - scheduling: { - access_control: { enabled: false, interval: '0 0 0 * * ?' }, - full: { enabled: false, interval: '0 0 0 * * ?' }, - incremental: { enabled: false, interval: '0 0 0 * * ?' }, - }, - service_type: null, - status: ConnectorStatus.CREATED, - sync_now: false, - }, - index: CONNECTORS_INDEX, - refresh: 'wait_for', - }); - expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ - index: 'search-index_name', - mappings: {}, - settings: { ...textAnalysisSettings('en'), auto_expand_replicas: '0-3', number_of_shards: 2 }, - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts index 455d3213c1d67f..c3a7ebe26dc3f7 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts @@ -7,17 +7,14 @@ import { IScopedClusterClient } from '@kbn/core/server'; -import { CONNECTORS_INDEX, CONNECTORS_VERSION } from '../..'; +import { CURRENT_CONNECTORS_INDEX } from '../..'; import { ConnectorDocument } from '../../../common/types/connectors'; import { ErrorCode } from '../../../common/types/error_codes'; -import { - DefaultConnectorsPipelineMeta, - setupConnectorsIndices, -} from '../../index_management/setup_indices'; import { createConnectorDocument } from '../../utils/create_connector_document'; import { fetchCrawlerByIndexName } from '../crawler/fetch_crawlers'; import { createIndex } from '../indices/create_index'; +import { getDefaultPipeline } from '../pipelines/get_default_pipeline'; import { deleteConnectorById } from './delete_connector'; @@ -53,7 +50,7 @@ const createConnector = async ( const result = await client.asCurrentUser.index({ document, - index: CONNECTORS_INDEX, + index: CURRENT_CONNECTORS_INDEX, refresh: 'wait_for', }); await createIndex(client, document.index_name, language, false); @@ -71,31 +68,13 @@ export const addConnector = async ( service_type?: string; } ): Promise<{ id: string; index_name: string }> => { - const connectorsIndexExists = await client.asCurrentUser.indices.exists({ - index: CONNECTORS_INDEX, - }); - if (!connectorsIndexExists) { - await setupConnectorsIndices(client.asCurrentUser); - } - const connectorsIndicesMapping = await client.asCurrentUser.indices.getMapping({ - index: CONNECTORS_INDEX, - }); - const pipeline: DefaultConnectorsPipelineMeta = - connectorsIndicesMapping[`${CONNECTORS_INDEX}-v${CONNECTORS_VERSION}`]?.mappings?._meta - ?.pipeline; + const pipeline = await getDefaultPipeline(client); const document = createConnectorDocument({ indexName: input.index_name, isNative: input.is_native, language: input.language, - pipeline: pipeline - ? { - extract_binary_content: pipeline.default_extract_binary_content, - name: pipeline.default_name, - reduce_whitespace: pipeline.default_reduce_whitespace, - run_ml_inference: pipeline.default_run_ml_inference, - } - : null, + pipeline, serviceType: input.service_type ?? null, }); diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connector_index_names.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connector_index_names.ts index b7289978151a96..695a23139fbc6f 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connector_index_names.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connector_index_names.ts @@ -5,16 +5,24 @@ * 2.0. */ +import { isIndexNotFoundException } from '@kbn/core-saved-objects-migration-server-internal'; import { IScopedClusterClient } from '@kbn/core/server'; import { CONNECTORS_INDEX } from '../..'; export async function fetchConnectorIndexNames(client: IScopedClusterClient): Promise { - const result = await client.asCurrentUser.search({ - _source: false, - fields: [{ field: 'index_name' }], - index: CONNECTORS_INDEX, - size: 10000, - }); - return (result?.hits.hits ?? []).map((field) => field.fields?.index_name[0] ?? ''); + try { + const result = await client.asCurrentUser.search({ + _source: false, + fields: [{ field: 'index_name' }], + index: CONNECTORS_INDEX, + size: 10000, + }); + return (result?.hits.hits ?? []).map((field) => field.fields?.index_name[0] ?? ''); + } catch (error) { + if (isIndexNotFoundException(error)) { + return []; + } + throw error; + } } diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts index 397369b5054ab2..4196aad469d65b 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts @@ -6,13 +6,28 @@ */ import { CONNECTORS_INDEX } from '../..'; -import { setupConnectorsIndices } from '../../index_management/setup_indices'; import { fetchConnectorById, fetchConnectorByIndexName, fetchConnectors } from './fetch_connectors'; -jest.mock('../../index_management/setup_indices', () => ({ - setupConnectorsIndices: jest.fn(), -})); +const indexNotFoundError = { + meta: { + body: { + error: { + type: 'index_not_found_exception', + }, + }, + }, +}; + +const otherError = { + meta: { + body: { + error: { + type: 'other_error', + }, + }, + }, +}; describe('fetchConnectors lib', () => { const mockClient = { @@ -46,43 +61,21 @@ describe('fetchConnectors lib', () => { index: CONNECTORS_INDEX, }); }); - it('should call setup connectors on index not found error', async () => { - mockClient.asCurrentUser.get.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { - type: 'index_not_found_exception', - }, - }, - }, - }) - ); + it('should return undefined on index not found error', async () => { + mockClient.asCurrentUser.get.mockImplementationOnce(() => Promise.reject(indexNotFoundError)); await expect(fetchConnectorById(mockClient as any, 'id')).resolves.toEqual(undefined); expect(mockClient.asCurrentUser.get).toHaveBeenCalledWith({ id: 'id', index: CONNECTORS_INDEX, }); - expect(setupConnectorsIndices as jest.Mock).toHaveBeenCalledWith(mockClient.asCurrentUser); }); - it('should not call setup connectors on other errors', async () => { - mockClient.asCurrentUser.get.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { - type: 'other error', - }, - }, - }, - }) - ); - await expect(fetchConnectorById(mockClient as any, 'id')).resolves.toEqual(undefined); + it('should throw on other errors', async () => { + mockClient.asCurrentUser.get.mockImplementationOnce(() => Promise.reject(otherError)); + await expect(fetchConnectorById(mockClient as any, 'id')).rejects.toEqual(otherError); expect(mockClient.asCurrentUser.get).toHaveBeenCalledWith({ id: 'id', index: CONNECTORS_INDEX, }); - expect(setupConnectorsIndices as jest.Mock).not.toHaveBeenCalled(); }); }); describe('fetch connector by name', () => { @@ -106,15 +99,9 @@ describe('fetchConnectors lib', () => { }, }); }); - it('should call setup connectors on index not found error', async () => { + it('should return undefined on index not found error', async () => { mockClient.asCurrentUser.search.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { type: 'index_not_found_exception' }, - }, - }, - }) + Promise.reject(indexNotFoundError) ); await expect(fetchConnectorByIndexName(mockClient as any, 'id')).resolves.toEqual(undefined); expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ @@ -125,21 +112,10 @@ describe('fetchConnectors lib', () => { }, }, }); - expect(setupConnectorsIndices as jest.Mock).toHaveBeenCalledWith(mockClient.asCurrentUser); }); - it('should not call setup connectors on other errors', async () => { - mockClient.asCurrentUser.search.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { - type: 'other error', - }, - }, - }, - }) - ); - await expect(fetchConnectorByIndexName(mockClient as any, 'id')).resolves.toEqual(undefined); + it('should throw on other errors', async () => { + mockClient.asCurrentUser.search.mockImplementationOnce(() => Promise.reject(otherError)); + await expect(fetchConnectorByIndexName(mockClient as any, 'id')).rejects.toEqual(otherError); expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ index: CONNECTORS_INDEX, query: { @@ -148,7 +124,6 @@ describe('fetchConnectors lib', () => { }, }, }); - expect(setupConnectorsIndices as jest.Mock).not.toHaveBeenCalled(); }); }); describe('fetch connectors', () => { @@ -197,15 +172,9 @@ describe('fetchConnectors lib', () => { }); expect(mockClient.asCurrentUser.search).toHaveBeenCalledTimes(3); }); - it('should call setup connectors on index not found error', async () => { + it('should return empty array on index not found error', async () => { mockClient.asCurrentUser.search.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { type: 'index_not_found_exception' }, - }, - }, - }) + Promise.reject(indexNotFoundError) ); await expect(fetchConnectors(mockClient as any)).resolves.toEqual([]); expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ @@ -214,28 +183,16 @@ describe('fetchConnectors lib', () => { query: { match_all: {} }, size: 1000, }); - expect(setupConnectorsIndices as jest.Mock).toHaveBeenCalledWith(mockClient.asCurrentUser); }); - it('should not call setup connectors on other errors', async () => { - mockClient.asCurrentUser.search.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { - type: 'other error', - }, - }, - }, - }) - ); - await expect(fetchConnectors(mockClient as any)).resolves.toEqual([]); + it('should throw on other errors', async () => { + mockClient.asCurrentUser.search.mockImplementationOnce(() => Promise.reject(otherError)); + await expect(fetchConnectors(mockClient as any)).rejects.toEqual(otherError); expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ from: 0, index: CONNECTORS_INDEX, query: { match_all: {} }, size: 1000, }); - expect(setupConnectorsIndices as jest.Mock).not.toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts index 69c0e6e25b1a81..c1d8b834d0e150 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts @@ -11,8 +11,6 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { CONNECTORS_INDEX } from '../..'; import { Connector, ConnectorDocument } from '../../../common/types/connectors'; import { OptimisticConcurrency } from '../../../common/types/util_types'; -import { setupConnectorsIndices } from '../../index_management/setup_indices'; - import { isIndexNotFoundException } from '../../utils/identify_exceptions'; import { fetchAll } from '../fetch_all'; @@ -34,9 +32,9 @@ export const fetchConnectorById = async ( : undefined; } catch (error) { if (isIndexNotFoundException(error)) { - await setupConnectorsIndices(client.asCurrentUser); + return undefined; } - return undefined; + throw error; } }; @@ -57,9 +55,9 @@ export const fetchConnectorByIndexName = async ( return result; } catch (error) { if (isIndexNotFoundException(error)) { - await setupConnectorsIndices(client.asCurrentUser); + return undefined; } - return undefined; + throw error; } }; @@ -75,8 +73,8 @@ export const fetchConnectors = async ( return await fetchAll(client, CONNECTORS_INDEX, query); } catch (error) { if (isIndexNotFoundException(error)) { - await setupConnectorsIndices(client.asCurrentUser); + return []; } - return []; + throw error; } }; diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.test.ts index 5408ac4d4a9e78..651849e803c41b 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.test.ts @@ -5,14 +5,8 @@ * 2.0. */ -import { setupConnectorsIndices } from '../../index_management/setup_indices'; - import { fetchSyncJobsByConnectorId } from './fetch_sync_jobs'; -jest.mock('../../index_management/setup_indices', () => ({ - setupConnectorsIndices: jest.fn(), -})); - describe('fetchSyncJobs lib', () => { const mockClient = { asCurrentUser: { @@ -71,7 +65,7 @@ describe('fetchSyncJobs lib', () => { }); expect(mockClient.asCurrentUser.search).not.toHaveBeenCalled(); }); - it('should call setup connectors on index not found error', async () => { + it('should return empty array on index not found error', async () => { mockClient.asCurrentUser.search.mockImplementationOnce(() => Promise.reject({ meta: { @@ -109,9 +103,8 @@ describe('fetchSyncJobs lib', () => { }, }, }); - expect(setupConnectorsIndices as jest.Mock).toHaveBeenCalledWith(mockClient.asCurrentUser); }); - it('should not call setup connectors on other errors', async () => { + it('should throw on other errors', async () => { mockClient.asCurrentUser.search.mockImplementationOnce(() => Promise.reject({ meta: { @@ -147,7 +140,6 @@ describe('fetchSyncJobs lib', () => { }, }, }); - expect(setupConnectorsIndices as jest.Mock).not.toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.ts index 23d9bcc842d188..43f373712f7531 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_sync_jobs.ts @@ -12,7 +12,6 @@ import { ConnectorSyncJob, SyncJobType } from '../../../common/types/connectors' import { Paginate } from '../../../common/types/pagination'; import { isNotNullish } from '../../../common/utils/is_not_nullish'; -import { setupConnectorsIndices } from '../../index_management/setup_indices'; import { fetchWithPagination } from '../../utils/fetch_with_pagination'; import { isIndexNotFoundException } from '../../utils/identify_exceptions'; @@ -82,10 +81,8 @@ export const fetchSyncJobsByConnectorId = async ( }; } catch (error) { if (isIndexNotFoundException(error)) { - await setupConnectorsIndices(client.asCurrentUser); return defaultResult; - } else { - throw error; } + throw error; } }; diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts index 4e35acd1434b46..90d82c82aca93e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts @@ -7,7 +7,7 @@ import { IScopedClusterClient } from '@kbn/core/server'; -import { CONNECTORS_INDEX, CONNECTORS_JOBS_INDEX } from '../..'; +import { CONNECTORS_INDEX, CURRENT_CONNECTORS_JOB_INDEX } from '../..'; import { CONNECTORS_ACCESS_CONTROL_INDEX_PREFIX } from '../../../common/constants'; import { SyncJobType, SyncStatus, TriggerMethod } from '../../../common/types/connectors'; @@ -95,7 +95,7 @@ describe('startSync lib function', () => { trigger_method: TriggerMethod.ON_DEMAND, worker_hostname: null, }, - index: CONNECTORS_JOBS_INDEX, + index: CURRENT_CONNECTORS_JOB_INDEX, }); }); it('should start a full sync with service type, pipeline and nextSyncConfig', async () => { @@ -164,7 +164,7 @@ describe('startSync lib function', () => { trigger_method: TriggerMethod.ON_DEMAND, worker_hostname: null, }, - index: CONNECTORS_JOBS_INDEX, + index: CURRENT_CONNECTORS_JOB_INDEX, }); }); @@ -299,7 +299,7 @@ describe('startSync lib function', () => { trigger_method: TriggerMethod.ON_DEMAND, worker_hostname: null, }, - index: CONNECTORS_JOBS_INDEX, + index: CURRENT_CONNECTORS_JOB_INDEX, }); }); @@ -366,7 +366,7 @@ describe('startSync lib function', () => { trigger_method: TriggerMethod.ON_DEMAND, worker_hostname: null, }, - index: CONNECTORS_JOBS_INDEX, + index: CURRENT_CONNECTORS_JOB_INDEX, }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.ts index a3b2164c467a1f..4c9ce9ffc162fb 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.ts @@ -7,7 +7,7 @@ import { IScopedClusterClient } from '@kbn/core/server'; -import { CONNECTORS_INDEX, CONNECTORS_JOBS_INDEX } from '../..'; +import { CONNECTORS_INDEX, CURRENT_CONNECTORS_JOB_INDEX } from '../..'; import { isConfigEntry } from '../../../common/connectors/is_category_entry'; import { @@ -99,7 +99,7 @@ export const startConnectorSync = async ( trigger_method: TriggerMethod.ON_DEMAND, worker_hostname: null, }, - index: CONNECTORS_JOBS_INDEX, + index: CURRENT_CONNECTORS_JOB_INDEX, }); } else { throw new Error(ErrorCode.RESOURCE_NOT_FOUND); diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_index.ts b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_index.ts index 95422eb7b96e8c..4233f8abbeff1e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_index.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_index.ts @@ -12,6 +12,7 @@ import { CONNECTORS_JOBS_INDEX } from '../..'; import { ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE } from '../../../common/constants'; import { ConnectorSyncJobDocument, SyncStatus } from '../../../common/types/connectors'; import { ElasticsearchIndexWithIngestion } from '../../../common/types/indices'; +import { isIndexNotFoundException } from '../../utils/identify_exceptions'; import { fetchConnectorByIndexName } from '../connectors/fetch_connectors'; import { fetchCrawlerByIndexName } from '../crawler/fetch_crawlers'; @@ -21,29 +22,36 @@ const hasInProgressSyncs = async ( client: IScopedClusterClient, connectorId: string ): Promise<{ inProgress: boolean; pending: boolean }> => { - const syncs = await client.asCurrentUser.search({ - index: CONNECTORS_JOBS_INDEX, - query: { - bool: { - filter: [ - { term: { 'connector.id': connectorId } }, - { - dis_max: { - queries: [ - { term: { status: SyncStatus.IN_PROGRESS } }, - { term: { status: SyncStatus.PENDING } }, - ], + try { + const syncs = await client.asCurrentUser.search({ + index: CONNECTORS_JOBS_INDEX, + query: { + bool: { + filter: [ + { term: { 'connector.id': connectorId } }, + { + dis_max: { + queries: [ + { term: { status: SyncStatus.IN_PROGRESS } }, + { term: { status: SyncStatus.PENDING } }, + ], + }, }, - }, - ], + ], + }, }, - }, - }); - const inProgress = syncs.hits.hits.some( - (sync) => sync._source?.status === SyncStatus.IN_PROGRESS - ); - const pending = syncs.hits.hits.some((sync) => sync._source?.status === SyncStatus.PENDING); - return { inProgress, pending }; + }); + const inProgress = syncs.hits.hits.some( + (sync) => sync._source?.status === SyncStatus.IN_PROGRESS + ); + const pending = syncs.hits.hits.some((sync) => sync._source?.status === SyncStatus.PENDING); + return { inProgress, pending }; + } catch (error) { + if (isIndexNotFoundException(error)) { + return { inProgress: false, pending: false }; + } + throw error; + } }; export const fetchIndex = async ( diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_default_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_default_pipeline.ts index 0959b7e8cfa7f6..c13ee2571b82b4 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_default_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_default_pipeline.ts @@ -10,24 +10,32 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { CURRENT_CONNECTORS_INDEX } from '../..'; import { DEFAULT_PIPELINE_VALUES } from '../../../common/constants'; +import { DefaultConnectorsPipelineMeta } from '../../../common/constants'; import { IngestPipelineParams } from '../../../common/types/connectors'; -import { DefaultConnectorsPipelineMeta } from '../../index_management/setup_indices'; +import { isIndexNotFoundException } from '../../utils/identify_exceptions'; export const getDefaultPipeline = async ( client: IScopedClusterClient ): Promise => { - const mapping = await client.asCurrentUser.indices.getMapping({ - index: CURRENT_CONNECTORS_INDEX, - }); - const meta: DefaultConnectorsPipelineMeta | undefined = - mapping[CURRENT_CONNECTORS_INDEX]?.mappings._meta?.pipeline; - const mappedMapping: IngestPipelineParams = meta - ? { - extract_binary_content: meta.default_extract_binary_content, - name: meta.default_name, - reduce_whitespace: meta.default_reduce_whitespace, - run_ml_inference: meta.default_run_ml_inference, - } - : DEFAULT_PIPELINE_VALUES; - return mappedMapping; + try { + const mapping = await client.asCurrentUser.indices.getMapping({ + index: CURRENT_CONNECTORS_INDEX, + }); + const meta: DefaultConnectorsPipelineMeta | undefined = + mapping[CURRENT_CONNECTORS_INDEX]?.mappings._meta?.pipeline; + const mappedMapping: IngestPipelineParams = meta + ? { + extract_binary_content: meta.default_extract_binary_content, + name: meta.default_name, + reduce_whitespace: meta.default_reduce_whitespace, + run_ml_inference: meta.default_run_ml_inference, + } + : DEFAULT_PIPELINE_VALUES; + return mappedMapping; + } catch (error) { + if (isIndexNotFoundException(error)) { + return DEFAULT_PIPELINE_VALUES; + } + throw error; + } }; diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/update_default_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/update_default_pipeline.ts index 8dfaf0ac8ba425..060c0edc883166 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/update_default_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/update_default_pipeline.ts @@ -9,12 +9,8 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { CURRENT_CONNECTORS_INDEX } from '../..'; +import { DefaultConnectorsPipelineMeta } from '../../../common/constants'; import { IngestPipelineParams } from '../../../common/types/connectors'; -import { - DefaultConnectorsPipelineMeta, - setupConnectorsIndices, -} from '../../index_management/setup_indices'; -import { isIndexNotFoundException } from '../../utils/identify_exceptions'; export const updateDefaultPipeline = async ( client: IScopedClusterClient, @@ -35,8 +31,7 @@ export const updateDefaultPipeline = async ( index: CURRENT_CONNECTORS_INDEX, }); } catch (error) { - if (isIndexNotFoundException(error)) { - setupConnectorsIndices(client.asCurrentUser); - } + // TODO: Throw error saying you have to index a connector first + throw error; } }; diff --git a/x-pack/plugins/enterprise_search/server/lib/stats/get_sync_jobs.ts b/x-pack/plugins/enterprise_search/server/lib/stats/get_sync_jobs.ts index 4e428c797eca5d..a19315507bf4c6 100644 --- a/x-pack/plugins/enterprise_search/server/lib/stats/get_sync_jobs.ts +++ b/x-pack/plugins/enterprise_search/server/lib/stats/get_sync_jobs.ts @@ -13,125 +13,140 @@ import { CONNECTORS_INDEX, CONNECTORS_JOBS_INDEX } from '../..'; import { SyncJobsStats } from '../../../common/stats'; import { ConnectorStatus, SyncStatus } from '../../../common/types/connectors'; +import { isIndexNotFoundException } from '../../utils/identify_exceptions'; export const fetchSyncJobsStats = async (client: IScopedClusterClient): Promise => { - const connectorIdsResult = await client.asCurrentUser.search({ - index: CONNECTORS_INDEX, - scroll: '10s', - stored_fields: [], - }); - const ids = connectorIdsResult.hits.hits.map((hit) => hit._id); - const orphanedJobsCountResponse = await client.asCurrentUser.count({ - index: CONNECTORS_JOBS_INDEX, - query: { - bool: { - must_not: [ - { - terms: { - 'connector.id': ids, + try { + const connectorIdsResult = await client.asCurrentUser.search({ + index: CONNECTORS_INDEX, + scroll: '10s', + stored_fields: [], + }); + const ids = connectorIdsResult.hits.hits.map((hit) => hit._id); + const orphanedJobsCountResponse = await client.asCurrentUser.count({ + index: CONNECTORS_JOBS_INDEX, + query: { + bool: { + must_not: [ + { + terms: { + 'connector.id': ids, + }, }, - }, - ], + ], + }, }, - }, - }); + }); - const inProgressJobsCountResponse = await client.asCurrentUser.count({ - index: CONNECTORS_JOBS_INDEX, - query: { - term: { - status: SyncStatus.IN_PROGRESS, + const inProgressJobsCountResponse = await client.asCurrentUser.count({ + index: CONNECTORS_JOBS_INDEX, + query: { + term: { + status: SyncStatus.IN_PROGRESS, + }, }, - }, - }); + }); - const idleJobsCountResponse = await client.asCurrentUser.count({ - index: CONNECTORS_JOBS_INDEX, - query: { - bool: { - filter: [ - { - term: { - status: SyncStatus.IN_PROGRESS, + const idleJobsCountResponse = await client.asCurrentUser.count({ + index: CONNECTORS_JOBS_INDEX, + query: { + bool: { + filter: [ + { + term: { + status: SyncStatus.IN_PROGRESS, + }, }, - }, - { - range: { - last_seen: { - lt: moment().subtract(1, 'minute').toISOString(), + { + range: { + last_seen: { + lt: moment().subtract(1, 'minute').toISOString(), + }, }, }, - }, - ], + ], + }, }, - }, - }); + }); - const errorResponse = await client.asCurrentUser.count({ - index: CONNECTORS_INDEX, - query: { - term: { - last_sync_status: SyncStatus.ERROR, + const errorResponse = await client.asCurrentUser.count({ + index: CONNECTORS_INDEX, + query: { + term: { + last_sync_status: SyncStatus.ERROR, + }, }, - }, - }); + }); - const connectedResponse = await client.asCurrentUser.count({ - index: CONNECTORS_INDEX, - query: { - bool: { - filter: [ - { - term: { - status: ConnectorStatus.CONNECTED, + const connectedResponse = await client.asCurrentUser.count({ + index: CONNECTORS_INDEX, + query: { + bool: { + filter: [ + { + term: { + status: ConnectorStatus.CONNECTED, + }, }, - }, - { - range: { - last_seen: { - gte: moment().subtract(30, 'minutes').toISOString(), + { + range: { + last_seen: { + gte: moment().subtract(30, 'minutes').toISOString(), + }, }, }, - }, - ], + ], + }, }, - }, - }); + }); - const incompleteResponse = await client.asCurrentUser.count({ - index: CONNECTORS_INDEX, - query: { - bool: { - should: [ - { - bool: { - must_not: { - terms: { - status: [ConnectorStatus.CONNECTED, ConnectorStatus.ERROR], + const incompleteResponse = await client.asCurrentUser.count({ + index: CONNECTORS_INDEX, + query: { + bool: { + should: [ + { + bool: { + must_not: { + terms: { + status: [ConnectorStatus.CONNECTED, ConnectorStatus.ERROR], + }, }, }, }, - }, - { - range: { - last_seen: { - lt: moment().subtract(30, 'minutes').toISOString(), + { + range: { + last_seen: { + lt: moment().subtract(30, 'minutes').toISOString(), + }, }, }, - }, - ], + ], + }, }, - }, - }); + }); - const response = { - connected: connectedResponse.count, - errors: errorResponse.count, - idle: idleJobsCountResponse.count, - in_progress: inProgressJobsCountResponse.count, - incomplete: incompleteResponse.count, - orphaned_jobs: orphanedJobsCountResponse.count, - }; + const response = { + connected: connectedResponse.count, + errors: errorResponse.count, + idle: idleJobsCountResponse.count, + in_progress: inProgressJobsCountResponse.count, + incomplete: incompleteResponse.count, + orphaned_jobs: orphanedJobsCountResponse.count, + }; - return response; + return response; + } catch (error) { + if (isIndexNotFoundException(error)) { + return { + connected: 0, + errors: 0, + idle: 0, + in_progress: 0, + incomplete: 0, + orphaned_jobs: 0, + }; + } + throw error; + } }; From 5d5c10a320a7cc6b9f67bd59068e25c7cdca14c2 Mon Sep 17 00:00:00 2001 From: Gerard Soldevila Date: Wed, 12 Jul 2023 13:38:37 +0200 Subject: [PATCH 09/20] [Migrations - v2] Allow for 1 byte size variation in es_response_too_large (#161626) ## Summary Fixes https://github.com/elastic/kibana/issues/160994 There must be some randomness factor that causes the response payload size to have a 1 byte size variation, as observed in the `es_response_too_large` error. This PR relaxes the constraint and accepts a `es_response_too_large` error with either 3184 or 3185 bytes. --- .../migrations/group3/actions/actions.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/actions/actions.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/actions/actions.test.ts index bffd4bb616856c..3800bfc1184ba6 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/actions/actions.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/actions/actions.test.ts @@ -1117,8 +1117,7 @@ describe('migration actions', () => { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/160994 - describe.skip('readWithPit', () => { + describe('readWithPit', () => { it('requests documents from an index using given PIT', async () => { const openPitTask = openPit({ client, index: 'existing_index_with_docs' }); const pitResponse = (await openPitTask()) as Either.Right; @@ -1297,7 +1296,12 @@ describe('migration actions', () => { const leftResponse = (await readWithPitTask()) as Either.Left; expect(leftResponse.left.type).toBe('es_response_too_large'); - expect(leftResponse.left.contentLength).toBe(3184); + // ES response contains a field that indicates how long it took ES to get the response, e.g.: "took": 7 + // if ES takes more than 9ms, the payload will be 1 byte bigger. + // see https://github.com/elastic/kibana/issues/160994 + // Thus, the statements below account for response times up to 99ms + expect(leftResponse.left.contentLength).toBeGreaterThanOrEqual(3184); + expect(leftResponse.left.contentLength).toBeLessThanOrEqual(3185); }); it('rejects if PIT does not exist', async () => { From a78c7b02b3b825826f39289e91e545ee6f4a67d9 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 12 Jul 2023 13:53:41 +0200 Subject: [PATCH 10/20] [Synthetics] Fixes dom warnings for p > div (#161744) --- .../monitors_page/overview/overview/metric_item.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx index 6489515b83b502..2c0885c7d6258b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx @@ -139,13 +139,14 @@ export const MetricItem = ({ justifyContent="flexEnd" // empty title to prevent default title from showing title="" + component="span" > - + {i18n.translate('xpack.synthetics.overview.duration.label', { defaultMessage: 'Duration', })} - + Date: Wed, 12 Jul 2023 08:25:28 -0400 Subject: [PATCH 11/20] [Fleet] Test disabled API in serverless (#161688) --- x-pack/plugins/fleet/server/errors/index.ts | 4 +- .../server/routes/fleet_proxies/handler.ts | 4 +- .../test_suites/observability/fleet.ts | 52 +++++++++++++++++++ .../test_suites/observability/index.ts | 1 + .../test_suites/security/fleet.ts | 52 +++++++++++++++++++ .../test_suites/security/index.ts | 1 + 6 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 x-pack/test_serverless/api_integration/test_suites/observability/fleet.ts create mode 100644 x-pack/test_serverless/api_integration/test_suites/security/fleet.ts diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index bfab916d5a1715..0b2c6b0fc5e93a 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -77,8 +77,8 @@ export class OutputInvalidError extends FleetError {} export class OutputLicenceError extends FleetError {} export class DownloadSourceError extends FleetError {} -export class FleetServerHostUnauthorizedError extends FleetError {} -export class FleetProxyUnauthorizedError extends FleetError {} +export class FleetServerHostUnauthorizedError extends FleetUnauthorizedError {} +export class FleetProxyUnauthorizedError extends FleetUnauthorizedError {} export class ArtifactsClientError extends FleetError {} export class ArtifactsClientAccessDeniedError extends FleetError { diff --git a/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts b/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts index a77eba8a9a28e9..e509dd1f8cd5dd 100644 --- a/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts +++ b/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts @@ -22,7 +22,7 @@ import { updateFleetProxy, getFleetProxyRelatedSavedObjects, } from '../../services/fleet_proxies'; -import { defaultFleetErrorHandler } from '../../errors'; +import { defaultFleetErrorHandler, FleetProxyUnauthorizedError } from '../../errors'; import type { GetOneFleetProxyRequestSchema, PostFleetProxyRequestSchema, @@ -68,7 +68,7 @@ async function bumpRelatedPolicies( function checkProxiesAvailable() { if (appContextService.getConfig()?.internal?.disableProxies) { - throw new Error('Proxies are not available'); + throw new FleetProxyUnauthorizedError('Proxies write APIs are disabled'); } } diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/fleet.ts b/x-pack/test_serverless/api_integration/test_suites/observability/fleet.ts new file mode 100644 index 00000000000000..06fbe54ca4410a --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/observability/fleet.ts @@ -0,0 +1,52 @@ +/* + * 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 expect from 'expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const svlCommonApi = getService('svlCommonApi'); + const supertest = getService('supertest'); + + describe('fleet', function () { + it('rejects request to create a new fleet server hosts', async () => { + const { body, status } = await supertest + .post('/api/fleet/fleet_server_hosts') + .set(svlCommonApi.getCommonRequestHeader()) + .send({ + name: 'test', + host_urls: ['https://localhost:8220'], + }); + + // in a non-serverless environment this would succeed with a 200 + expect(body).toEqual({ + statusCode: 403, + error: 'Forbidden', + message: 'Fleet server host write APIs are disabled', + }); + expect(status).toBe(403); + }); + + it('rejects request to create a new proxy', async () => { + const { body, status } = await supertest + .post('/api/fleet/proxies') + .set(svlCommonApi.getCommonRequestHeader()) + .send({ + name: 'test', + url: 'https://localhost:8220', + }); + + // in a non-serverless environment this would succeed with a 200 + expect(body).toEqual({ + statusCode: 403, + error: 'Forbidden', + message: 'Proxies write APIs are disabled', + }); + expect(status).toBe(403); + }); + }); +} diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/index.ts b/x-pack/test_serverless/api_integration/test_suites/observability/index.ts index 1020ebc74d5519..ad140860f19633 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('serverless observability API', function () { + loadTestFile(require.resolve('./fleet')); loadTestFile(require.resolve('./snapshot_telemetry')); }); } diff --git a/x-pack/test_serverless/api_integration/test_suites/security/fleet.ts b/x-pack/test_serverless/api_integration/test_suites/security/fleet.ts new file mode 100644 index 00000000000000..06fbe54ca4410a --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/security/fleet.ts @@ -0,0 +1,52 @@ +/* + * 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 expect from 'expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const svlCommonApi = getService('svlCommonApi'); + const supertest = getService('supertest'); + + describe('fleet', function () { + it('rejects request to create a new fleet server hosts', async () => { + const { body, status } = await supertest + .post('/api/fleet/fleet_server_hosts') + .set(svlCommonApi.getCommonRequestHeader()) + .send({ + name: 'test', + host_urls: ['https://localhost:8220'], + }); + + // in a non-serverless environment this would succeed with a 200 + expect(body).toEqual({ + statusCode: 403, + error: 'Forbidden', + message: 'Fleet server host write APIs are disabled', + }); + expect(status).toBe(403); + }); + + it('rejects request to create a new proxy', async () => { + const { body, status } = await supertest + .post('/api/fleet/proxies') + .set(svlCommonApi.getCommonRequestHeader()) + .send({ + name: 'test', + url: 'https://localhost:8220', + }); + + // in a non-serverless environment this would succeed with a 200 + expect(body).toEqual({ + statusCode: 403, + error: 'Forbidden', + message: 'Proxies write APIs are disabled', + }); + expect(status).toBe(403); + }); + }); +} diff --git a/x-pack/test_serverless/api_integration/test_suites/security/index.ts b/x-pack/test_serverless/api_integration/test_suites/security/index.ts index 9dc97ea8a9b578..a57d4446e4b9b1 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('serverless security API', function () { + loadTestFile(require.resolve('./fleet')); loadTestFile(require.resolve('./snapshot_telemetry')); }); } From 30fa4db8b5c8d8e14e8929e5529ca3fdda302613 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Wed, 12 Jul 2023 14:38:46 +0200 Subject: [PATCH 12/20] [Fleet] add status telemetry to agents_per_version (#161731) ## Summary Closes https://github.com/elastic/ingest-dev/issues/1491 Querying agent statuses per version to add to telemetry. How to test locally: - enroll a few different version of agents - wait up to 1h to see the telemetry task triggered - check debug logs to see that the agents per version telemetry contains the status information ``` [2023-07-12T11:00:45.172+02:00][DEBUG][plugins.fleet] Agents per version telemetry: [{"version":"8.7.1","count":4,"healthy":4,"unhealthy":0,"updating":0,"inactive":0,"unenrolled":0,"offline":0},{"version":"8.2.1","count":3,"healthy":3,"unhealthy":0,"updating":0,"inactive":0,"unenrolled":0,"offline":0},{"version":"8.2.0","count":2,"healthy":0,"unhealthy":0,"updating":0,"inactive":0,"unenrolled":0,"offline":2}] ``` ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../server/collectors/agent_collectors.ts | 59 ++++++++++++++++--- .../fleet/server/collectors/register.ts | 2 +- .../fleet_usage_telemetry.test.ts | 22 ++++++- .../services/telemetry/fleet_usages_schema.ts | 36 +++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts index 8030b55a6a9fd4..585e328f12e16c 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts @@ -10,18 +10,22 @@ import type { SavedObjectsClient, ElasticsearchClient } from '@kbn/core/server'; import { AGENTS_INDEX } from '../../common'; import * as AgentService from '../services/agents'; import { appContextService } from '../services'; +import { getAgentStatusForAgentPolicy } from '../services/agents'; -export interface AgentUsage { - total_enrolled: number; +export interface AgentStatus { healthy: number; unhealthy: number; offline: number; inactive: number; unenrolled: number; - total_all_statuses: number; updating: number; } +export interface AgentUsage extends AgentStatus { + total_enrolled: number; + total_all_statuses: number; +} + export const getAgentUsage = async ( soClient?: SavedObjectsClient, esClient?: ElasticsearchClient @@ -55,10 +59,12 @@ export const getAgentUsage = async ( }; export interface AgentData { - agents_per_version: Array<{ - version: string; - count: number; - }>; + agents_per_version: Array< + { + version: string; + count: number; + } & AgentStatus + >; agent_checkin_status: { error: number; degraded: number; @@ -74,6 +80,7 @@ const DEFAULT_AGENT_DATA = { export const getAgentData = async ( esClient: ElasticsearchClient, + soClient: SavedObjectsClient, abortController: AbortController ): Promise => { try { @@ -115,8 +122,44 @@ export const getAgentData = async ( { signal: abortController.signal } ); const agentsPerVersion = ((response?.aggregations?.versions as any).buckets ?? []).map( - (bucket: any) => ({ version: bucket.key, count: bucket.doc_count }) + (bucket: any) => ({ + version: bucket.key, + count: bucket.doc_count, + healthy: 0, + unhealthy: 0, + updating: 0, + inactive: 0, + unenrolled: 0, + offline: 0, + }) ); + + const getAgentStatusesPerVersion = async (version: string) => { + return await getAgentStatusForAgentPolicy( + esClient, + soClient, + undefined, + `agent.version:${version}` + ); + }; + + for (const agent of agentsPerVersion) { + const { + inactive, + online: healthy, + error: unhealthy, + updating, + offline, + unenrolled, + } = await getAgentStatusesPerVersion(agent.version); + agent.healthy = healthy; + agent.unhealthy = unhealthy; + agent.updating = updating; + agent.inactive = inactive; + agent.unenrolled = unenrolled; + agent.offline = offline; + } + const statuses = transformLastCheckinStatusBuckets(response); const agentsPerPolicy = ((response?.aggregations?.policies as any).buckets ?? []).map( diff --git a/x-pack/plugins/fleet/server/collectors/register.ts b/x-pack/plugins/fleet/server/collectors/register.ts index d59298e835fa62..901a049df8d734 100644 --- a/x-pack/plugins/fleet/server/collectors/register.ts +++ b/x-pack/plugins/fleet/server/collectors/register.ts @@ -60,7 +60,7 @@ export const fetchFleetUsage = async ( agents: await getAgentUsage(soClient, esClient), fleet_server: await getFleetServerUsage(soClient, esClient), packages: await getPackageUsage(soClient), - ...(await getAgentData(esClient, abortController)), + ...(await getAgentData(esClient, soClient, abortController)), fleet_server_config: await getFleetServerConfig(soClient), agent_policies: await getAgentPoliciesUsage(soClient), ...(await getPanicLogsLastHour(esClient)), diff --git a/x-pack/plugins/fleet/server/integration_tests/fleet_usage_telemetry.test.ts b/x-pack/plugins/fleet/server/integration_tests/fleet_usage_telemetry.test.ts index 8d29d362125e39..e95a6fce722a0a 100644 --- a/x-pack/plugins/fleet/server/integration_tests/fleet_usage_telemetry.test.ts +++ b/x-pack/plugins/fleet/server/integration_tests/fleet_usage_telemetry.test.ts @@ -351,8 +351,26 @@ describe('fleet usage telemetry', () => { }, packages: [], agents_per_version: [ - { version: '8.5.1', count: 1 }, - { version: '8.6.0', count: 1 }, + { + version: '8.5.1', + count: 1, + healthy: 0, + inactive: 0, + offline: 1, + unenrolled: 1, + unhealthy: 0, + updating: 0, + }, + { + version: '8.6.0', + count: 1, + healthy: 0, + inactive: 0, + offline: 1, + unenrolled: 0, + unhealthy: 0, + updating: 0, + }, ], agent_checkin_status: { error: 1, degraded: 1 }, agents_per_policy: [2], diff --git a/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts b/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts index d92628f5e29716..faf963798768be 100644 --- a/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts +++ b/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts @@ -22,6 +22,42 @@ export const fleetAgentsSchema: RootSchema = { description: 'Number of agents enrolled that use this version', }, }, + healthy: { + type: 'long', + _meta: { + description: 'The total number of enrolled agents in a healthy state', + }, + }, + unhealthy: { + type: 'long', + _meta: { + description: 'The total number of enrolled agents in an unhealthy state', + }, + }, + updating: { + type: 'long', + _meta: { + description: 'The total number of enrolled agents in an updating state', + }, + }, + offline: { + type: 'long', + _meta: { + description: 'The total number of enrolled agents currently offline', + }, + }, + inactive: { + type: 'long', + _meta: { + description: 'The total number of enrolled agents currently inactive', + }, + }, + unenrolled: { + type: 'long', + _meta: { + description: 'The total number of unenrolled agents', + }, + }, }, }, }; From 3827dde90a552e15f22098a0e72122d3ad94d135 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 12 Jul 2023 14:40:53 +0200 Subject: [PATCH 13/20] Security/tests/flaky group 4 (#161571) ## Summary Unskipping after running through flaky test runner 100 times: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2599 Main ticket: https://github.com/elastic/kibana/issues/161531 Resolving #159499 Resolving #158905 Resolving #156088 Resolving #160297 --- .../group4/telemetry/usage_collector/detection_rules.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/usage_collector/detection_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/usage_collector/detection_rules.ts index e0e0478c6ad0f7..2f8f24a1ad2de8 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/usage_collector/detection_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group4/telemetry/usage_collector/detection_rules.ts @@ -67,8 +67,7 @@ export default ({ getService }: FtrProviderContext) => { await deleteAllEventLogExecutionEvents(es, log); }); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/160297 - describe.skip('"kql" rule type', () => { + describe('"kql" rule type', () => { it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { const rule = getRuleForSignalTesting(['telemetry'], 'rule-1', false); await createRule(supertest, log, rule); @@ -290,8 +289,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/156088 - describe.skip('"eql" rule type', () => { + describe('"eql" rule type', () => { it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { const rule = getEqlRuleForSignalTesting(['telemetry'], 'rule-1', false); await createRule(supertest, log, rule); From 19f00ec3d7a613f428d17059d838c801b3a80f6d Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 12 Jul 2023 14:59:09 +0200 Subject: [PATCH 14/20] Display user roles on user profile page (#161375) Closes https://github.com/elastic/kibana/issues/159566 ## Summary Adds a new section in the profile bar that highlights the assigned roles of the current user. The roles are rendered using the EUI Badges and if there are more roles than one, then the section only renders the first one while the rest are rendered in a Popover. ## Old UI image ## New UI #### For a user with a single role: image #### For a user with more than one role Show helper text and a button that opens a popover on click image #### Popover for users with long role names Setting the badge group max width to 200px (arbitrary for now) allows truncation of the role badges image --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../user_profile/user_profile.test.tsx | 62 ++++++++++ .../user_profile/user_profile.tsx | 116 ++++++++++++++++-- 2 files changed, 168 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 7a7839ef6e6db7..b5748a16d1e432 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -395,4 +395,66 @@ describe('useUserProfileForm', () => { }); }); }); + + describe('User roles section', () => { + it('should display the user roles', () => { + const data: UserProfileData = {}; + + const nonCloudUser = mockAuthenticatedUser({ elastic_cloud_user: false }); + coreStart.settings.client.get.mockReturnValue(false); + coreStart.settings.client.isOverridden.mockReturnValue(true); + + const testWrapper = mount( + + + + ); + expect(testWrapper.exists('span[data-test-subj="userRoles"]')).toBeTruthy(); + + expect(testWrapper.exists('EuiButtonEmpty[data-test-subj="userRolesExpand"]')).toBeFalsy(); + expect(testWrapper.exists('EuiBadgeGroup[data-test-subj="remainingRoles"]')).toBeFalsy(); + }); + + it('should display a popover for users with more than one role', () => { + const data: UserProfileData = {}; + + const nonCloudUser = mockAuthenticatedUser({ elastic_cloud_user: false }); + coreStart.settings.client.get.mockReturnValue(false); + coreStart.settings.client.isOverridden.mockReturnValue(true); + + nonCloudUser.roles = [...nonCloudUser.roles, 'user-role-1', 'user-role-2']; + const testWrapper = mount( + + + + ); + + const extraRoles = nonCloudUser.roles.splice(1); + + const userRolesExpandButton = testWrapper.find( + 'EuiButtonEmpty[data-test-subj="userRolesExpand"]' + ); + + expect(userRolesExpandButton).toBeTruthy(); + expect(userRolesExpandButton.text()).toEqual(`+${extraRoles.length} more`); + }); + }); }); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 0ffa627ac1a3fe..fd7de9a7047f39 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -6,6 +6,8 @@ */ import { + EuiBadge, + EuiBadgeGroup, EuiButton, EuiButtonEmpty, EuiButtonGroup, @@ -21,6 +23,7 @@ import { EuiKeyPadMenu, EuiKeyPadMenuItem, EuiPageTemplate_Deprecated as EuiPageTemplate, + EuiPopover, EuiSpacer, EuiText, useEuiTheme, @@ -67,6 +70,20 @@ export interface UserProfileProps { }; } +export interface UserDetailsEditorProps { + user: AuthenticatedUser; +} + +export interface UserSettingsEditorProps { + formik: ReturnType; + isThemeOverridden: boolean; + isOverriddenThemeDarkMode: boolean; +} + +export interface UserRoleProps { + user: AuthenticatedUser; +} + export interface UserProfileFormValues { user: { full_name: string; @@ -85,7 +102,7 @@ export interface UserProfileFormValues { avatarType: 'initials' | 'image'; } -function UserDetailsEditor({ user }: { user: AuthenticatedUser }) { +const UserDetailsEditor: FunctionComponent = ({ user }) => { const { services } = useKibana(); const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); @@ -142,17 +159,13 @@ function UserDetailsEditor({ user }: { user: AuthenticatedUser }) { ); -} +}; -function UserSettingsEditor({ +const UserSettingsEditor: FunctionComponent = ({ formik, isThemeOverridden, isOverriddenThemeDarkMode, -}: { - formik: ReturnType; - isThemeOverridden: boolean; - isOverriddenThemeDarkMode: boolean; -}) { +}) => { if (!formik.values.data) { return null; } @@ -262,7 +275,7 @@ function UserSettingsEditor({ ); -} +}; function UserAvatarEditor({ user, @@ -557,6 +570,68 @@ function UserPasswordEditor({ ); } +const UserRoles: FunctionComponent = ({ user }) => { + const { euiTheme } = useEuiTheme(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + + const [firstRole] = user.roles; + const remainingRoles = user.roles.slice(1); + + const renderMoreRoles = () => { + const button = ( + + + + ); + return ( + + + {remainingRoles.map((role) => ( + + {role} + + ))} + + + ); + }; + + return ( + <> +

+ + {firstRole} + +
+ {remainingRoles.length ? renderMoreRoles() : null} + + ); +}; + export const UserProfile: FunctionComponent = ({ user, data }) => { const { euiTheme } = useEuiTheme(); const { services } = useKibana(); @@ -581,7 +656,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Username" /> ), - description: user.username as string | undefined, + description: user.username as string | undefined | JSX.Element, helpText: ( = ({ user, data }) }); } + rightSideItems.push({ + title: ( + + ), + description: , + helpText: ( + + ), + testSubj: 'userRoles', + }); + return ( <> From c031900036b320dff64aebe14c0bf0ec9001ae84 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 12 Jul 2023 09:21:43 -0400 Subject: [PATCH 15/20] [Fleet] Fix dupplicate unhealthy callout (#161755) --- .../fleet/sections/agents/agent_list_page/index.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index bfb728c792a930..123fdfad5543e6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -529,17 +529,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} - {/* TODO serverless agent soft limit */} - {showUnhealthyCallout && ( - <> - {cloud?.deploymentUrl ? ( - - ) : ( - - )} - - - )} {/* Search and filter bar */} Date: Wed, 12 Jul 2023 15:44:01 +0200 Subject: [PATCH 16/20] [Synthetics] Fix type warning (#161745) --- .../synthetics_overview_status.ts | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts index f89688b36fee4d..d5f6fa883e4b5c 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts @@ -10,20 +10,24 @@ import { ObserverCodec } from '../ping/observer'; import { ErrorStateCodec } from '../ping/error_state'; import { AgentType, MonitorType, PingErrorType, UrlType } from '..'; -export const OverviewPingCode = t.interface({ - '@timestamp': t.string, - summary: t.partial({ - down: t.number, - up: t.number, +export const OverviewPingCodec = t.intersection([ + t.interface({ + '@timestamp': t.string, + summary: t.partial({ + down: t.number, + up: t.number, + }), + monitor: MonitorType, + observer: ObserverCodec, + config_id: t.string, + agent: AgentType, + url: UrlType, + state: ErrorStateCodec, }), - monitor: MonitorType, - observer: ObserverCodec, - config_id: t.string, - error: PingErrorType, - agent: AgentType, - url: UrlType, - state: ErrorStateCodec, -}); + t.partial({ + error: PingErrorType, + }), +]); export const OverviewStatusMetaDataCodec = t.interface({ monitorQueryId: t.string, @@ -31,7 +35,7 @@ export const OverviewStatusMetaDataCodec = t.interface({ status: t.string, location: t.string, timestamp: t.string, - ping: OverviewPingCode, + ping: OverviewPingCodec, }); export const OverviewPendingStatusMetaDataCodec = t.intersection([ @@ -43,7 +47,7 @@ export const OverviewPendingStatusMetaDataCodec = t.intersection([ }), t.partial({ timestamp: t.string, - ping: OverviewPingCode, + ping: OverviewPingCodec, }), ]); @@ -70,7 +74,7 @@ export const OverviewStatusStateCodec = t.intersection([ }), ]); -export type OverviewPing = t.TypeOf; +export type OverviewPing = t.TypeOf; export type OverviewStatus = t.TypeOf; export type OverviewStatusState = t.TypeOf; export type OverviewStatusMetaData = t.TypeOf; From 11856ab1d24d99b2ba1d07997705ecb37cd4703a Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Wed, 12 Jul 2023 15:56:20 +0200 Subject: [PATCH 17/20] [Security Solution] Unskipping Cypress local storage test (#161655) --- .../timelines/local_storage.cy.ts | 26 +++++++++---------- .../cypress/screens/timeline.ts | 7 +++-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/local_storage.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/local_storage.cy.ts index 6b6d585ddbb84c..239dbea8fce962 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/local_storage.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/local_storage.cy.ts @@ -5,40 +5,40 @@ * 2.0. */ -import { cleanKibana, reload } from '../../../tasks/common'; +import { reload } from '../../../tasks/common'; import { login, visit } from '../../../tasks/login'; import { HOSTS_URL } from '../../../urls/navigation'; import { openEvents } from '../../../tasks/hosts/main'; -import { DATAGRID_HEADERS } from '../../../screens/timeline'; +import { DATAGRID_HEADERS, DATAGRID_HEADER } from '../../../screens/timeline'; import { waitsForEventsToBeLoaded } from '../../../tasks/hosts/events'; import { removeColumn } from '../../../tasks/timeline'; -// TODO: Fix bug in persisting the columns of timeline -describe.skip('persistent timeline', () => { +describe('persistent timeline', () => { before(() => { - cleanKibana(); login(); visit(HOSTS_URL); openEvents(); waitsForEventsToBeLoaded(); + + /* Stores in 'expectedNumberOfTimelineColumns' alias the number of columns we are going to have + after the delition of the column */ cy.get(DATAGRID_HEADERS).then((header) => cy.wrap(header.length - 1).as('expectedNumberOfTimelineColumns') ); }); it('persist the deletion of a column', function () { - const MESSAGE_COLUMN = 'message'; - const MESSAGE_COLUMN_POSITION = 2; - - cy.get(DATAGRID_HEADERS).eq(MESSAGE_COLUMN_POSITION).should('have.text', MESSAGE_COLUMN); - removeColumn(MESSAGE_COLUMN); - - cy.get(DATAGRID_HEADERS).should('have.length', this.expectedNumberOfTimelineColumns); + /* For testing purposes we are going to use the message column */ + const COLUMN = 'message'; + cy.get(DATAGRID_HEADER(COLUMN)).should('exist'); + removeColumn(COLUMN); reload(); waitsForEventsToBeLoaded(); + /* After the deletion of the message column and the reload of the page, we make sure + we have the expected number of columns and that the message column is not displayed */ cy.get(DATAGRID_HEADERS).should('have.length', this.expectedNumberOfTimelineColumns); - cy.get(DATAGRID_HEADERS).each(($el) => expect($el.text()).not.equal(MESSAGE_COLUMN)); + cy.get(DATAGRID_HEADER(COLUMN)).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index be28b3438b26a2..d46b0d04f3355a 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -39,8 +39,11 @@ export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline- export const DATA_PROVIDERS = '.field-value'; -export const DATAGRID_HEADERS = - '[data-test-subj="events-viewer-panel"] [data-test-subj^="dataGridHeaderCell-"]'; +export const DATAGRID_HEADERS = '[data-test-subj^="dataGridHeaderCell-"]'; + +export const DATAGRID_HEADER = (header: string) => { + return `[data-test-subj="dataGridHeaderCell-${header}"]`; +}; export const DATE_PICKER_END = '[data-test-subj="superDatePickerendDatePopoverButton"]'; From 62c9a1f6cb7b0f29822eeda22bdb3497a5b44d86 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 12 Jul 2023 16:12:12 +0200 Subject: [PATCH 18/20] Add SLO Edit Form locator (#161753) --- x-pack/plugins/observability/common/index.ts | 1 + x-pack/plugins/observability/public/index.ts | 3 ++ .../public/locators/slo_edit.test.ts | 26 +++++++++++++ .../observability/public/locators/slo_edit.ts | 37 +++++++++++++++++++ x-pack/plugins/observability/public/plugin.ts | 4 ++ 5 files changed, 71 insertions(+) create mode 100644 x-pack/plugins/observability/public/locators/slo_edit.test.ts create mode 100644 x-pack/plugins/observability/public/locators/slo_edit.ts diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 50d55f071ac964..1d4bf57abf2853 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -65,6 +65,7 @@ export const alertsLocatorID = 'ALERTS_LOCATOR'; export const ruleDetailsLocatorID = 'RULE_DETAILS_LOCATOR'; export const rulesLocatorID = 'RULES_LOCATOR'; export const sloDetailsLocatorID = 'SLO_DETAILS_LOCATOR'; +export const sloEditLocatorID = 'SLO_EDIT_LOCATOR'; export type { AlertsLocatorParams } from './locators/alerts'; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 303257e7ca6172..50b67fc5d0bb0f 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -46,9 +46,12 @@ export { ruleDetailsLocatorID, rulesLocatorID, sloDetailsLocatorID, + sloEditLocatorID, uptimeOverviewLocatorID, } from '../common'; +export type { SloEditLocatorParams } from './locators/slo_edit'; + export type { UXMetrics } from './pages/overview/components/sections/ux/core_web_vitals/core_vitals'; export { getCoreVitalsComponent } from './pages/overview/components/sections/ux/core_web_vitals/get_core_web_vitals_lazy'; diff --git a/x-pack/plugins/observability/public/locators/slo_edit.test.ts b/x-pack/plugins/observability/public/locators/slo_edit.test.ts new file mode 100644 index 00000000000000..17039b72dd44a9 --- /dev/null +++ b/x-pack/plugins/observability/public/locators/slo_edit.test.ts @@ -0,0 +1,26 @@ +/* + * 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 { buildSlo } from '../data/slo/slo'; +import { SloEditLocatorDefinition } from './slo_edit'; + +describe('SloEditLocator', () => { + const locator = new SloEditLocatorDefinition(); + + it('should return correct url when empty params are provided', async () => { + const location = await locator.getLocation({}); + expect(location.app).toEqual('observability'); + expect(location.path).toEqual('/slos/create?_a=()'); + }); + + it('should return correct url when slo is provided', async () => { + const location = await locator.getLocation(buildSlo({ id: 'foo' })); + expect(location.path).toEqual( + "/slos/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z')" + ); + }); +}); diff --git a/x-pack/plugins/observability/public/locators/slo_edit.ts b/x-pack/plugins/observability/public/locators/slo_edit.ts new file mode 100644 index 00000000000000..bac7354d34190b --- /dev/null +++ b/x-pack/plugins/observability/public/locators/slo_edit.ts @@ -0,0 +1,37 @@ +/* + * 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 { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import type { RecursivePartial } from '@elastic/charts'; +import type { SerializableRecord } from '@kbn/utility-types'; +import type { LocatorDefinition } from '@kbn/share-plugin/public'; +import type { CreateSLOForm } from '../pages/slo_edit/types'; +import { sloEditLocatorID } from '../../common'; +import { SLO_CREATE_PATH, SLOS_PATH } from '../routes/paths'; + +export type SloEditParams = RecursivePartial; + +export interface SloEditLocatorParams extends SloEditParams, SerializableRecord {} + +export class SloEditLocatorDefinition implements LocatorDefinition { + public readonly id = sloEditLocatorID; + + public readonly getLocation = async (slo: SloEditLocatorParams) => { + return { + app: 'observability', + path: setStateToKbnUrl( + '_a', + { + ...slo, + }, + { useHash: false, storeInHashQuery: false }, + slo.id ? `${SLOS_PATH}/edit/${encodeURI(String(slo.id))}` : SLO_CREATE_PATH + ), + state: {}, + }; + }; +} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 78bb07a0ee662a..24c1d2a84ca47d 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -54,6 +54,7 @@ import { ExploratoryViewPublicStart } from '@kbn/exploratory-view-plugin/public' import { RulesLocatorDefinition } from './locators/rules'; import { RuleDetailsLocatorDefinition } from './locators/rule_details'; import { SloDetailsLocatorDefinition } from './locators/slo_details'; +import { SloEditLocatorDefinition } from './locators/slo_edit'; import { observabilityAppId, observabilityFeatureId } from '../common'; import { registerDataHandler } from './context/has_data_context/data_handler'; import { @@ -223,6 +224,8 @@ export class Plugin new SloDetailsLocatorDefinition() ); + const sloEditLocator = pluginsSetup.share.url.locators.create(new SloEditLocatorDefinition()); + const mount = async (params: AppMountParameters) => { // Load application bundle const { renderApp } = await import('./application'); @@ -349,6 +352,7 @@ export class Plugin rulesLocator, ruleDetailsLocator, sloDetailsLocator, + sloEditLocator, getCoPilotService: () => this.coPilotService!, }; } From c4790da182427f6793ef8082d9f9a4285385e821 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 12 Jul 2023 09:31:44 -0500 Subject: [PATCH 19/20] fix: return decoded urls on CloudStart as well as CloudSetup (#161711) ## Summary #159442 updated the decoding of the cloud id and added `elasticsearchUrl` & `kibanaUrl` to the `CloudStart` type, but it only set them on the `CloudSetup` result. This change will also add them to the `CloudStart` so they are available to code that is trying to read the values from `CloudStart` , mainly [serverless_search](https://github.com/elastic/kibana/blob/main/x-pack/plugins/serverless_search/public/application/components/overview.tsx#L49-L51) is what I'm concerned with. --- x-pack/plugins/cloud/public/plugin.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 78bd6a8a9ef9ac..b17614bb3c41e7 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -103,6 +103,11 @@ export class CloudPlugin implements Plugin { const { deploymentUrl, profileUrl, billingUrl, organizationUrl } = this.getCloudUrls(); + let decodedId: DecodedCloudId | undefined; + if (this.config.id) { + decodedId = decodeCloudId(this.config.id, this.logger); + } + return { CloudContextProvider, isCloudEnabled: this.isCloudEnabled, @@ -111,6 +116,8 @@ export class CloudPlugin implements Plugin { deploymentUrl, profileUrl, organizationUrl, + elasticsearchUrl: decodedId?.elasticsearchUrl, + kibanaUrl: decodedId?.kibanaUrl, }; } From d166193ac04a8a3db48f252bc90ca36928427507 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 12 Jul 2023 16:32:57 +0200 Subject: [PATCH 20/20] [Synthetics] Optimise get last 50 checks query (#161742) --- .../monitor_management/monitor_types.ts | 1 + .../overview/actions_popover.test.tsx | 1 + .../overview/overview/overview_grid.test.tsx | 2 + .../overview/overview/overview_grid_item.tsx | 1 + .../hooks/use_last_50_duration_chart.test.ts | 18 ++-- .../hooks/use_last_50_duration_chart.ts | 3 + .../hooks/use_last_x_checks.test.tsx | 29 ++++- .../synthetics/hooks/use_last_x_checks.ts | 20 +++- .../routes/monitor_cruds/get_monitor.test.ts | 101 ++++++++++++++++++ .../routes/monitor_cruds/get_monitor.ts | 5 +- .../apis/synthetics/get_monitor_overview.ts | 12 ++- 11 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.test.ts diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts index cd9e96081778bb..979919d6614a5b 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts @@ -395,6 +395,7 @@ export const MonitorOverviewItemCodec = t.intersection([ isStatusAlertEnabled: t.boolean, type: t.string, tags: t.array(t.string), + schedule: t.string, }), t.partial({ projectId: t.string, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx index e278aa9a3f5b4d..ea8edd7878a40e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx @@ -31,6 +31,7 @@ describe('ActionsPopover', () => { configId: '1lkjelre', type: 'browser', tags: [], + schedule: '120', }; }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx index ed470f6f24bced..3ea7d5e0ba5b32 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx @@ -32,6 +32,7 @@ describe('Overview Grid', () => { isStatusAlertEnabled: true, type: 'browser', tags: [], + schedule: '60', }); data.push({ id: `${i}`, @@ -45,6 +46,7 @@ describe('Overview Grid', () => { isStatusAlertEnabled: true, type: 'browser', tags: [], + schedule: '60', }); } return data; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx index 952a48d4247336..3fb27e202f9967 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx @@ -36,6 +36,7 @@ export const OverviewGridItem = ({ locationId: monitor.location?.id, monitorId: monitor.id, timestamp, + schedule: monitor.schedule, }); return ( { jest.spyOn(hooks, 'useLastXChecks').mockReturnValue({ hits: getMockHits(), loading: false }); const { result } = renderHook( - () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc' }), + () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc', schedule: '1' }), { wrapper: WrappedHelper } ); expect(result.current).toEqual({ @@ -91,7 +91,7 @@ describe('useLast50DurationChart', () => { .spyOn(hooks, 'useLastXChecks') .mockReturnValue({ hits: hitsWithAnUndefinedDuration, loading: false }); const { result } = renderHook( - () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc' }), + () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc', schedule: '10' }), { wrapper: WrappedHelper } ); @@ -153,7 +153,7 @@ describe('useLast50DurationChart', () => { const spy = jest .spyOn(hooks, 'useLastXChecks') .mockReturnValue({ hits: hitsWithAnUndefinedDuration, loading: false }); - renderHook(() => useLast50DurationChart({ monitorId, locationId }), { + renderHook(() => useLast50DurationChart({ monitorId, locationId, schedule: '120' }), { wrapper: WrappedHelper, }); @@ -163,6 +163,7 @@ describe('useLast50DurationChart', () => { locationId, fields: ['monitor.duration.us'], size: 50, + schedule: '120', }); }); @@ -171,12 +172,15 @@ describe('useLast50DurationChart', () => { jest.spyOn(hooks, 'useLastXChecks').mockReturnValue({ hits: getMockHits(), loading }); const { result } = renderHook( - () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc' }), + () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc', schedule: '3' }), { wrapper: WrappedHelper } ); - renderHook(() => useLast50DurationChart({ monitorId: 'test-id', locationId: 'loc' }), { - wrapper: WrappedHelper, - }); + renderHook( + () => useLast50DurationChart({ monitorId: 'test-id', locationId: 'loc', schedule: '5' }), + { + wrapper: WrappedHelper, + } + ); expect(result.current.loading).toEqual(loading); }); }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts index 8cb7d524635c72..f08e025a48540b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts @@ -14,10 +14,12 @@ export function useLast50DurationChart({ monitorId, locationId, timestamp, + schedule, }: { monitorId: string; timestamp?: string; locationId: string; + schedule: string; }) { const { hits, loading } = useLastXChecks<{ 'monitor.duration.us': number[] | undefined; @@ -27,6 +29,7 @@ export function useLast50DurationChart({ fields, size: 50, timestamp, + schedule, }); const { data, median, min, max, avg } = useMemo(() => { if (loading) { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx index 0a6154ab76244f..8c16902357273e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import { useLastXChecks } from './use_last_x_checks'; +import { getTimeRangeFilter, useLastXChecks } from './use_last_x_checks'; import { WrappedHelper } from '../utils/testing'; import * as searchHooks from './use_redux_es_search'; import { SYNTHETICS_INDEX_PATTERN } from '../../../../common/constants'; @@ -40,6 +40,7 @@ describe('useLastXChecks', () => { locationId: 'loc', size: 30, fields: ['monitor.duration.us'], + schedule: '10', }), { wrapper: WrappedHelper } ); @@ -95,6 +96,7 @@ describe('useLastXChecks', () => { locationId: 'loc', size, fields, + schedule: '120', }), { wrapper: WrappedHelper } ); @@ -118,6 +120,7 @@ describe('useLastXChecks', () => { locationId: 'loc', size: 30, fields: ['monitor.duration.us'], + schedule: '240', }), { wrapper: WrappedHelper } ); @@ -136,6 +139,7 @@ describe('useLastXChecks', () => { locationId: 'loc', size: 30, fields: ['monitor.duration.us'], + schedule: '1', }), { wrapper: WrappedHelper } ); @@ -164,6 +168,7 @@ describe('useLastXChecks', () => { locationId: 'loc', size: 30, fields: ['monitor.duration.us'], + schedule: '3', }), { wrapper: WrapperWithState } ); @@ -174,3 +179,25 @@ describe('useLastXChecks', () => { ); }); }); + +describe('getTimeRangeFilter', () => { + it.each([ + [1, 'now-1h'], + [3, 'now-3h'], + [5, 'now-5h'], + [10, 'now-9h'], + [60, 'now-50h'], + [120, 'now-100h'], + [240, 'now-200h'], + ])('returns expected filter', (val, res) => { + const filter = getTimeRangeFilter(String(val)); + expect(filter).toEqual({ + range: { + '@timestamp': { + gte: res, + lte: 'now', + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts index 2d7cc1775e05f8..d83da872ab4512 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts @@ -20,14 +20,30 @@ import { selectServiceLocationsState } from '../state'; import { useSyntheticsRefreshContext } from '../contexts/synthetics_refresh_context'; import { SYNTHETICS_INDEX_PATTERN, UNNAMED_LOCATION } from '../../../../common/constants'; +export const getTimeRangeFilter = (schedule: string) => { + const inMinutes = Number(schedule); + const fiftyChecksInMinutes = inMinutes * 50; + const hours = Math.ceil(fiftyChecksInMinutes / 60); + return { + range: { + '@timestamp': { + gte: `now-${hours}h`, + lte: 'now', + }, + }, + }; +}; + export function useLastXChecks({ monitorId, locationId, fields = ['*'], size = 50, timestamp, + schedule, }: { monitorId: string; + schedule: string; locationId: string; timestamp?: string; fields?: string[]; @@ -45,6 +61,7 @@ export function useLastXChecks({ filter: [ SUMMARY_FILTER, EXCLUDE_RUN_ONCE_FILTER, + getTimeRangeFilter(schedule), { term: { 'monitor.id': monitorId, @@ -58,13 +75,14 @@ export function useLastXChecks({ }), }, }, + _source: false, sort: [{ '@timestamp': 'desc' }], fields, }, }); const { data } = useReduxEsSearch(params, [lastRefresh], { - name: `zGetLastXMonitorRunsByLocation/${monitorId}/${locationId}`, + name: `zGetLastXChecks/${monitorId}/${locationId}`, isRequestReady: locationsLoaded && Boolean(timestamp), // don't run query until locations are loaded }); diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.test.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.test.ts new file mode 100644 index 00000000000000..f4393226323727 --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { getOverviewConfigsPerLocation } from './get_monitor'; +import { EncryptedSyntheticsMonitor } from '../../../common/runtime_types'; + +describe('getOverviewConfigsPerLocation', () => { + it('returns a map of locations to monitor configs', () => { + const result = getOverviewConfigsPerLocation(attributes as EncryptedSyntheticsMonitor); + expect(result).toEqual([ + { + configId: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + id: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + isEnabled: true, + isStatusAlertEnabled: true, + location: { id: 'us_central', isServiceManaged: true, label: 'North America - US Central' }, + name: 'https://simonhearne.com/', + projectId: undefined, + schedule: '3', + tags: [], + type: 'http', + }, + { + configId: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + id: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + isEnabled: true, + isStatusAlertEnabled: true, + location: { id: 'us_central_qa', isServiceManaged: true, label: 'US Central QA' }, + name: 'https://simonhearne.com/', + projectId: undefined, + schedule: '3', + tags: [], + type: 'http', + }, + ]); + }); + it('returns a map of locations to monitor configs with queried locations', () => { + const result = getOverviewConfigsPerLocation( + attributes as EncryptedSyntheticsMonitor, + 'us_central' + ); + expect(result).toEqual([ + { + configId: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + id: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + isEnabled: true, + isStatusAlertEnabled: true, + location: { id: 'us_central', isServiceManaged: true, label: 'North America - US Central' }, + name: 'https://simonhearne.com/', + projectId: undefined, + schedule: '3', + tags: [], + type: 'http', + }, + ]); + }); +}); + +const attributes = { + type: 'http', + form_monitor_type: 'http', + enabled: true, + alert: { status: { enabled: true }, tls: { enabled: true } }, + schedule: { number: '3', unit: 'm' }, + 'service.name': '', + config_id: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + tags: [], + timeout: '16', + name: 'https://simonhearne.com/', + locations: [ + { isServiceManaged: true, id: 'us_central', label: 'North America - US Central' }, + { isServiceManaged: true, id: 'us_central_qa', label: 'US Central QA' }, + ], + namespace: 'default', + origin: 'ui', + journey_id: '', + hash: '', + id: 'cfb51782-7152-43db-9986-02b34d5e5a8c', + __ui: { is_tls_enabled: false }, + urls: 'https://simonhearne.com/', + max_redirects: '0', + 'url.port': null, + proxy_url: '', + 'response.include_body': 'on_error', + 'response.include_headers': true, + 'check.response.status': [], + 'check.request.method': 'GET', + mode: 'any', + 'response.include_body_max_bytes': '1024', + ipv4: true, + ipv6: true, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + revision: 2, +}; diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts index 24e2d895f18e08..e9063d2eb48793 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts @@ -123,9 +123,9 @@ export const getSyntheticsMonitorOverviewRoute: SyntheticsRestApiRouteFactory = }, }); -function getOverviewConfigsPerLocation( +export function getOverviewConfigsPerLocation( attributes: EncryptedSyntheticsMonitor, - queriedLocations: string | string[] | undefined + queriedLocations?: string | string[] ) { const id = attributes[ConfigKey.MONITOR_QUERY_ID]; const configId = attributes[ConfigKey.CONFIG_ID]; @@ -149,6 +149,7 @@ function getOverviewConfigsPerLocation( configId, location, name: attributes[ConfigKey.NAME], + schedule: attributes[ConfigKey.SCHEDULE].number, tags: attributes[ConfigKey.TAGS], isEnabled: attributes[ConfigKey.ENABLED], type: attributes[ConfigKey.MONITOR_TYPE], diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts index 3225749712ead1..184e6883484e08 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts @@ -10,6 +10,7 @@ import { ConfigKey, SyntheticsMonitor, MonitorFields, + MonitorOverviewItem, } from '@kbn/synthetics-plugin/common/runtime_types'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; import expect from '@kbn/expect'; @@ -175,7 +176,8 @@ export default function ({ getService }: FtrProviderContext) { const apiResponse = await supertest .get(`/s/${SPACE_ID}${SYNTHETICS_API_URLS.SYNTHETICS_OVERVIEW}`) .query({ sortField: 'status' }); - expect(apiResponse.body.monitors).eql([ + + const expected: MonitorOverviewItem[] = [ { id: savedMonitors[0].attributes[ConfigKey.MONITOR_QUERY_ID], configId: savedMonitors[0].id, @@ -194,6 +196,7 @@ export default function ({ getService }: FtrProviderContext) { isStatusAlertEnabled: true, tags: ['tag1', 'tag2'], type: 'http', + schedule: '5', }, { id: savedMonitors[0].attributes[ConfigKey.MONITOR_QUERY_ID], @@ -213,6 +216,7 @@ export default function ({ getService }: FtrProviderContext) { isStatusAlertEnabled: true, tags: ['tag1', 'tag2'], type: 'http', + schedule: '5', }, { id: savedMonitors[1].attributes[ConfigKey.MONITOR_QUERY_ID], @@ -232,6 +236,7 @@ export default function ({ getService }: FtrProviderContext) { isStatusAlertEnabled: true, tags: ['tag1', 'tag2'], type: 'http', + schedule: '5', }, { id: savedMonitors[1].attributes[ConfigKey.MONITOR_QUERY_ID], @@ -251,8 +256,11 @@ export default function ({ getService }: FtrProviderContext) { isStatusAlertEnabled: true, tags: ['tag1', 'tag2'], type: 'http', + schedule: '5', }, - ]); + ]; + + expect(apiResponse.body.monitors).eql(expected); expect(savedMonitors[1].attributes[ConfigKey.MONITOR_QUERY_ID]).eql(customHeartbeatId); } finally { await Promise.all(