From 1949de076a3d677da0184a1a58ee3782be2c9b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Wed, 21 Aug 2024 18:33:50 +0200 Subject: [PATCH 01/45] Query builder base page (#190091) ## Summary Adds the base Search Playground behind a feature flag. This will be used to create new Search Playground page mode that is used for queries rather than LLMs Screenshot 2024-08-13 at 16 00 32 Screenshot 2024-08-13 at 16 00 41 ### 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 - [ ] [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../public/applications/index.tsx | 7 +- .../shared/kibana/kibana_logic.ts | 6 +- .../plugins/search_playground/common/index.ts | 3 + .../public/analytics/constants.ts | 3 + .../public/components/app.tsx | 89 +++++++++++++++---- .../public/components/header.tsx | 24 ++++- .../components/search_mode/result_list.tsx | 59 ++++++++++++ .../components/search_mode/search_mode.tsx | 62 +++++++++++++ .../{setup_page.tsx => chat_setup_page.tsx} | 9 +- .../search_playground_setup_page.tsx | 75 ++++++++++++++++ .../public/hooks/use_page_mode.ts | 44 +++++++++ .../use_search_playground_feature_flag.ts | 15 ++++ ...d_overview.tsx => playground_overview.tsx} | 12 ++- .../public/playground_router.tsx | 15 +++- .../search_playground/public/plugin.ts | 4 +- .../search_playground/public/routes.ts | 3 + .../plugins/search_playground/public/types.ts | 8 ++ .../public/utils/feature_flags.ts | 13 +++ .../search_playground/server/routes.ts | 1 - 19 files changed, 414 insertions(+), 38 deletions(-) create mode 100644 x-pack/plugins/search_playground/public/components/search_mode/result_list.tsx create mode 100644 x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx rename x-pack/plugins/search_playground/public/components/setup_page/{setup_page.tsx => chat_setup_page.tsx} (98%) create mode 100644 x-pack/plugins/search_playground/public/components/setup_page/search_playground_setup_page.tsx create mode 100644 x-pack/plugins/search_playground/public/hooks/use_page_mode.ts create mode 100644 x-pack/plugins/search_playground/public/hooks/use_search_playground_feature_flag.ts rename x-pack/plugins/search_playground/public/{chat_playground_overview.tsx => playground_overview.tsx} (78%) create mode 100644 x-pack/plugins/search_playground/public/utils/feature_flags.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 98d6677c35fc1..dab9c9a0b6049 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -152,7 +152,12 @@ export const renderApp = ( ReactDOM.render( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index f300a16ab29d9..da5209a9dc1ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -68,8 +68,8 @@ export interface KibanaLogicProps { productFeatures: ProductFeatures; renderHeaderActions(HeaderActions?: FC): void; searchHomepage?: SearchHomepagePluginStart; - searchPlayground?: SearchPlaygroundPluginStart; searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart; + searchPlayground?: SearchPlaygroundPluginStart; security?: SecurityPluginStart; setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; setChromeIsVisible(isVisible: boolean): void; @@ -103,8 +103,8 @@ export interface KibanaValues { productFeatures: ProductFeatures; renderHeaderActions(HeaderActions?: FC): void; searchHomepage: SearchHomepagePluginStart | null; - searchPlayground: SearchPlaygroundPluginStart | null; searchInferenceEndpoints: SearchInferenceEndpointsPluginStart | null; + searchPlayground: SearchPlaygroundPluginStart | null; security: SecurityPluginStart | null; setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; setChromeIsVisible(isVisible: boolean): void; @@ -150,8 +150,8 @@ export const KibanaLogic = kea>({ productFeatures: [props.productFeatures, {}], renderHeaderActions: [props.renderHeaderActions, {}], searchHomepage: [props.searchHomepage || null, {}], - searchPlayground: [props.searchPlayground || null, {}], searchInferenceEndpoints: [props.searchInferenceEndpoints || null, {}], + searchPlayground: [props.searchPlayground || null, {}], security: [props.security || null, {}], setBreadcrumbs: [props.setBreadcrumbs, {}], setChromeIsVisible: [props.setChromeIsVisible, {}], diff --git a/x-pack/plugins/search_playground/common/index.ts b/x-pack/plugins/search_playground/common/index.ts index 5d1ac311112a7..533dfa612ee44 100644 --- a/x-pack/plugins/search_playground/common/index.ts +++ b/x-pack/plugins/search_playground/common/index.ts @@ -7,3 +7,6 @@ export const PLUGIN_ID = 'searchPlayground'; export const PLUGIN_NAME = 'Playground'; +export const PLUGIN_PATH = '/app/search_playground'; + +export const SEARCH_MODE_FEATURE_FLAG_ID = 'searchPlayground:searchModeEnabled'; diff --git a/x-pack/plugins/search_playground/public/analytics/constants.ts b/x-pack/plugins/search_playground/public/analytics/constants.ts index 055b5b74b201b..55725026ba48f 100644 --- a/x-pack/plugins/search_playground/public/analytics/constants.ts +++ b/x-pack/plugins/search_playground/public/analytics/constants.ts @@ -22,12 +22,15 @@ export enum AnalyticsEvents { includeCitations = 'include_citations', instructionsFieldChanged = 'instructions_field_changed', queryFieldsUpdated = 'view_query_fields_updated', + queryBuilderFieldsUpdated = 'view_search_fields_updated', queryModeLoaded = 'query_mode_loaded', + queryBuilderModeLoaded = 'search_builder_mode_loaded', modelSelected = 'model_selected', retrievalDocsFlyoutOpened = 'retrieval_docs_flyout_opened', sourceFieldsLoaded = 'source_fields_loaded', sourceIndexUpdated = 'source_index_updated', setupChatPageLoaded = 'start_new_chat_page_loaded', + setupSearchPageLoaded = 'search_setup_page_loaded', viewCodeFlyoutOpened = 'view_code_flyout_opened', viewCodeLanguageChange = 'view_code_language_change', } diff --git a/x-pack/plugins/search_playground/public/components/app.tsx b/x-pack/plugins/search_playground/public/components/app.tsx index 8385f5c91d1fc..34b89433ea705 100644 --- a/x-pack/plugins/search_playground/public/components/app.tsx +++ b/x-pack/plugins/search_playground/public/components/app.tsx @@ -5,19 +5,23 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { useWatch } from 'react-hook-form'; import { QueryMode } from './query_mode/query_mode'; -import { SetupPage } from './setup_page/setup_page'; +import { ChatSetupPage } from './setup_page/chat_setup_page'; import { Header } from './header'; import { useLoadConnectors } from '../hooks/use_load_connectors'; -import { ChatForm, ChatFormFields } from '../types'; +import { ChatForm, ChatFormFields, PlaygroundPageMode } from '../types'; import { Chat } from './chat'; +import { SearchMode } from './search_mode/search_mode'; +import { SearchPlaygroundSetupPage } from './setup_page/search_playground_setup_page'; +import { usePageMode } from '../hooks/use_page_mode'; export interface AppProps { showDocs?: boolean; + pageMode?: PlaygroundPageMode; } export enum ViewMode { @@ -25,22 +29,65 @@ export enum ViewMode { query = 'query', } -export const App: React.FC = ({ showDocs = false }) => { - const [showSetupPage, setShowSetupPage] = useState(true); +export const App: React.FC = ({ + showDocs = false, + pageMode = PlaygroundPageMode.chat, +}) => { const [selectedMode, setSelectedMode] = useState(ViewMode.chat); const { data: connectors } = useLoadConnectors(); - const hasSelectedIndices = useWatch({ - name: ChatFormFields.indices, - }).length; - const handleModeChange = (id: string) => setSelectedMode(id as ViewMode); + const hasSelectedIndices = Boolean( + useWatch({ + name: ChatFormFields.indices, + }).length + ); + const handleModeChange = (id: ViewMode) => setSelectedMode(id); + const handlePageModeChange = (mode: PlaygroundPageMode) => setSelectedPageMode(mode); + const { + showSetupPage, + pageMode: selectedPageMode, + setPageMode: setSelectedPageMode, + } = usePageMode({ + hasSelectedIndices, + hasConnectors: Boolean(connectors?.length), + initialPageMode: pageMode, + }); + + const restrictedWidth = selectedPageMode === PlaygroundPageMode.search && selectedMode === 'chat'; + const paddingSize = + selectedPageMode === PlaygroundPageMode.search && selectedMode === 'chat' ? 'xl' : 'none'; - useEffect(() => { - if (showSetupPage && connectors?.length && hasSelectedIndices) { - setShowSetupPage(false); - } else if (!showSetupPage && (!connectors?.length || !hasSelectedIndices)) { - setShowSetupPage(true); - } - }, [connectors, hasSelectedIndices, showSetupPage]); + const getSetupPage = () => { + return ( + showSetupPage && ( + <> + {selectedPageMode === PlaygroundPageMode.chat && } + {selectedPageMode === PlaygroundPageMode.search && } + + ) + ); + }; + const getQueryBuilderPage = () => { + return ( + !showSetupPage && + selectedPageMode === PlaygroundPageMode.search && ( + <> + {selectedMode === ViewMode.chat && } + {selectedMode === ViewMode.query && } + + ) + ); + }; + const getChatPage = () => { + return ( + !showSetupPage && + selectedPageMode === PlaygroundPageMode.chat && ( + <> + {selectedMode === ViewMode.chat && } + {selectedMode === ViewMode.query && } + + ) + ); + }; return ( <> @@ -49,19 +96,23 @@ export const App: React.FC = ({ showDocs = false }) => { onModeChange={handleModeChange} selectedMode={selectedMode} isActionsDisabled={showSetupPage} + selectedPageMode={selectedPageMode} + onSelectPageModeChange={handlePageModeChange} /> - {showSetupPage ? : selectedMode === ViewMode.chat ? : } + {getSetupPage()} + {getChatPage()} + {getQueryBuilderPage()} ); diff --git a/x-pack/plugins/search_playground/public/components/header.tsx b/x-pack/plugins/search_playground/public/components/header.tsx index bc468a0e4be87..d24c8648ecec6 100644 --- a/x-pack/plugins/search_playground/public/components/header.tsx +++ b/x-pack/plugins/search_playground/public/components/header.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiPageHeaderSection, EuiPageTemplate, + EuiSelect, EuiTitle, useEuiTheme, } from '@elastic/eui'; @@ -20,11 +21,15 @@ import React from 'react'; import { PlaygroundHeaderDocs } from './playground_header_docs'; import { Toolbar } from './toolbar'; import { ViewMode } from './app'; +import { PlaygroundPageMode } from '../types'; +import { useSearchPlaygroundFeatureFlag } from '../hooks/use_search_playground_feature_flag'; interface HeaderProps { showDocs?: boolean; selectedMode: string; - onModeChange: (mode: string) => void; + onModeChange: (mode: ViewMode) => void; + selectedPageMode: PlaygroundPageMode; + onSelectPageModeChange: (mode: PlaygroundPageMode) => void; isActionsDisabled?: boolean; } @@ -33,7 +38,10 @@ export const Header: React.FC = ({ onModeChange, showDocs = false, isActionsDisabled = false, + selectedPageMode, + onSelectPageModeChange, }) => { + const isSearchModeEnabled = useSearchPlaygroundFeatureFlag(); const { euiTheme } = useEuiTheme(); const options = [ { @@ -72,6 +80,18 @@ export const Header: React.FC = ({ + {isSearchModeEnabled && ( + onSelectPageModeChange(e.target.value as PlaygroundPageMode)} + /> + )} + = ({ legend="viewMode" options={options} idSelected={selectedMode} - onChange={onModeChange} + onChange={(id: string) => onModeChange(id as ViewMode)} buttonSize="compressed" isDisabled={isActionsDisabled} data-test-subj="viewModeSelector" diff --git a/x-pack/plugins/search_playground/public/components/search_mode/result_list.tsx b/x-pack/plugins/search_playground/public/components/search_mode/result_list.tsx new file mode 100644 index 0000000000000..ca6bc48549ada --- /dev/null +++ b/x-pack/plugins/search_playground/public/components/search_mode/result_list.tsx @@ -0,0 +1,59 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; + +const DEMO_DATA = [ + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, + { id: '123321', name: 'John Doe', age: 25 }, +]; + +export const ResultList: React.FC = () => { + return ( + + + {DEMO_DATA.map((item, index) => { + return ( + <> + + + + +

{item.id}

+
+
+ + +

{item.name}

+
+
+
+
+ {index !== DEMO_DATA.length - 1 && } + + ); + })} +
+
+ ); +}; diff --git a/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx b/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx new file mode 100644 index 0000000000000..ed6b2ed70c188 --- /dev/null +++ b/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiSearchBar, + useEuiTheme, +} from '@elastic/eui'; +import React from 'react'; +import { css } from '@emotion/react'; +import { ResultList } from './result_list'; + +export const SearchMode: React.FC = () => { + const { euiTheme } = useEuiTheme(); + const showResults = true; // TODO demo + + return ( + + + + + + + + + + {showResults ? ( + + ) : ( + Ready to search} + body={ +

+ Type in a query in the search bar above or view the query we automatically + created for you. +

+ } + actions={View the query} + /> + )} +
+
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/search_playground/public/components/setup_page/setup_page.tsx b/x-pack/plugins/search_playground/public/components/setup_page/chat_setup_page.tsx similarity index 98% rename from x-pack/plugins/search_playground/public/components/setup_page/setup_page.tsx rename to x-pack/plugins/search_playground/public/components/setup_page/chat_setup_page.tsx index ed03d05712c48..fb240db692964 100644 --- a/x-pack/plugins/search_playground/public/components/setup_page/setup_page.tsx +++ b/x-pack/plugins/search_playground/public/components/setup_page/chat_setup_page.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import React, { useEffect } from 'react'; + import { EuiEmptyPrompt, EuiFlexGroup, @@ -13,17 +15,16 @@ import { EuiLoadingSpinner, EuiTitle, } from '@elastic/eui'; -import React, { useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { CreateIndexButton } from './create_index_button'; import { useQueryIndices } from '../../hooks/use_query_indices'; import { docLinks } from '../../../common/doc_links'; import { useUsageTracker } from '../../hooks/use_usage_tracker'; import { AnalyticsEvents } from '../../analytics/constants'; -import { ConnectLLMButton } from './connect_llm_button'; import { AddDataSources } from './add_data_sources'; +import { ConnectLLMButton } from './connect_llm_button'; +import { CreateIndexButton } from './create_index_button'; -export const SetupPage: React.FC = () => { +export const ChatSetupPage: React.FC = () => { const usageTracker = useUsageTracker(); const { indices, isLoading: isIndicesLoading } = useQueryIndices(); diff --git a/x-pack/plugins/search_playground/public/components/setup_page/search_playground_setup_page.tsx b/x-pack/plugins/search_playground/public/components/setup_page/search_playground_setup_page.tsx new file mode 100644 index 0000000000000..31478cc362163 --- /dev/null +++ b/x-pack/plugins/search_playground/public/components/setup_page/search_playground_setup_page.tsx @@ -0,0 +1,75 @@ +/* + * 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, { useEffect } from 'react'; + +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiLoadingSpinner, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useQueryIndices } from '../../hooks/use_query_indices'; +import { useUsageTracker } from '../../hooks/use_usage_tracker'; +import { AnalyticsEvents } from '../../analytics/constants'; +import { AddDataSources } from './add_data_sources'; + +export const SearchPlaygroundSetupPage: React.FC = () => { + const usageTracker = useUsageTracker(); + const { isLoading: isIndicesLoading } = useQueryIndices(); + + useEffect(() => { + usageTracker?.load(AnalyticsEvents.setupSearchPageLoaded); + }, [usageTracker]); + + return ( + + + + } + actions={ + + {isIndicesLoading ? ( + + ) : ( + + + + )} + + } + footer={ + <> + + + + + {' '} + + + + + } + /> + ); +}; diff --git a/x-pack/plugins/search_playground/public/hooks/use_page_mode.ts b/x-pack/plugins/search_playground/public/hooks/use_page_mode.ts new file mode 100644 index 0000000000000..2cc1149585de9 --- /dev/null +++ b/x-pack/plugins/search_playground/public/hooks/use_page_mode.ts @@ -0,0 +1,44 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { PlaygroundPageMode } from '../types'; + +export const usePageMode = ({ + hasSelectedIndices, + hasConnectors, + initialPageMode = PlaygroundPageMode.chat, +}: { + hasSelectedIndices: boolean; + hasConnectors: boolean; + initialPageMode?: PlaygroundPageMode; +}) => { + const [showSetupPage, setShowSetupPage] = useState(true); + const [pageMode, setPageMode] = useState(initialPageMode); + + useEffect(() => { + if (pageMode === PlaygroundPageMode.chat) { + if (showSetupPage && hasConnectors && hasSelectedIndices) { + setShowSetupPage(false); + } else if (!showSetupPage && (!hasConnectors || !hasSelectedIndices)) { + setShowSetupPage(true); + } + } else { + if (showSetupPage && hasSelectedIndices) { + setShowSetupPage(false); + } else if (!showSetupPage && !hasSelectedIndices) { + setShowSetupPage(true); + } + } + }, [hasSelectedIndices, showSetupPage, pageMode, hasConnectors]); + + return { + showSetupPage, + pageMode, + setPageMode, + }; +}; diff --git a/x-pack/plugins/search_playground/public/hooks/use_search_playground_feature_flag.ts b/x-pack/plugins/search_playground/public/hooks/use_search_playground_feature_flag.ts new file mode 100644 index 0000000000000..44cbfbeeaacb6 --- /dev/null +++ b/x-pack/plugins/search_playground/public/hooks/use_search_playground_feature_flag.ts @@ -0,0 +1,15 @@ +/* + * 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 { isSearchModeEnabled } from '../utils/feature_flags'; +import { useKibana } from './use_kibana'; + +export const useSearchPlaygroundFeatureFlag = (): boolean => { + const { uiSettings } = useKibana().services; + + return uiSettings ? isSearchModeEnabled(uiSettings) : false; +}; diff --git a/x-pack/plugins/search_playground/public/chat_playground_overview.tsx b/x-pack/plugins/search_playground/public/playground_overview.tsx similarity index 78% rename from x-pack/plugins/search_playground/public/chat_playground_overview.tsx rename to x-pack/plugins/search_playground/public/playground_overview.tsx index d3e56c5f7a6ba..9fec0fa566ac6 100644 --- a/x-pack/plugins/search_playground/public/chat_playground_overview.tsx +++ b/x-pack/plugins/search_playground/public/playground_overview.tsx @@ -9,10 +9,16 @@ import React, { useMemo } from 'react'; import { EuiPageTemplate } from '@elastic/eui'; import { PlaygroundProvider } from './providers/playground_provider'; -import { App } from './components/app'; import { useKibana } from './hooks/use_kibana'; +import { PlaygroundPageMode } from './types'; +import { App } from './components/app'; -export const ChatPlaygroundOverview: React.FC = () => { +interface PlaygroundOverviewProps { + pageMode?: PlaygroundPageMode; +} +export const PlaygroundOverview: React.FC = ({ + pageMode = PlaygroundPageMode.chat, +}) => { const { services: { console: consolePlugin }, } = useKibana(); @@ -31,7 +37,7 @@ export const ChatPlaygroundOverview: React.FC = () => { grow={false} panelled={false} > - + {embeddableConsole} diff --git a/x-pack/plugins/search_playground/public/playground_router.tsx b/x-pack/plugins/search_playground/public/playground_router.tsx index b32232dcd80cd..09c8e28982e14 100644 --- a/x-pack/plugins/search_playground/public/playground_router.tsx +++ b/x-pack/plugins/search_playground/public/playground_router.tsx @@ -8,17 +8,26 @@ import { Route, Routes } from '@kbn/shared-ux-router'; import React from 'react'; import { Redirect } from 'react-router-dom'; -import { ChatPlaygroundOverview } from './chat_playground_overview'; +import { PlaygroundOverview } from './playground_overview'; -import { ROOT_PATH, SEARCH_PLAYGROUND_CHAT_PATH } from './routes'; +import { ROOT_PATH, SEARCH_PLAYGROUND_CHAT_PATH, SEARCH_PLAYGROUND_SEARCH_PATH } from './routes'; +import { PlaygroundPageMode } from './types'; +import { useSearchPlaygroundFeatureFlag } from './hooks/use_search_playground_feature_flag'; export const PlaygroundRouter: React.FC = () => { + const isSearchModeEnabled = useSearchPlaygroundFeatureFlag(); + return ( - + + {isSearchModeEnabled && ( + + + + )} ); }; diff --git a/x-pack/plugins/search_playground/public/plugin.ts b/x-pack/plugins/search_playground/public/plugin.ts index c3cfd8bc7583e..20142c807b609 100644 --- a/x-pack/plugins/search_playground/public/plugin.ts +++ b/x-pack/plugins/search_playground/public/plugin.ts @@ -12,7 +12,7 @@ import type { AppMountParameters, PluginInitializerContext, } from '@kbn/core/public'; -import { PLUGIN_ID, PLUGIN_NAME } from '../common'; +import { PLUGIN_ID, PLUGIN_NAME, PLUGIN_PATH } from '../common'; import { docLinks } from '../common/doc_links'; import { PlaygroundHeaderDocs } from './components/playground_header_docs'; import { Playground, getPlaygroundProvider } from './embeddable'; @@ -42,7 +42,7 @@ export class SearchPlaygroundPlugin core.application.register({ id: PLUGIN_ID, - appRoute: '/app/search_playground', + appRoute: PLUGIN_PATH, title: PLUGIN_NAME, async mount({ element, history }: AppMountParameters) { const { renderApp } = await import('./application'); diff --git a/x-pack/plugins/search_playground/public/routes.ts b/x-pack/plugins/search_playground/public/routes.ts index afb279ed15bb3..72e8605f7c084 100644 --- a/x-pack/plugins/search_playground/public/routes.ts +++ b/x-pack/plugins/search_playground/public/routes.ts @@ -5,5 +5,8 @@ * 2.0. */ +export const SEARCH_PLAYGROUND_APP_ID = `search_playground`; export const ROOT_PATH = '/'; + export const SEARCH_PLAYGROUND_CHAT_PATH = `${ROOT_PATH}chat`; +export const SEARCH_PLAYGROUND_SEARCH_PATH = `${ROOT_PATH}search`; diff --git a/x-pack/plugins/search_playground/public/types.ts b/x-pack/plugins/search_playground/public/types.ts index 6e83f52f3f683..dd690c935312c 100644 --- a/x-pack/plugins/search_playground/public/types.ts +++ b/x-pack/plugins/search_playground/public/types.ts @@ -28,6 +28,11 @@ import { PlaygroundHeaderDocs } from './components/playground_header_docs'; export * from '../common/types'; +export enum PlaygroundPageMode { + chat = 'chat', + search = 'search', +} + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SearchPlaygroundPluginSetup {} export interface SearchPlaygroundPluginStart { @@ -57,6 +62,9 @@ export interface AppServicesContext { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; usageCollection?: UsageCollectionStart; console?: ConsolePluginStart; + featureFlags: { + searchPlaygroundEnabled: boolean; + }; } export enum ChatFormFields { diff --git a/x-pack/plugins/search_playground/public/utils/feature_flags.ts b/x-pack/plugins/search_playground/public/utils/feature_flags.ts new file mode 100644 index 0000000000000..adaf52368b55d --- /dev/null +++ b/x-pack/plugins/search_playground/public/utils/feature_flags.ts @@ -0,0 +1,13 @@ +/* + * 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 { IUiSettingsClient } from '@kbn/core/public'; +import { SEARCH_MODE_FEATURE_FLAG_ID } from '../../common'; + +export function isSearchModeEnabled(uiSettings: IUiSettingsClient): boolean { + return uiSettings.get(SEARCH_MODE_FEATURE_FLAG_ID, false); +} diff --git a/x-pack/plugins/search_playground/server/routes.ts b/x-pack/plugins/search_playground/server/routes.ts index 8e8ee0bf8617f..8d7769020f445 100644 --- a/x-pack/plugins/search_playground/server/routes.ts +++ b/x-pack/plugins/search_playground/server/routes.ts @@ -59,7 +59,6 @@ export function defineRoutes({ }, errorHandler(logger)(async (context, request, response) => { const { client } = (await context.core).elasticsearch; - const { indices } = request.body; const fields = await fetchFields(client, indices); From c1e751d9ff9f5c96cecdba72d2aede60d1dbcf02 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 21 Aug 2024 09:36:22 -0700 Subject: [PATCH 02/45] [Reporting] small documentation updates (#190930) ## Summary This PR offers small updates to documentation on: * Calling Kibana APIs to automate report generation * Using Elasticsearch APIs directly to export data --- docs/user/reporting/reporting-csv-limitations.asciidoc | 6 +++++- docs/user/reporting/response-codes.asciidoc | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/user/reporting/reporting-csv-limitations.asciidoc b/docs/user/reporting/reporting-csv-limitations.asciidoc index 253d6e4d75157..277b720e21be1 100644 --- a/docs/user/reporting/reporting-csv-limitations.asciidoc +++ b/docs/user/reporting/reporting-csv-limitations.asciidoc @@ -7,4 +7,8 @@ We recommend using CSV reports to export moderate amounts of data only. The feat - Cross-cluster search is used - ES|QL is used and result row count exceeds the limits of ES|QL queries -To work around the limitations, use filters to create multiple smaller reports, or extract the data you need directly with the Elasticsearch APIs. See {ref}/scroll-api.html[Scroll API], {ref}/point-in-time-api.html[Point in time API], {ref}/esql-rest.html[ES|QL] or {ref}/sql-rest-format.html#_csv[SQL] with CSV response data format. <> can be adjusted to overcome some of these limiting scenarios. Results are dependent on data size, availability, and latency factors and are not guaranteed. \ No newline at end of file +To work around the limitations, use filters to create multiple smaller reports, or extract the data you need directly with the Elasticsearch APIs. + +For more information on using Elasticsearch APIs directly, see {ref}/scroll-api.html[Scroll API], {ref}/point-in-time-api.html[Point in time API], {ref}/esql-rest.html[ES|QL] or {ref}/sql-rest-format.html#_csv[SQL] with CSV response data format. We recommend that you use an official Elastic language client: details for each programming language library that Elastic provides are in the https://www.elastic.co/guide/en/elasticsearch/client/index.html[{es} Client documentation]. + +<> can be adjusted to overcome some of these limiting scenarios. Results are dependent on data size, availability, and latency factors and are not guaranteed. \ No newline at end of file diff --git a/docs/user/reporting/response-codes.asciidoc b/docs/user/reporting/response-codes.asciidoc index 24a12a3de93a5..0b60fac63dc4b 100644 --- a/docs/user/reporting/response-codes.asciidoc +++ b/docs/user/reporting/response-codes.asciidoc @@ -1,5 +1,7 @@ -The reporting APIs use HTTP response codes to give feedback. In automation, -this helps external systems track the various possible job states: +The response payload of a request to generate a report includes the path to +download a report. The API to download a report uses HTTP response codes to give +feedback. In automation, this helps external systems track the various possible +job states: - **`200` (OK)**: As expected, Kibana returns `200` status in the response for successful requests to queue or download reports. From 0ffd37a074f41693c96e6ef9d6db495e3c03dbf0 Mon Sep 17 00:00:00 2001 From: Lola Date: Wed, 21 Aug 2024 12:52:38 -0400 Subject: [PATCH 03/45] [Cloud Security] fix disabled button when Agentless is selected (#190713) ## Summary Summarize your PR. If it involves visual changes include a screenshot or gif. This PR is to create Security project and to debug validation errors in Serverless QA. In Serverless QA, when we submit save an Agentless integration, the Save and Continue button is disabled. Locally, the button works as expected. I need to add `console.debug on Validation errors` since I don't have visibility on the package policy validation errors. [Reverted code back to show Agent-based pop-ups](https://github.com/elastic/kibana/pull/189932/files#r1707816416) and setFormState depending on the agentCount. **Before:** **Locally** https://github.com/user-attachments/assets/940cd523-c83b-44e2-8bf7-92bff019d85a **QA** https://github.com/user-attachments/assets/ce15011c-ccc0-4941-8798-40e78cf81023 **After Project CI Build:** **Serverless QA** https://github.com/user-attachments/assets/364903f4-c6af-4768-ab48-ca8da9cdb476 --------- Co-authored-by: Elastic Machine --- .../aws_credentials_form_agentless.tsx | 4 ++-- .../single_page_layout/hooks/form.tsx | 23 ++++++++++++++----- .../add_cis_integration_form_page.ts | 5 ++++ .../agentless/cis_integration_aws.ts | 12 ++++++++++ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx index c730153a96254..ee03e50ea36a1 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx @@ -119,12 +119,12 @@ Utilize AWS CloudFormation (a built-in AWS tool) or a series of manual steps to defaultMessage="Tick the checkbox under {capabilities} in the opened CloudFormation stack review form: {acknowledge}" values={{ acknowledge: ( - + - + ), capabilities: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx index 993feb605a986..697731f744721 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx @@ -378,17 +378,28 @@ export function useOnSubmit({ const hasGoogleCloudShell = data?.item ? getCloudShellUrlFromPackagePolicy(data.item) : false; - if (agentCount > 0) { - setFormState('SUBMITTED'); - return; + // Check if agentless is configured in ESS and Serverless until Agentless API migrates to Serverless + const isAgentlessConfigured = + isAgentlessAgentPolicy(createdPolicy) || isAgentlessPackagePolicy(data!.item); + + // Removing this code will disabled the Save and Continue button. We need code below update form state and trigger correct modal depending on agent count + if (hasFleetAddAgentsPrivileges && !isAgentlessConfigured) { + if (agentCount) { + setFormState('SUBMITTED'); + } else if (hasAzureArmTemplate) { + setFormState('SUBMITTED_AZURE_ARM_TEMPLATE'); + } else if (hasCloudFormation) { + setFormState('SUBMITTED_CLOUD_FORMATION'); + } else if (hasGoogleCloudShell) { + setFormState('SUBMITTED_GOOGLE_CLOUD_SHELL'); + } else { + setFormState('SUBMITTED_NO_AGENTS'); + } } if (!error) { setSavedPackagePolicy(data!.item); - // Check if agentless is configured in ESS and Serverless until Agentless API migrates to Serverless - const isAgentlessConfigured = - isAgentlessAgentPolicy(createdPolicy) || isAgentlessPackagePolicy(data!.item); const promptForAgentEnrollment = !(agentCount && agentPolicies.length > 0) && !isAgentlessConfigured && diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts index 5337e23d985ae..8732f0ba5b012 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts @@ -476,6 +476,10 @@ export function AddCisIntegrationFormPageProvider({ await PageObjects.header.waitUntilLoadingHasFinished(); }; + const showSuccessfulToast = async (testSubjectId: string) => { + return await testSubjects.exists(testSubjectId); + }; + const getFirstCspmIntegrationPageIntegration = async () => { const integration = await testSubjects.find('integrationNameLink'); return await integration.getVisibleText(); @@ -539,5 +543,6 @@ export function AddCisIntegrationFormPageProvider({ getFirstCspmIntegrationPageIntegration, getFirstCspmIntegrationPageAgent, getAgentBasedPolicyValue, + showSuccessfulToast, }; } diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts index 13276bb6cc66d..a33c484f8c90d 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts @@ -142,5 +142,17 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ).to.be('true'); }); }); + + describe('Serverless - Agentless CIS_AWS Create flow', () => { + it(`user should save agentless integration policy when there are no api or validation errors and button is not disabled`, async () => { + await cisIntegration.createAgentlessIntegration({ + cloudProvider: 'aws', + }); + + expect(await cisIntegration.showSuccessfulToast('packagePolicyCreateSuccessToast')).to.be( + true + ); + }); + }); }); } From 1da096793d775d9dacafc6a744b84caf5cb2a5eb Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 21 Aug 2024 11:19:35 -0600 Subject: [PATCH 04/45] [canvas] fix by-value map embeddables have broken layers (#190996) Resolves https://github.com/elastic/kibana/issues/190994 ### Test instructions 1. create new canvas workpad 2. Click "Select type" and select "Maps" 3. Add a documents layer to your map and click "Save and return" 4. Click "canvas" bread crumb to go back to canvas listing page 5. Open you work pad again. Map should still display data --- .../canvas/public/components/hooks/use_canvas_api.tsx | 1 + x-pack/plugins/canvas/types/embeddables.ts | 2 ++ .../public/react_embeddable/map_react_embeddable.tsx | 11 +++++++++++ 3 files changed, 14 insertions(+) diff --git a/x-pack/plugins/canvas/public/components/hooks/use_canvas_api.tsx b/x-pack/plugins/canvas/public/components/hooks/use_canvas_api.tsx index 3d1784bf65c82..fa302c57ead8c 100644 --- a/x-pack/plugins/canvas/public/components/hooks/use_canvas_api.tsx +++ b/x-pack/plugins/canvas/public/components/hooks/use_canvas_api.tsx @@ -49,6 +49,7 @@ export const useCanvasApi: () => CanvasContainerApi = () => { createNewEmbeddable(panelType, initialState); }, disableTriggers: true, + type: 'canvas', /** * getSerializedStateForChild is left out here because we cannot access the state here. That method * is injected in `x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx` diff --git a/x-pack/plugins/canvas/types/embeddables.ts b/x-pack/plugins/canvas/types/embeddables.ts index dfdce31c9058b..794e0b8f1cdad 100644 --- a/x-pack/plugins/canvas/types/embeddables.ts +++ b/x-pack/plugins/canvas/types/embeddables.ts @@ -11,6 +11,7 @@ import { EmbeddableInput as Input } from '@kbn/embeddable-plugin/common'; import type { HasAppContext, HasDisableTriggers, + HasType, PublishesViewMode, PublishesUnifiedSearch, } from '@kbn/presentation-publishing'; @@ -25,5 +26,6 @@ export type EmbeddableInput = Input & { export type CanvasContainerApi = PublishesViewMode & CanAddNewPanel & HasDisableTriggers & + HasType & HasSerializedChildState & Partial; diff --git a/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx b/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx index 415e3819cdadb..28a14592a8c32 100644 --- a/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx +++ b/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx @@ -12,6 +12,7 @@ import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public'; import { ReactEmbeddableFactory, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { + apiIsOfType, areTriggersDisabled, getUnchangingComparator, initializeTimeRange, @@ -125,6 +126,16 @@ export const mapEmbeddableFactory: ReactEmbeddableFactory< }; } + /** + * Canvas by-value embeddables do not support references + */ + if (apiIsOfType(parentApi, 'canvas')) { + return { + rawState: getByValueState(rawState, savedMap.getAttributes()), + references: [], + }; + } + // by-value embeddable const { attributes, references } = extractReferences({ attributes: savedMap.getAttributes(), From 6db6a8d7a6f72310591a03295c40dcc045f3324f Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Wed, 21 Aug 2024 19:34:50 +0200 Subject: [PATCH 05/45] [Ingest Pipelines] Fix scss deprecation issue (#190945) --- .../components/processors_tree/processors_tree.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.scss index b04020cb59c69..06deff37ff9b1 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.scss @@ -14,12 +14,13 @@ height: 2px; &--visible { - &:hover { - background-color: $euiColorPrimary; - } visibility: visible; } + &--visible:hover { + background-color: $euiColorPrimary; + } + &--unavailable { &:hover { background-color: $euiColorMediumShade; From 64e1116b4b0a286787f2ee11bffd89e292c41e1a Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 21 Aug 2024 20:08:49 +0200 Subject: [PATCH 06/45] Dashboard insights flyout with dashboard views (#187993) ## Summary close https://github.com/elastic/kibana/issues/183687 ## Feature - Implement dashboard view stats UI on top of usage counter that counts dashboard views for last 90 day and shows weekly histogram. - (Even if there is not a lot of data, we still show it as a weekly histogram, so it can be pretty empty intially) ![Screenshot 2024-08-15 at 13 00 11](https://github.com/user-attachments/assets/adeabf78-e3d3-4cfa-adc3-76a32ede595b) ## Implementation ### Server side Dashboard plugin registers new routes to increase the view count and get stats. Routes are protected for users with dashboard access only. The implementation is located in `@kbn/content-management-content-insights-server` and internally uses usage counters. The retention is 90 days, so we can only show stats for last 90 days. ### Client side - Dashboard uses the client from `@kbn/content-management-content-insights-public` to increase the view count every time a user opens a dashboard. - TableListView opens the flyout from `@kbn/content-management-content-insights-public`to display the stats ## How to test - For new views just open a dashboard and check that view stat is increased - For old views you can populate the usage counters with historic data. I used the following script: https://gist.github.com/Dosant/425042fcf75d5e40e5a46374f6234a54 --- .github/CODEOWNERS | 2 + package.json | 2 + .../src/components/editor_flyout_content.tsx | 7 +- .../editor_flyout_content_container.tsx | 2 +- .../src/components/editor_loader.tsx | 46 +++--- .../inspector_flyout_content.test.tsx | 17 -- .../src/open_content_editor.tsx | 2 +- .../content_editor/tsconfig.json | 1 - .../content_insights/README.mdx | 64 ++++++++ .../content_insights_public/README.md | 3 + .../content_insights_public/index.ts | 21 +++ .../content_insights_public/jest.config.js | 13 ++ .../content_insights_public/kibana.jsonc | 5 + .../content_insights_public/package.json | 6 + .../content_insights_public/src/client.ts | 50 ++++++ .../src/components/activity_view.test.tsx | 0 .../src/components/activity_view.tsx | 127 ++++++--------- .../src/components/index.ts | 10 ++ .../src/components/views_stats/index.ts | 9 ++ .../components/views_stats/views_chart.tsx | 80 ++++++++++ .../views_stats/views_stats.test.tsx | 64 ++++++++ .../components/views_stats/views_stats.tsx | 125 +++++++++++++++ .../views_stats/views_stats_lib.test.tsx | 61 ++++++++ .../content_insights_public/src/services.tsx | 49 ++++++ .../content_insights_public/src/types.ts | 11 ++ .../content_insights_public/tsconfig.json | 32 ++++ .../content_insights_server/README.md | 3 + .../content_insights_server/index.ts | 13 ++ .../content_insights_server/jest.config.js | 13 ++ .../content_insights_server/kibana.jsonc | 5 + .../content_insights_server/package.json | 6 + .../content_insights_server/src/register.ts | 147 ++++++++++++++++++ .../content_insights_server/tsconfig.json | 21 +++ .../content_editor_activity_row.tsx | 48 ++++++ .../table_list_view_table/src/services.tsx | 68 ++++---- .../src/table_list_view.test.tsx | 4 +- .../src/table_list_view_table.tsx | 20 ++- .../table_list_view_table/tsconfig.json | 1 + .../embeddable/create/create_dashboard.ts | 9 ++ .../dashboard_listing/dashboard_listing.tsx | 2 + .../dashboard_listing_table.tsx | 2 + .../use_dashboard_listing_table.test.tsx | 1 - .../hooks/use_dashboard_listing_table.tsx | 1 - .../dashboard_content_insights.stub.ts | 23 +++ .../dashboard_content_insights_service.ts | 33 ++++ .../dashboard_content_insights/types.ts | 14 ++ .../public/services/plugin_services.stub.ts | 2 + .../public/services/plugin_services.ts | 2 + .../dashboard/public/services/types.ts | 2 + src/plugins/dashboard/server/plugin.ts | 25 ++- src/plugins/dashboard/tsconfig.json | 2 + src/plugins/usage_collection/server/index.ts | 2 +- .../dashboard/group4/dashboard_listing.ts | 39 +++++ tsconfig.base.json | 4 + .../__snapshots__/oss_features.test.ts.snap | 4 + .../plugins/features/server/oss_features.ts | 4 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../platform_security/authorization.ts | 4 + yarn.lock | 8 + 61 files changed, 1172 insertions(+), 175 deletions(-) create mode 100644 packages/content-management/content_insights/README.mdx create mode 100644 packages/content-management/content_insights/content_insights_public/README.md create mode 100644 packages/content-management/content_insights/content_insights_public/index.ts create mode 100644 packages/content-management/content_insights/content_insights_public/jest.config.js create mode 100644 packages/content-management/content_insights/content_insights_public/kibana.jsonc create mode 100644 packages/content-management/content_insights/content_insights_public/package.json create mode 100644 packages/content-management/content_insights/content_insights_public/src/client.ts rename packages/content-management/{content_editor => content_insights/content_insights_public}/src/components/activity_view.test.tsx (100%) rename packages/content-management/{content_editor => content_insights/content_insights_public}/src/components/activity_view.tsx (50%) create mode 100644 packages/content-management/content_insights/content_insights_public/src/components/index.ts create mode 100644 packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts create mode 100644 packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx create mode 100644 packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx create mode 100644 packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx create mode 100644 packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx create mode 100644 packages/content-management/content_insights/content_insights_public/src/services.tsx create mode 100644 packages/content-management/content_insights/content_insights_public/src/types.ts create mode 100644 packages/content-management/content_insights/content_insights_public/tsconfig.json create mode 100644 packages/content-management/content_insights/content_insights_server/README.md create mode 100644 packages/content-management/content_insights/content_insights_server/index.ts create mode 100644 packages/content-management/content_insights/content_insights_server/jest.config.js create mode 100644 packages/content-management/content_insights/content_insights_server/kibana.jsonc create mode 100644 packages/content-management/content_insights/content_insights_server/package.json create mode 100644 packages/content-management/content_insights/content_insights_server/src/register.ts create mode 100644 packages/content-management/content_insights/content_insights_server/tsconfig.json create mode 100644 packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx create mode 100644 src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts create mode 100644 src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts create mode 100644 src/plugins/dashboard/public/services/dashboard_content_insights/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 113c79a2dc0a0..ba352582bd651 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -99,6 +99,8 @@ packages/kbn-config-mocks @elastic/kibana-core packages/kbn-config-schema @elastic/kibana-core src/plugins/console @elastic/kibana-management packages/content-management/content_editor @elastic/appex-sharedux +packages/content-management/content_insights/content_insights_public @elastic/appex-sharedux +packages/content-management/content_insights/content_insights_server @elastic/appex-sharedux examples/content_management_examples @elastic/appex-sharedux packages/content-management/favorites/favorites_public @elastic/appex-sharedux packages/content-management/favorites/favorites_server @elastic/appex-sharedux diff --git a/package.json b/package.json index 6ef8a524f4a3b..9ff4ac1707b18 100644 --- a/package.json +++ b/package.json @@ -222,6 +222,8 @@ "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/console-plugin": "link:src/plugins/console", "@kbn/content-management-content-editor": "link:packages/content-management/content_editor", + "@kbn/content-management-content-insights-public": "link:packages/content-management/content_insights/content_insights_public", + "@kbn/content-management-content-insights-server": "link:packages/content-management/content_insights/content_insights_server", "@kbn/content-management-examples-plugin": "link:examples/content_management_examples", "@kbn/content-management-favorites-public": "link:packages/content-management/favorites/favorites_public", "@kbn/content-management-favorites-server": "link:packages/content-management/favorites/favorites_server", diff --git a/packages/content-management/content_editor/src/components/editor_flyout_content.tsx b/packages/content-management/content_editor/src/components/editor_flyout_content.tsx index a347dbb2c4d31..acc5f68d62b02 100644 --- a/packages/content-management/content_editor/src/components/editor_flyout_content.tsx +++ b/packages/content-management/content_editor/src/components/editor_flyout_content.tsx @@ -28,7 +28,6 @@ import type { Item } from '../types'; import { MetadataForm } from './metadata_form'; import { useMetadataForm } from './use_metadata_form'; import type { CustomValidators } from './use_metadata_form'; -import { ActivityView } from './activity_view'; const getI18nTexts = ({ entityName }: { entityName: string }) => ({ saveButtonLabel: i18n.translate('contentManagement.contentEditor.saveButtonLabel', { @@ -56,7 +55,7 @@ export interface Props { }) => Promise; customValidators?: CustomValidators; onCancel: () => void; - showActivityView?: boolean; + appendRows?: React.ReactNode; } const capitalize = (str: string) => `${str.charAt(0).toLocaleUpperCase()}${str.substring(1)}`; @@ -70,7 +69,7 @@ export const ContentEditorFlyoutContent: FC = ({ onSave, onCancel, customValidators, - showActivityView, + appendRows, }) => { const { euiTheme } = useEuiTheme(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -151,7 +150,7 @@ export const ContentEditorFlyoutContent: FC = ({ TagList={TagList} TagSelector={TagSelector} > - {showActivityView && } + {appendRows} diff --git a/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx b/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx index 18094bc04f084..49359cd1801f2 100644 --- a/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx +++ b/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx @@ -21,7 +21,7 @@ type CommonProps = Pick< | 'onCancel' | 'entityName' | 'customValidators' - | 'showActivityView' + | 'appendRows' >; export type Props = CommonProps; diff --git a/packages/content-management/content_editor/src/components/editor_loader.tsx b/packages/content-management/content_editor/src/components/editor_loader.tsx index b15009f3b4db1..6bfe88fa2c12a 100644 --- a/packages/content-management/content_editor/src/components/editor_loader.tsx +++ b/packages/content-management/content_editor/src/components/editor_loader.tsx @@ -6,32 +6,30 @@ * Side Public License, v 1. */ -import React, { useState, useCallback, useEffect } from 'react'; -import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; +import React from 'react'; +import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader } from '@elastic/eui'; import type { Props } from './editor_flyout_content_container'; -export const ContentEditorLoader: React.FC = (props) => { - const [Editor, setEditor] = useState | null>(null); - - const loadEditor = useCallback(async () => { - const { ContentEditorFlyoutContentContainer } = await import( - './editor_flyout_content_container' - ); - setEditor(() => ContentEditorFlyoutContentContainer); - }, []); +const ContentEditorFlyoutContentContainer = React.lazy(() => + import('./editor_flyout_content_container').then( + ({ ContentEditorFlyoutContentContainer: _ContentEditorFlyoutContentContainer }) => ({ + default: _ContentEditorFlyoutContentContainer, + }) + ) +); - useEffect(() => { - // On mount: load the editor asynchronously - loadEditor(); - }, [loadEditor]); - - return Editor ? ( - - ) : ( - <> - - - - +export const ContentEditorLoader: React.FC = (props) => { + return ( + + + + + + } + > + + ); }; diff --git a/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx b/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx index c543acedbae5b..e4668d022d00d 100644 --- a/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx +++ b/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx @@ -262,22 +262,5 @@ describe('', () => { tags: ['id-3', 'id-4'], // New selection }); }); - - test('should render activity view', async () => { - await act(async () => { - testBed = await setup({ showActivityView: true }); - }); - const { find, component } = testBed!; - - expect(find('activityView').exists()).toBe(true); - expect(find('activityView.createdByCard').exists()).toBe(true); - expect(find('activityView.updatedByCard').exists()).toBe(false); - - testBed.setProps({ - item: { ...savedObjectItem, updatedAt: '2021-01-01T00:00:00Z' }, - }); - component.update(); - expect(find('activityView.updatedByCard').exists()).toBe(true); - }); }); }); diff --git a/packages/content-management/content_editor/src/open_content_editor.tsx b/packages/content-management/content_editor/src/open_content_editor.tsx index 89b73991ba5d6..2365aa9641e23 100644 --- a/packages/content-management/content_editor/src/open_content_editor.tsx +++ b/packages/content-management/content_editor/src/open_content_editor.tsx @@ -21,7 +21,7 @@ export type OpenContentEditorParams = Pick< | 'readonlyReason' | 'entityName' | 'customValidators' - | 'showActivityView' + | 'appendRows' >; export function useOpenContentEditor() { diff --git a/packages/content-management/content_editor/tsconfig.json b/packages/content-management/content_editor/tsconfig.json index b4f77e22f1f44..565535ec85b3e 100644 --- a/packages/content-management/content_editor/tsconfig.json +++ b/packages/content-management/content_editor/tsconfig.json @@ -30,7 +30,6 @@ "@kbn/test-jest-helpers", "@kbn/react-kibana-mount", "@kbn/content-management-user-profiles", - "@kbn/user-profile-components" ], "exclude": [ "target/**/*" diff --git a/packages/content-management/content_insights/README.mdx b/packages/content-management/content_insights/README.mdx new file mode 100644 index 0000000000000..a2a3894775a29 --- /dev/null +++ b/packages/content-management/content_insights/README.mdx @@ -0,0 +1,64 @@ +--- +id: sharedUX/ContentInsights +slug: /shared-ux/content-insights +title: Content Insights +description: A set of Content Management services and component to provide insights on the content of Kibana. +tags: ['shared-ux', 'component'] +date: 2024-08-06 +--- + +## Description + +The Content Insights is a set of Content Management services and components to provide insights on the content of Kibana. +Currently, it allows to track the usage of your content and display the stats of it. + +- The service can count the following events: + - `viewed` +- It provides the api for registering the routes to increase the count and to get the stats. +- It provides the client to increase the count and to get the stats. +- It provides a flyout and a component to display the stats as a total count and a weekly chart. +- Internally it uses the usage collection plugin to store and search the data. + +## API + +// server side + +```ts +import { registerContentInsights } from '@kbn/content-management-content-insights-server'; + +if (plugins.usageCollection) { + // Registers routes for tracking and fetching dashboard views + registerContentInsights( + { + usageCollection: plugins.usageCollection, + http: core.http, + getStartServices: () => + core.getStartServices().then(([_, start]) => ({ + usageCollection: start.usageCollection!, + })), + }, + { + domainId: 'dashboard', + // makes sure that only users with read/all access to dashboard app can access the routes + routeTags: ['access:dashboardUsageStats'], + } + ); +} +``` + +// client side + +```ts +import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; + +const contentInsightsClient = new ContentInsightsClient( + { http: params.coreStart.http }, + { domainId: 'dashboard' } +); + +contentInsightsClient.track(dashboardId, 'viewed'); + +// wrap component in `ContentInsightsProvider` and use the hook to open an insights flyout +const openInsightsFlyout = useOpenInsightsFlyout(); +openInsightsFlyout({ item }); +``` diff --git a/packages/content-management/content_insights/content_insights_public/README.md b/packages/content-management/content_insights/content_insights_public/README.md new file mode 100644 index 0000000000000..719e26238f12a --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-content-insights-public + +Refer to [README](../README.mdx) diff --git a/packages/content-management/content_insights/content_insights_public/index.ts b/packages/content-management/content_insights/content_insights_public/index.ts new file mode 100644 index 0000000000000..e1a0a67ec39bf --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + ContentInsightsProvider, + type ContentInsightsServices, + useServices as useContentInsightsServices, +} from './src/services'; + +export { + type ContentInsightsClientPublic, + ContentInsightsClient, + type ContentInsightsEventTypes, +} from './src/client'; + +export { ActivityView, ViewsStats, type ActivityViewProps } from './src/components'; diff --git a/packages/content-management/content_insights/content_insights_public/jest.config.js b/packages/content-management/content_insights/content_insights_public/jest.config.js new file mode 100644 index 0000000000000..b1844b25fcfca --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/content-management/content_insights/content_insights_public'], +}; diff --git a/packages/content-management/content_insights/content_insights_public/kibana.jsonc b/packages/content-management/content_insights/content_insights_public/kibana.jsonc new file mode 100644 index 0000000000000..fc4e12374faf9 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/content-management-content-insights-public", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/content-management/content_insights/content_insights_public/package.json b/packages/content-management/content_insights/content_insights_public/package.json new file mode 100644 index 0000000000000..ca78ba0f1e39d --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/content-management-content-insights-public", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/content-management/content_insights/content_insights_public/src/client.ts b/packages/content-management/content_insights/content_insights_public/src/client.ts new file mode 100644 index 0000000000000..8f392ce50536d --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/client.ts @@ -0,0 +1,50 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { HttpStart } from '@kbn/core-http-browser'; +import type { + ContentInsightsStats, + ContentInsightsStatsResponse, +} from '@kbn/content-management-content-insights-server'; + +export type ContentInsightsEventTypes = 'viewed'; + +/** + * Public interface of the Content Management Insights service. + */ +export interface ContentInsightsClientPublic { + track(id: string, eventType: ContentInsightsEventTypes): void; + getStats(id: string, eventType: ContentInsightsEventTypes): Promise; +} + +/** + * Client for the Content Management Insights service. + */ +export class ContentInsightsClient implements ContentInsightsClientPublic { + constructor( + private readonly deps: { http: HttpStart }, + private readonly config: { domainId: string } + ) {} + + track(id: string, eventType: ContentInsightsEventTypes) { + this.deps.http + .post(`/internal/content_management/insights/${this.config.domainId}/${id}/${eventType}`) + .catch((e) => { + // eslint-disable-next-line no-console + console.warn(`Could not track ${eventType} event for ${id}`, e); + }); + } + + async getStats(id: string, eventType: ContentInsightsEventTypes) { + return this.deps.http + .get( + `/internal/content_management/insights/${this.config.domainId}/${id}/${eventType}/stats` + ) + .then((response) => response.result); + } +} diff --git a/packages/content-management/content_editor/src/components/activity_view.test.tsx b/packages/content-management/content_insights/content_insights_public/src/components/activity_view.test.tsx similarity index 100% rename from packages/content-management/content_editor/src/components/activity_view.test.tsx rename to packages/content-management/content_insights/content_insights_public/src/components/activity_view.test.tsx diff --git a/packages/content-management/content_editor/src/components/activity_view.tsx b/packages/content-management/content_insights/content_insights_public/src/components/activity_view.tsx similarity index 50% rename from packages/content-management/content_editor/src/components/activity_view.tsx rename to packages/content-management/content_insights/content_insights_public/src/components/activity_view.tsx index eb413acb20e36..065a2ed0648a7 100644 --- a/packages/content-management/content_editor/src/components/activity_view.tsx +++ b/packages/content-management/content_insights/content_insights_public/src/components/activity_view.tsx @@ -6,15 +6,7 @@ * Side Public License, v 1. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIconTip, - EuiPanel, - EuiSpacer, - EuiText, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; @@ -30,7 +22,7 @@ import { getUserDisplayName } from '@kbn/user-profile-components'; import { Item } from '../types'; export interface ActivityViewProps { - item: Pick; + item: Pick, 'createdBy' | 'createdAt' | 'updatedBy' | 'updatedAt' | 'managed'>; } export const ActivityView = ({ item }: ActivityViewProps) => { @@ -54,78 +46,53 @@ export const ActivityView = ({ item }: ActivityViewProps) => { ); return ( - - {' '} - + + + + ) : item.managed ? ( + <>{ManagedUserLabel} + ) : ( + <> + {UnknownUserLabel} + + + ) + } + when={item.createdAt} + data-test-subj={'createdByCard'} + /> + + + {showLastUpdated && ( + + ) : item.managed ? ( + <>{ManagedUserLabel} + ) : ( + <> + {UnknownUserLabel} + + + ) } + when={item.updatedAt} + data-test-subj={'updatedByCard'} /> - - } - fullWidth - data-test-subj={'activityView'} - > - <> - - - - ) : item.managed ? ( - <>{ManagedUserLabel} - ) : ( - <> - {UnknownUserLabel} - - - ) - } - when={item.createdAt} - data-test-subj={'createdByCard'} - /> - - - {showLastUpdated && ( - - ) : item.managed ? ( - <>{ManagedUserLabel} - ) : ( - <> - {UnknownUserLabel} - - - ) - } - when={item.updatedAt} - data-test-subj={'updatedByCard'} - /> - )} - - - - + )} + + ); }; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/index.ts b/packages/content-management/content_insights/content_insights_public/src/components/index.ts new file mode 100644 index 0000000000000..b018fca6c843e --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/index.ts @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ActivityView, type ActivityViewProps } from './activity_view'; +export { ViewsStats } from './views_stats'; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts new file mode 100644 index 0000000000000..01fa00cd44537 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ViewsStats } from './views_stats'; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx new file mode 100644 index 0000000000000..ff1675744b9a6 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx @@ -0,0 +1,80 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Chart, Settings, DARK_THEME, LIGHT_THEME, BarSeries, Axis } from '@elastic/charts'; +import { formatDate, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; + +const dateFormatter = (d: Date) => formatDate(d, `MM/DD`); + +const seriesName = i18n.translate('contentManagement.contentEditor.viewsStats.viewsLabel', { + defaultMessage: 'Views', +}); + +const weekOfFormatter = (date: Date) => + i18n.translate('contentManagement.contentEditor.viewsStats.weekOfLabel', { + defaultMessage: 'Week of {date}', + values: { date: dateFormatter(date) }, + }); + +export const ViewsChart = ({ data }: { data: Array<[week: number, views: number]> }) => { + const { colorMode } = useEuiTheme(); + + const momentDow = moment().localeData().firstDayOfWeek(); // configured from advanced settings + const isoDow = momentDow === 0 ? 7 : momentDow; + + const momentTz = moment().tz(); // configured from advanced settings + + return ( + + + + + + + + ); +}; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx new file mode 100644 index 0000000000000..649c34da0f2f7 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render, within } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { ContentInsightsProvider } from '../../services'; + +import { ViewsStats } from './views_stats'; + +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-07-15T14:00:00.00Z')); +}); +afterEach(() => jest.clearAllMocks()); +afterAll(() => jest.useRealTimers()); + +const mockStats = jest.fn().mockResolvedValue({ + from: '2024-05-01T00:00:00.000Z', + count: 10, + daily: [ + { + date: '2024-05-01T00:00:00.000Z', + count: 5, + }, + { + date: '2024-06-01T00:00:00.000Z', + count: 5, + }, + ], +}); + +const WrappedViewsStats = () => { + const item = { id: '1' } as any; + const client = { + track: jest.fn(), + getStats: mockStats, + }; + return ( + + + + + + + + ); +}; + +describe('ViewsStats', () => { + test('should render the total views and chart', async () => { + const { getByTestId } = render(); + const totalViews = getByTestId('views-stats-total-views'); + expect(totalViews).toBeInTheDocument(); + await within(totalViews).findByText('Views (last 75 days)'); + await within(totalViews).findByText('10'); + }); +}); diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx new file mode 100644 index 0000000000000..15138b55ba9b5 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx @@ -0,0 +1,125 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiPanel, EuiStat, EuiSpacer, useEuiTheme, EuiIconTip } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useQuery } from '@tanstack/react-query'; +import { i18n } from '@kbn/i18n'; +import type { ContentInsightsStats } from '@kbn/content-management-content-insights-server'; +import { css } from '@emotion/react'; +import moment from 'moment'; + +import { Item } from '../../types'; +import { ViewsChart } from './views_chart'; +import { useServices } from '../../services'; + +export const ViewsStats = ({ item }: { item: Item }) => { + const contentInsightsClient = useServices()?.contentInsightsClient; + + if (!contentInsightsClient) { + throw new Error('Content insights client is not available'); + } + + const { euiTheme } = useEuiTheme(); + const { data, isLoading } = useQuery( + ['content-insights:viewed', item.id], + async () => + contentInsightsClient.getStats(item.id, 'viewed').then((response) => ({ + totalDays: getTotalDays(response), + totalViews: response.count, + chartData: getChartData(response), + })), + { + staleTime: 0, + retry: false, + } + ); + + return ( + + + + + + } + isLoading={isLoading} + /> + + + + + + ); +}; + +const NoViewsTip = () => ( + + } + /> +); + +export function getTotalDays(stats: ContentInsightsStats) { + return moment.utc().diff(moment.utc(stats.from), 'days'); +} + +export function getChartData(stats: ContentInsightsStats): Array<[week: number, views: number]> { + // prepare a map of views by week starting from the first full week till the current week + const viewsByWeek = new Map(); + + // we use moment to handle weeks because it is configured with the correct first day of the week from advanced settings + // by default it is sunday + const thisWeek = moment().startOf('week'); + const firstFullWeek = moment(stats.from).add(7, 'day').startOf('week'); + + // fill the map with weeks starting from the first full week till the current week + let current = firstFullWeek.clone(); + while (current.isSameOrBefore(thisWeek)) { + viewsByWeek.set(current.toISOString(), 0); + current = current.clone().add(1, 'week'); + } + + // fill the map with views per week + for (let i = 0; i < stats.daily.length; i++) { + const week = moment(stats.daily[i].date).startOf('week').toISOString(); + if (viewsByWeek.has(week)) { + viewsByWeek.set(week, viewsByWeek.get(week)! + stats.daily[i].count); + } + } + + return Array.from(viewsByWeek.entries()) + .sort((a, b) => (a[0] > b[0] ? 1 : -1)) + .map(([date, views]) => [new Date(date).getTime(), views]); +} diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx new file mode 100644 index 0000000000000..0ad2b32430561 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { getChartData, getTotalDays } from './views_stats'; + +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-07-15T14:00:00.00Z')); + moment.updateLocale('en', { + week: { + dow: 1, // test with Monday is the first day of the week. + }, + }); +}); +afterEach(() => jest.clearAllMocks()); +afterAll(() => jest.useRealTimers()); + +describe('getTotalDays', () => { + test('should return the total days between the current date and the from date', () => { + const totalDays = getTotalDays({ + from: '2024-07-01T00:00:00.000Z', + daily: [], + count: 0, + }); + expect(totalDays).toBe(14); + }); +}); + +describe('getChartData', () => { + test('should return views bucketed by week', () => { + const data = getChartData({ + from: '2024-05-01T00:00:00.000Z', + daily: [], + count: 0, + }); + expect(data.every(([, count]) => count === 0)).toBe(true); + + // moment is mocked with America/New_York timezone, hence +04:00 offset + expect(data.map((d) => new Date(d[0]).toISOString())).toMatchInlineSnapshot(` + Array [ + "2024-05-06T04:00:00.000Z", + "2024-05-13T04:00:00.000Z", + "2024-05-20T04:00:00.000Z", + "2024-05-27T04:00:00.000Z", + "2024-06-03T04:00:00.000Z", + "2024-06-10T04:00:00.000Z", + "2024-06-17T04:00:00.000Z", + "2024-06-24T04:00:00.000Z", + "2024-07-01T04:00:00.000Z", + "2024-07-08T04:00:00.000Z", + "2024-07-15T04:00:00.000Z", + ] + `); + }); +}); diff --git a/packages/content-management/content_insights/content_insights_public/src/services.tsx b/packages/content-management/content_insights/content_insights_public/src/services.tsx new file mode 100644 index 0000000000000..13d2ade797024 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/services.tsx @@ -0,0 +1,49 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FC, PropsWithChildren, useContext } from 'react'; +import React from 'react'; + +import { ContentInsightsClientPublic } from './client'; + +/** + * Abstract external services for this component. + */ +export interface ContentInsightsServices { + contentInsightsClient: ContentInsightsClientPublic; +} + +const ContentInsightsContext = React.createContext(null); + +/** + * Abstract external service Provider. + */ +export const ContentInsightsProvider: FC>> = ({ + children, + ...services +}) => { + if (!services.contentInsightsClient) { + return <>{children}; + } + + return ( + + {children} + + ); +}; + +/* + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(ContentInsightsContext); + return context; +} diff --git a/packages/content-management/content_insights/content_insights_public/src/types.ts b/packages/content-management/content_insights/content_insights_public/src/types.ts new file mode 100644 index 0000000000000..75e0ca561c9ae --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; + +export type Item = UserContentCommonSchema; diff --git a/packages/content-management/content_insights/content_insights_public/tsconfig.json b/packages/content-management/content_insights/content_insights_public/tsconfig.json new file mode 100644 index 0000000000000..27d479a15d6c9 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types", + "@kbn/ambient-storybook-types", + "@emotion/react/types/css-prop", + "@testing-library/jest-dom", + "@testing-library/react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/content-management-user-profiles", + "@kbn/i18n-react", + "@kbn/i18n", + "@kbn/user-profile-components", + "@kbn/core-http-browser", + "@kbn/content-management-content-insights-server", + "@kbn/content-management-table-list-view-common", + ] +} diff --git a/packages/content-management/content_insights/content_insights_server/README.md b/packages/content-management/content_insights/content_insights_server/README.md new file mode 100644 index 0000000000000..00f54612cf532 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-content-insights-server + +Refer to [README](../README.mdx) diff --git a/packages/content-management/content_insights/content_insights_server/index.ts b/packages/content-management/content_insights/content_insights_server/index.ts new file mode 100644 index 0000000000000..fe78d0eb181ae --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + registerContentInsights, + type ContentInsightsStatsResponse, + type ContentInsightsStats, +} from './src/register'; diff --git a/packages/content-management/content_insights/content_insights_server/jest.config.js b/packages/content-management/content_insights/content_insights_server/jest.config.js new file mode 100644 index 0000000000000..7761f3fba8000 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/packages/content-management/content_insights/content_insights_server'], +}; diff --git a/packages/content-management/content_insights/content_insights_server/kibana.jsonc b/packages/content-management/content_insights/content_insights_server/kibana.jsonc new file mode 100644 index 0000000000000..386c1a6bf1304 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/content-management-content-insights-server", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/content-management/content_insights/content_insights_server/package.json b/packages/content-management/content_insights/content_insights_server/package.json new file mode 100644 index 0000000000000..ff99762999828 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/content-management-content-insights-server", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/content-management/content_insights/content_insights_server/src/register.ts b/packages/content-management/content_insights/content_insights_server/src/register.ts new file mode 100644 index 0000000000000..06283cce089ac --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/src/register.ts @@ -0,0 +1,147 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + UsageCollectionSetup, + UsageCollectionStart, +} from '@kbn/usage-collection-plugin/server'; +import type { CoreSetup } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import moment from 'moment'; + +/** + * Configuration for the usage counter + */ +export interface ContentInsightsConfig { + /** + * e.g. 'dashboard' + * passed as a domainId to usage counter apis + */ + domainId: string; + + /** + * Can control created routes access via access tags + */ + routeTags?: string[]; + + /** + * Retention period in days for usage counter data + */ + retentionPeriodDays?: number; +} + +export interface ContentInsightsDependencies { + usageCollection: UsageCollectionSetup; + http: CoreSetup['http']; + getStartServices: () => Promise<{ + usageCollection: UsageCollectionStart; + }>; +} + +export interface ContentInsightsStatsResponse { + result: ContentInsightsStats; +} + +export interface ContentInsightsStats { + /** + * The date from which the data is counted + */ + from: string; + /** + * Total count of events + */ + count: number; + /** + * Daily counts of events + */ + daily: Array<{ + date: string; + count: number; + }>; +} + +/* + * Registers the content insights routes + */ +export const registerContentInsights = ( + { usageCollection, http, getStartServices }: ContentInsightsDependencies, + config: ContentInsightsConfig +) => { + const retentionPeriodDays = config.retentionPeriodDays ?? 90; + const counter = usageCollection.createUsageCounter(config.domainId, { + retentionPeriodDays, + }); + + const router = http.createRouter(); + const validate = { + params: schema.object({ + id: schema.string(), + eventType: schema.literal('viewed'), + }), + }; + router.post( + { + path: `/internal/content_management/insights/${config.domainId}/{id}/{eventType}`, + validate, + options: { + tags: config.routeTags, + }, + }, + async (context, req, res) => { + const { id, eventType } = req.params; + + counter.incrementCounter({ + counterName: id, + counterType: eventType, + namespace: (await context.core).savedObjects.client.getCurrentNamespace(), + }); + return res.ok(); + } + ); + router.get( + { + path: `/internal/content_management/insights/${config.domainId}/{id}/{eventType}/stats`, + validate, + options: { + tags: config.routeTags, + }, + }, + async (context, req, res) => { + const { id, eventType } = req.params; + const { + usageCollection: { search }, + } = await getStartServices(); + + const startOfDay = moment.utc().startOf('day'); + const from = startOfDay.clone().subtract(retentionPeriodDays, 'days'); + + const result = await search({ + filters: { + domainId: config.domainId, + counterName: id, + counterType: eventType, + namespace: (await context.core).savedObjects.client.getCurrentNamespace(), + from: from.toISOString(), + }, + }); + + const response: ContentInsightsStatsResponse = { + result: { + from: from.toISOString(), + count: result.counters[0]?.count ?? 0, + daily: (result.counters[0]?.records ?? []).map((record) => ({ + date: record.updatedAt, + count: record.count, + })), + }, + }; + + return res.ok({ body: response }); + } + ); +}; diff --git a/packages/content-management/content_insights/content_insights_server/tsconfig.json b/packages/content-management/content_insights/content_insights_server/tsconfig.json new file mode 100644 index 0000000000000..3e2312c0278a2 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/usage-collection-plugin", + "@kbn/core", + "@kbn/config-schema", + ] +} diff --git a/packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx b/packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx new file mode 100644 index 0000000000000..79a10d42f41e2 --- /dev/null +++ b/packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiFormRow, EuiIconTip, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { FC } from 'react'; +import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; +import { ActivityView, ViewsStats } from '@kbn/content-management-content-insights-public'; + +/** + * This component is used as an extension for the ContentEditor to render the ActivityView and ViewsStats inside the flyout without depending on them directly + */ +export const ContentEditorActivityRow: FC<{ item: UserContentCommonSchema }> = ({ item }) => { + return ( + + {' '} + + } + /> + + } + > + <> + + + + + + ); +}; diff --git a/packages/content-management/table_list_view_table/src/services.tsx b/packages/content-management/table_list_view_table/src/services.tsx index ebdbaf31b4f0e..b452bf916a525 100644 --- a/packages/content-management/table_list_view_table/src/services.tsx +++ b/packages/content-management/table_list_view_table/src/services.tsx @@ -14,6 +14,10 @@ import { ContentEditorKibanaProvider, type SavedObjectsReference, } from '@kbn/content-management-content-editor'; +import { + ContentInsightsClientPublic, + ContentInsightsProvider, +} from '@kbn/content-management-content-insights-public'; import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser'; import type { I18nStart } from '@kbn/core-i18n-browser'; import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser'; @@ -174,6 +178,11 @@ export interface TableListViewKibanaDependencies { * The favorites client to enable the favorites feature. */ favorites?: FavoritesClientPublic; + + /** + * Content insights client to enable content insights features. + */ + contentInsightsClient?: ContentInsightsClientPublic; } /** @@ -240,37 +249,42 @@ export const TableListViewKibanaProvider: FC< - { - notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text }); - }} - > - - application.getUrlForApp('management', { - path: `/kibana/settings?query=savedObjects:listingLimit`, - }) - } + + { notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text }); }} - searchQueryParser={searchQueryParser} - DateFormatterComp={(props) => } - currentAppId$={application.currentAppId$} - navigateToUrl={application.navigateToUrl} - isTaggingEnabled={() => Boolean(savedObjectsTagging)} - isFavoritesEnabled={() => Boolean(services.favorites)} - getTagList={getTagList} - TagList={TagList} - itemHasTags={itemHasTags} - getTagIdsFromReferences={getTagIdsFromReferences} - getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)} > - {children} - - + + application.getUrlForApp('management', { + path: `/kibana/settings?query=savedObjects:listingLimit`, + }) + } + notifyError={(title, text) => { + notifications.toasts.addDanger({ + title: toMountPoint(title, startServices), + text, + }); + }} + searchQueryParser={searchQueryParser} + DateFormatterComp={(props) => } + currentAppId$={application.currentAppId$} + navigateToUrl={application.navigateToUrl} + isTaggingEnabled={() => Boolean(savedObjectsTagging)} + isFavoritesEnabled={() => Boolean(services.favorites)} + getTagList={getTagList} + TagList={TagList} + itemHasTags={itemHasTags} + getTagIdsFromReferences={getTagIdsFromReferences} + getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)} + > + {children} + + + diff --git a/packages/content-management/table_list_view_table/src/table_list_view.test.tsx b/packages/content-management/table_list_view_table/src/table_list_view.test.tsx index e56322099d5ff..f7ad968d78965 100644 --- a/packages/content-management/table_list_view_table/src/table_list_view.test.tsx +++ b/packages/content-management/table_list_view_table/src/table_list_view.test.tsx @@ -1052,7 +1052,7 @@ describe('TableListView', () => { }); describe('search', () => { - const updatedAt = new Date('2023-07-15').toISOString(); + const updatedAt = moment('2023-07-15').toISOString(); const hits: UserContentCommonSchema[] = [ { @@ -1146,7 +1146,7 @@ describe('TableListView', () => { { id: 'item-from-search', type: 'dashboard', - updatedAt: new Date('2023-07-01').toISOString(), + updatedAt: moment('2023-07-01').toISOString(), attributes: { title: 'Item from search', }, diff --git a/packages/content-management/table_list_view_table/src/table_list_view_table.tsx b/packages/content-management/table_list_view_table/src/table_list_view_table.tsx index 82894d7d8b6ef..c15462f88b585 100644 --- a/packages/content-management/table_list_view_table/src/table_list_view_table.tsx +++ b/packages/content-management/table_list_view_table/src/table_list_view_table.tsx @@ -38,6 +38,10 @@ import type { } from '@kbn/content-management-content-editor'; import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; import type { RecentlyAccessed } from '@kbn/recently-accessed'; +import { + ContentInsightsProvider, + useContentInsightsServices, +} from '@kbn/content-management-content-insights-public'; import { Table, @@ -54,12 +58,10 @@ import { useTags } from './use_tags'; import { useInRouterContext, useUrlState } from './use_url_state'; import { RowActions, TableItemsRowActions } from './types'; import { sortByRecentlyAccessed } from './components/table_sort_select'; +import { ContentEditorActivityRow } from './components/content_editor_activity_row'; interface ContentEditorConfig - extends Pick< - OpenContentEditorParams, - 'isReadonly' | 'onSave' | 'customValidators' | 'showActivityView' - > { + extends Pick { enabled?: boolean; } @@ -371,6 +373,7 @@ function TableListViewTableComp({ } = useServices(); const openContentEditor = useOpenContentEditor(); + const contentInsightsServices = useContentInsightsServices(); const isInRouterContext = useInRouterContext(); @@ -567,6 +570,12 @@ function TableListViewTableComp({ close(); }), + appendRows: contentInsightsServices && ( + // have to "REWRAP" in the provider here because it will be rendered in a different context + + + + ), }); }, [ @@ -576,6 +585,7 @@ function TableListViewTableComp({ contentEditor, tableItemsRowActions, fetchItems, + contentInsightsServices, ] ); @@ -713,7 +723,7 @@ function TableListViewTableComp({ name: i18n.translate('contentManagement.tableList.listing.table.actionTitle', { defaultMessage: 'Actions', }), - width: `${32 * actions.length}px`, + width: `72px`, actions, }); } diff --git a/packages/content-management/table_list_view_table/tsconfig.json b/packages/content-management/table_list_view_table/tsconfig.json index 7bd513f12f99e..a5530ee717e49 100644 --- a/packages/content-management/table_list_view_table/tsconfig.json +++ b/packages/content-management/table_list_view_table/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/react-kibana-mount", "@kbn/content-management-user-profiles", "@kbn/recently-accessed", + "@kbn/content-management-content-insights-public", "@kbn/content-management-favorites-public" ], "exclude": [ diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 708e3c8aac809..158fc638adc3d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -178,6 +178,7 @@ export const initializeDashboard = async ({ query: queryService, search: { session }, }, + dashboardContentInsights, } = pluginServices.getServices(); const { queryString, @@ -635,5 +636,13 @@ export const initializeDashboard = async ({ }); } + if (loadDashboardReturn.dashboardId && !incomingEmbeddable) { + // We count a new view every time a user opens a dashboard, both in view or edit mode + // We don't count views when a user is editing a dashboard and is returning from an editor after saving + // however, there is an edge case that we now count a new view when a user is editing a dashboard and is returning from an editor by canceling + // TODO: this should be revisited by making embeddable transfer support canceling logic https://github.com/elastic/kibana/issues/190485 + dashboardContentInsights.trackDashboardView(loadDashboardReturn.dashboardId); + } + return { input: initialDashboardInput, searchSessionId: initialSearchSessionId }; }; diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx index 2904eb4648df1..1e0de2b72b2b5 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx @@ -45,6 +45,7 @@ export const DashboardListing = ({ savedObjectsTagging, coreContext: { executionContext }, userProfile, + dashboardContentInsights: { contentInsightsClient }, dashboardFavorites, } = pluginServices.getServices(); @@ -86,6 +87,7 @@ export const DashboardListing = ({ savedObjectsTagging: savedObjectsTaggingFakePlugin, FormattedRelative, favorites: dashboardFavorites, + contentInsightsClient, }} > {...tableListViewTableProps}> diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx index 07ce8eeea771f..5a0f8caee1a9b 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx @@ -47,6 +47,7 @@ export const DashboardListingTable = ({ coreContext: { executionContext }, chrome: { theme }, userProfile, + dashboardContentInsights: { contentInsightsClient }, } = pluginServices.getServices(); useExecutionContext(executionContext, { @@ -98,6 +99,7 @@ export const DashboardListingTable = ({ core={core} savedObjectsTagging={savedObjectsTaggingFakePlugin} FormattedRelative={FormattedRelative} + contentInsightsClient={contentInsightsClient} > <> { onSave: expect.any(Function), isReadonly: false, customValidators: expect.any(Object), - showActivityView: true, }, createdByEnabled: true, recentlyAccessed: expect.objectContaining({ get: expect.any(Function) }), diff --git a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx index 097fc5ea6d866..d3afbc61d2607 100644 --- a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx @@ -283,7 +283,6 @@ export const useDashboardListingTable = ({ isReadonly: !showWriteControls, onSave: updateItemMeta, customValidators: contentEditorValidators, - showActivityView: true, }, createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem, deleteItems: !showWriteControls ? undefined : deleteItems, diff --git a/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts new file mode 100644 index 0000000000000..26abd7f65c768 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import type { DashboardContentInsightsService } from './types'; + +type DashboardContentInsightsServiceFactory = PluginServiceFactory; + +export const dashboardContentInsightsServiceFactory: DashboardContentInsightsServiceFactory = + () => { + return { + trackDashboardView: jest.fn(), + contentInsightsClient: { + track: jest.fn(), + getStats: jest.fn(), + }, + }; + }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts new file mode 100644 index 0000000000000..8877589d036ea --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; +import { DashboardStartDependencies } from '../../plugin'; +import { DashboardContentInsightsService } from './types'; + +export type DashboardContentInsightsServiceFactory = KibanaPluginServiceFactory< + DashboardContentInsightsService, + DashboardStartDependencies +>; + +export const dashboardContentInsightsServiceFactory: DashboardContentInsightsServiceFactory = ( + params +) => { + const contentInsightsClient = new ContentInsightsClient( + { http: params.coreStart.http }, + { domainId: 'dashboard' } + ); + + return { + trackDashboardView: (dashboardId: string) => { + contentInsightsClient.track(dashboardId, 'viewed'); + }, + contentInsightsClient, + }; +}; diff --git a/src/plugins/dashboard/public/services/dashboard_content_insights/types.ts b/src/plugins/dashboard/public/services/dashboard_content_insights/types.ts new file mode 100644 index 0000000000000..ac709c725f879 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_insights/types.ts @@ -0,0 +1,14 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ContentInsightsClientPublic } from '@kbn/content-management-content-insights-public'; + +export interface DashboardContentInsightsService { + trackDashboardView: (dashboardId: string) => void; + contentInsightsClient: ContentInsightsClientPublic; +} diff --git a/src/plugins/dashboard/public/services/plugin_services.stub.ts b/src/plugins/dashboard/public/services/plugin_services.stub.ts index 247a3f29e35aa..1bd37c98d6ece 100644 --- a/src/plugins/dashboard/public/services/plugin_services.stub.ts +++ b/src/plugins/dashboard/public/services/plugin_services.stub.ts @@ -49,6 +49,7 @@ import { noDataPageServiceFactory } from './no_data_page/no_data_page_service.st import { uiActionsServiceFactory } from './ui_actions/ui_actions_service.stub'; import { dashboardRecentlyAccessedServiceFactory } from './dashboard_recently_accessed/dashboard_recently_accessed.stub'; import { dashboardFavoritesServiceFactory } from './dashboard_favorites/dashboard_favorites_service.stub'; +import { dashboardContentInsightsServiceFactory } from './dashboard_content_insights/dashboard_content_insights.stub'; export const providers: PluginServiceProviders = { dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory), @@ -85,6 +86,7 @@ export const providers: PluginServiceProviders = { userProfile: new PluginServiceProvider(userProfileServiceFactory), observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceStubFactory), dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedServiceFactory), + dashboardContentInsights: new PluginServiceProvider(dashboardContentInsightsServiceFactory), dashboardFavorites: new PluginServiceProvider(dashboardFavoritesServiceFactory), }; diff --git a/src/plugins/dashboard/public/services/plugin_services.ts b/src/plugins/dashboard/public/services/plugin_services.ts index 8f554d33aeea4..592b494634432 100644 --- a/src/plugins/dashboard/public/services/plugin_services.ts +++ b/src/plugins/dashboard/public/services/plugin_services.ts @@ -50,6 +50,7 @@ import { observabilityAIAssistantServiceFactory } from './observability_ai_assis import { userProfileServiceFactory } from './user_profile/user_profile_service'; import { dashboardRecentlyAccessedFactory } from './dashboard_recently_accessed/dashboard_recently_accessed'; import { dashboardFavoritesServiceFactory } from './dashboard_favorites/dashboard_favorites_service'; +import { dashboardContentInsightsServiceFactory } from './dashboard_content_insights/dashboard_content_insights_service'; const providers: PluginServiceProviders = { dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory, [ @@ -99,6 +100,7 @@ const providers: PluginServiceProviders & { @@ -85,5 +86,6 @@ export interface DashboardServices { observabilityAIAssistant: ObservabilityAIAssistantService; // TODO: make this optional in follow up userProfile: DashboardUserProfileService; dashboardRecentlyAccessed: DashboardRecentlyAccessedService; + dashboardContentInsights: DashboardContentInsightsService; dashboardFavorites: DashboardFavoritesService; } diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index e1626c2e72108..27747ca71bb8d 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -11,9 +11,10 @@ import { TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; import { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; -import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { UsageCollectionSetup, UsageCollectionStart } from '@kbn/usage-collection-plugin/server'; import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; +import { registerContentInsights } from '@kbn/content-management-content-insights-server'; import { initializeDashboardTelemetryTask, @@ -31,13 +32,14 @@ import { dashboardPersistableStateServiceFactory } from './dashboard_container/d interface SetupDeps { embeddable: EmbeddableSetup; - usageCollection: UsageCollectionSetup; + usageCollection?: UsageCollectionSetup; taskManager: TaskManagerSetupContract; contentManagement: ContentManagementServerSetup; } interface StartDeps { taskManager: TaskManagerStartContract; + usageCollection?: UsageCollectionStart; } export class DashboardPlugin @@ -83,6 +85,25 @@ export class DashboardPlugin ); } + if (plugins.usageCollection) { + // Registers routes for tracking and fetching dashboard views + registerContentInsights( + { + usageCollection: plugins.usageCollection, + http: core.http, + getStartServices: () => + core.getStartServices().then(([_, start]) => ({ + usageCollection: start.usageCollection!, + })), + }, + { + domainId: 'dashboard', + // makes sure that only users with read/all access to dashboard app can access the routes + routeTags: ['access:dashboardUsageStats'], + } + ); + } + plugins.embeddable.registerEmbeddableFactory( dashboardPersistableStateServiceFactory(plugins.embeddable) ); diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index a6011f2f67bec..f289f4f725fe5 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -83,6 +83,8 @@ "@kbn/lens-embeddable-utils", "@kbn/lens-plugin", "@kbn/recently-accessed", + "@kbn/content-management-content-insights-public", + "@kbn/content-management-content-insights-server", "@kbn/managed-content-badge", "@kbn/content-management-favorites-public", "@kbn/core-http-browser-mocks", diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index bdac41e9c04da..8de2a99d30e7d 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -28,7 +28,7 @@ export type { export { serializeCounterKey, USAGE_COUNTERS_SAVED_OBJECT_TYPE } from './usage_counters'; -export type { UsageCollectionSetup } from './plugin'; +export type { UsageCollectionSetup, UsageCollectionStart } from './plugin'; export { config } from './config'; export const plugin = async (initializerContext: PluginInitializerContext) => { const { UsageCollectionPlugin } = await import('./plugin'); diff --git a/test/functional/apps/dashboard/group4/dashboard_listing.ts b/test/functional/apps/dashboard/group4/dashboard_listing.ts index 36e99d0e5c8c1..8a5e121f659cf 100644 --- a/test/functional/apps/dashboard/group4/dashboard_listing.ts +++ b/test/functional/apps/dashboard/group4/dashboard_listing.ts @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const listingTable = getService('listingTable'); const dashboardAddPanel = getService('dashboardAddPanel'); + const testSubjects = getService('testSubjects'); describe('dashboard listing page', function describeIndexTests() { const dashboardName = 'Dashboard Listing Test'; @@ -234,5 +235,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(newPanelCount).to.equal(originalPanelCount); }); }); + + describe('insights', () => { + const DASHBOARD_NAME = 'Insights Dashboard'; + + before(async () => { + await PageObjects.dashboard.navigateToApp(); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.saveDashboard(DASHBOARD_NAME, { + saveAsNew: true, + waitDialogIsClosed: false, + exitFromEditMode: false, + }); + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('shows the insights panel and counts the views', async () => { + await listingTable.searchForItemWithName(DASHBOARD_NAME); + + async function getViewsCount() { + await listingTable.inspectVisualization(); + const totalViewsStats = await testSubjects.find('views-stats-total-views'); + const viewsStr = await ( + await totalViewsStats.findByCssSelector('.euiStat__title') + ).getVisibleText(); + await listingTable.closeInspector(); + return Number(viewsStr); + } + + const views1 = await getViewsCount(); + expect(views1).to.be(1); + + await listingTable.clickItemLink('dashboard', DASHBOARD_NAME); + await PageObjects.dashboard.waitForRenderComplete(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + const views2 = await getViewsCount(); + expect(views2).to.be(2); + }); + }); }); } diff --git a/tsconfig.base.json b/tsconfig.base.json index 5f45f95234a2b..fb98e0160cb76 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -192,6 +192,10 @@ "@kbn/console-plugin/*": ["src/plugins/console/*"], "@kbn/content-management-content-editor": ["packages/content-management/content_editor"], "@kbn/content-management-content-editor/*": ["packages/content-management/content_editor/*"], + "@kbn/content-management-content-insights-public": ["packages/content-management/content_insights/content_insights_public"], + "@kbn/content-management-content-insights-public/*": ["packages/content-management/content_insights/content_insights_public/*"], + "@kbn/content-management-content-insights-server": ["packages/content-management/content_insights/content_insights_server"], + "@kbn/content-management-content-insights-server/*": ["packages/content-management/content_insights/content_insights_server/*"], "@kbn/content-management-examples-plugin": ["examples/content_management_examples"], "@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"], "@kbn/content-management-favorites-public": ["packages/content-management/favorites/favorites_public"], diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index d2d088defd966..8f70f79435843 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -547,6 +547,7 @@ Array [ }, "api": Array [ "bulkGetUserProfiles", + "dashboardUsageStats", "store_search_session", ], "app": Array [ @@ -603,6 +604,7 @@ Array [ "privilege": Object { "api": Array [ "bulkGetUserProfiles", + "dashboardUsageStats", ], "app": Array [ "dashboards", @@ -1167,6 +1169,7 @@ Array [ }, "api": Array [ "bulkGetUserProfiles", + "dashboardUsageStats", "store_search_session", ], "app": Array [ @@ -1223,6 +1226,7 @@ Array [ "privilege": Object { "api": Array [ "bulkGetUserProfiles", + "dashboardUsageStats", ], "app": Array [ "dashboards", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index ea9b19e6b60e9..90c997352e2ba 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -209,7 +209,7 @@ export const buildOSSFeatures = ({ ], }, ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], - api: ['bulkGetUserProfiles'], + api: ['bulkGetUserProfiles', 'dashboardUsageStats'], }, read: { app: ['dashboards', 'kibana'], @@ -230,7 +230,7 @@ export const buildOSSFeatures = ({ ], }, ui: ['show'], - api: ['bulkGetUserProfiles'], + api: ['bulkGetUserProfiles', 'dashboardUsageStats'], }, }, subFeatures: [ diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 9d1c16977a97c..94e34a5f25a7a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -455,7 +455,6 @@ "console.welcomePage.useVariables.step2": "Invoquez les variables dans les chemins et corps de vos requêtes autant de fois que souhaité.", "console.welcomePage.useVariablesDescription": "Définissez les variables dans la Console, puis utilisez-les dans vos requêtes sous la forme {variableName}.", "console.welcomePage.useVariablesTitle": "Réutiliser les valeurs avec les variables", - "contentManagement.contentEditor.activity.activityLabelHelpText": "Les données liées à l'activité sont générées automatiquement et ne peuvent pas être mises à jour.", "contentManagement.contentEditor.activity.createdByLabelText": "Créé par", "contentManagement.contentEditor.activity.lastUpdatedByDateTime": "le {dateTime}", "contentManagement.contentEditor.activity.lastUpdatedByLabelText": "Dernière mise à jour par", @@ -464,7 +463,6 @@ "contentManagement.contentEditor.cancelButtonLabel": "Annuler", "contentManagement.contentEditor.flyoutTitle": "Détails de {entityName}", "contentManagement.contentEditor.flyoutWarningsTitle": "Continuez avec prudence !", - "contentManagement.contentEditor.metadataForm.activityLabel": "Activité", "contentManagement.contentEditor.metadataForm.descriptionInputLabel": "Description", "contentManagement.contentEditor.metadataForm.nameInputLabel": "Nom", "contentManagement.contentEditor.metadataForm.nameIsEmptyError": "Nom obligatoire.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 238691c13a078..efa3a915278df 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -455,7 +455,6 @@ "console.welcomePage.useVariables.step2": "任意の回数だけリクエストのパスと本文で変数を参照します。", "console.welcomePage.useVariablesDescription": "コンソールで変数を定義し、{variableName}の形式でリクエストで使用します。", "console.welcomePage.useVariablesTitle": "変数で値を再利用", - "contentManagement.contentEditor.activity.activityLabelHelpText": "アクティビティデータは自動生成されるため、更新できません。", "contentManagement.contentEditor.activity.createdByLabelText": "作成者", "contentManagement.contentEditor.activity.lastUpdatedByDateTime": "{dateTime}に", "contentManagement.contentEditor.activity.lastUpdatedByLabelText": "最終更新者", @@ -464,7 +463,6 @@ "contentManagement.contentEditor.cancelButtonLabel": "キャンセル", "contentManagement.contentEditor.flyoutTitle": "{entityName}詳細", "contentManagement.contentEditor.flyoutWarningsTitle": "十分ご注意ください!", - "contentManagement.contentEditor.metadataForm.activityLabel": "アクティビティ", "contentManagement.contentEditor.metadataForm.descriptionInputLabel": "説明", "contentManagement.contentEditor.metadataForm.nameInputLabel": "名前", "contentManagement.contentEditor.metadataForm.nameIsEmptyError": "名前が必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3b88e7dfcb306..dbf1786160150 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -454,7 +454,6 @@ "console.welcomePage.useVariables.step2": "请参阅请求路径和正文中的变量,无论多少次均可。", "console.welcomePage.useVariablesDescription": "在控制台中定义变量,然后在请求中以 {variableName} 的形式使用它们。", "console.welcomePage.useVariablesTitle": "重复使用包含变量的值", - "contentManagement.contentEditor.activity.activityLabelHelpText": "活动数据将自动生成并且无法进行更新。", "contentManagement.contentEditor.activity.createdByLabelText": "创建者", "contentManagement.contentEditor.activity.lastUpdatedByLabelText": "最后更新者", "contentManagement.contentEditor.activity.managedUserLabel": "系统", @@ -462,7 +461,6 @@ "contentManagement.contentEditor.cancelButtonLabel": "取消", "contentManagement.contentEditor.flyoutTitle": "{entityName} 详情", "contentManagement.contentEditor.flyoutWarningsTitle": "谨慎操作!", - "contentManagement.contentEditor.metadataForm.activityLabel": "活动", "contentManagement.contentEditor.metadataForm.descriptionInputLabel": "描述", "contentManagement.contentEditor.metadataForm.nameInputLabel": "名称", "contentManagement.contentEditor.metadataForm.nameIsEmptyError": "名称必填。", diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts index 0b1f665fa7f72..329b9be0de561 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts @@ -1918,6 +1918,7 @@ export default function ({ getService }: FtrProviderContext) { "all": Array [ "login:", "api:bulkGetUserProfiles", + "api:dashboardUsageStats", "api:store_search_session", "api:generateReport", "api:downloadCsv", @@ -2100,6 +2101,7 @@ export default function ({ getService }: FtrProviderContext) { "minimal_all": Array [ "login:", "api:bulkGetUserProfiles", + "api:dashboardUsageStats", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -2251,6 +2253,7 @@ export default function ({ getService }: FtrProviderContext) { "minimal_read": Array [ "login:", "api:bulkGetUserProfiles", + "api:dashboardUsageStats", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -2349,6 +2352,7 @@ export default function ({ getService }: FtrProviderContext) { "read": Array [ "login:", "api:bulkGetUserProfiles", + "api:dashboardUsageStats", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", diff --git a/yarn.lock b/yarn.lock index ab818bc120054..baad89f95f4a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3680,6 +3680,14 @@ version "0.0.0" uid "" +"@kbn/content-management-content-insights-public@link:packages/content-management/content_insights/content_insights_public": + version "0.0.0" + uid "" + +"@kbn/content-management-content-insights-server@link:packages/content-management/content_insights/content_insights_server": + version "0.0.0" + uid "" + "@kbn/content-management-examples-plugin@link:examples/content_management_examples": version "0.0.0" uid "" From f3142bacae143410398bbc33e90f739f35dab778 Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Wed, 21 Aug 2024 13:35:22 -0500 Subject: [PATCH 07/45] Update dependency chromedriver to ^127.0.3 (main) (#191019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [chromedriver](https://togithub.com/giggio/node-chromedriver) | devDependencies | patch | [`^127.0.2` -> `^127.0.3`](https://renovatebot.com/diffs/npm/chromedriver/127.0.3/127.0.3) | --- ### 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 [Renovate Bot](https://togithub.com/renovatebot/renovate). Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9ff4ac1707b18..ee6ebf60ce187 100644 --- a/package.json +++ b/package.json @@ -1637,7 +1637,7 @@ "buildkite-test-collector": "^1.7.0", "callsites": "^3.1.0", "chance": "1.0.18", - "chromedriver": "^127.0.2", + "chromedriver": "^127.0.3", "clean-webpack-plugin": "^3.0.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.1", diff --git a/yarn.lock b/yarn.lock index baad89f95f4a0..73366acb9d613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14198,7 +14198,7 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^127.0.2: +chromedriver@^127.0.3: version "127.0.3" resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-127.0.3.tgz#33abca5924eb809e0e6ef2dd30eaa8cf7dba58d4" integrity sha512-trUHkFt0n7jGzNOgkO1srOJfz50kKyAGJ016PyV0hrtyKNIGnOC9r3Jlssz19UoEjSzI/1g2shEiIFtDbBYVaw== From 748326d1e22371560ebf8a6ed09692fe25dfb932 Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Wed, 21 Aug 2024 20:47:29 +0200 Subject: [PATCH 08/45] Context for tooling logs (#190766) ## Summary Allows to set context for the tooling logger. The context would be logged before the log output, like so: ``` info [My-Tool] Hello! ``` Split from: https://github.com/elastic/kibana/pull/190401 --------- Co-authored-by: Tiago Costa --- packages/kbn-dev-cli-runner/src/run.ts | 18 ++++++++----- .../__snapshots__/tooling_log.test.ts.snap | 19 ++++++++++++++ packages/kbn-tooling-log/src/message.ts | 2 ++ packages/kbn-tooling-log/src/tooling_log.ts | 26 +++++++++++++------ .../src/tooling_log_collecting_writer.ts | 16 ++++++------ .../src/tooling_log_text_writer.ts | 3 ++- 6 files changed, 61 insertions(+), 23 deletions(-) diff --git a/packages/kbn-dev-cli-runner/src/run.ts b/packages/kbn-dev-cli-runner/src/run.ts index d84c7b9a4edf2..8440bd9ea6b57 100644 --- a/packages/kbn-dev-cli-runner/src/run.ts +++ b/packages/kbn-dev-cli-runner/src/run.ts @@ -31,18 +31,24 @@ export interface RunOptions { description?: string; log?: { defaultLevel?: LogLevel; + context?: string; }; flags?: FlagOptions; } export async function run(fn: RunFn, options: RunOptions = {}): Promise { const flags = getFlags(process.argv.slice(2), options.flags, options.log?.defaultLevel); - const log = new ToolingLog({ - level: pickLevelFromFlags(flags, { - default: options.log?.defaultLevel, - }), - writeTo: process.stdout, - }); + const log = new ToolingLog( + { + level: pickLevelFromFlags(flags, { + default: options.log?.defaultLevel, + }), + writeTo: process.stdout, + }, + { + context: options.log?.context, + } + ); const metrics = new Metrics(log); const helpText = getHelp({ diff --git a/packages/kbn-tooling-log/src/__snapshots__/tooling_log.test.ts.snap b/packages/kbn-tooling-log/src/__snapshots__/tooling_log.test.ts.snap index 7742c2bb681d0..de1d9c02f024e 100644 --- a/packages/kbn-tooling-log/src/__snapshots__/tooling_log.test.ts.snap +++ b/packages/kbn-tooling-log/src/__snapshots__/tooling_log.test.ts.snap @@ -9,6 +9,7 @@ Array [ "bar", "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "debug", @@ -24,6 +25,7 @@ Array [ "args": Array [ [Error: error message], ], + "context": undefined, "indent": 0, "source": undefined, "type": "error", @@ -34,6 +36,7 @@ Array [ "args": Array [ "string message", ], + "context": undefined, "indent": 0, "source": undefined, "type": "error", @@ -52,6 +55,7 @@ Array [ "args": Array [ "foo", ], + "context": undefined, "indent": 0, "source": undefined, "type": "debug", @@ -60,6 +64,7 @@ Array [ "args": Array [ "bar", ], + "context": undefined, "indent": 0, "source": undefined, "type": "info", @@ -68,6 +73,7 @@ Array [ "args": Array [ "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "verbose", @@ -81,6 +87,7 @@ Array [ "args": Array [ "foo", ], + "context": undefined, "indent": 0, "source": undefined, "type": "debug", @@ -89,6 +96,7 @@ Array [ "args": Array [ "bar", ], + "context": undefined, "indent": 0, "source": undefined, "type": "info", @@ -97,6 +105,7 @@ Array [ "args": Array [ "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "verbose", @@ -111,6 +120,7 @@ Array [ "args": Array [ "foo", ], + "context": undefined, "indent": 1, "source": undefined, "type": "debug", @@ -121,6 +131,7 @@ Array [ "args": Array [ "bar", ], + "context": undefined, "indent": 3, "source": undefined, "type": "debug", @@ -131,6 +142,7 @@ Array [ "args": Array [ "baz", ], + "context": undefined, "indent": 6, "source": undefined, "type": "debug", @@ -141,6 +153,7 @@ Array [ "args": Array [ "box", ], + "context": undefined, "indent": 4, "source": undefined, "type": "debug", @@ -151,6 +164,7 @@ Array [ "args": Array [ "foo", ], + "context": undefined, "indent": 0, "source": undefined, "type": "debug", @@ -168,6 +182,7 @@ Array [ "bar", "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "info", @@ -185,6 +200,7 @@ Array [ "bar", "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "success", @@ -202,6 +218,7 @@ Array [ "bar", "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "verbose", @@ -219,6 +236,7 @@ Array [ "bar", "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "warning", @@ -236,6 +254,7 @@ Array [ "bar", "baz", ], + "context": undefined, "indent": 0, "source": undefined, "type": "write", diff --git a/packages/kbn-tooling-log/src/message.ts b/packages/kbn-tooling-log/src/message.ts index 082c0e65d48b2..df8485d906337 100644 --- a/packages/kbn-tooling-log/src/message.ts +++ b/packages/kbn-tooling-log/src/message.ts @@ -20,4 +20,6 @@ export interface Message { source?: string; /** args passed to the logging method */ args: any[]; + /** an identifier of the logging entity */ + context?: string; } diff --git a/packages/kbn-tooling-log/src/tooling_log.ts b/packages/kbn-tooling-log/src/tooling_log.ts index bef4320be2356..a3e124892a033 100644 --- a/packages/kbn-tooling-log/src/tooling_log.ts +++ b/packages/kbn-tooling-log/src/tooling_log.ts @@ -25,6 +25,13 @@ export interface ToolingLogOptions { * writers on either log will update the other too. */ parent?: ToolingLog; + + /** + * A string, conveniently the name of the script, + * that will be prepended to log messages. + * Can be useful to identify which entity is emitting the log. + */ + context?: string; } export class ToolingLog implements SomeDevLog { @@ -32,6 +39,7 @@ export class ToolingLog implements SomeDevLog { private writers$: Rx.BehaviorSubject; private readonly written$: Rx.Subject; private readonly type: string | undefined; + private readonly context: string | undefined; constructor(writerConfig?: ToolingLogTextWriterConfig, options?: ToolingLogOptions) { this.indentWidth$ = options?.parent ? options.parent.indentWidth$ : new Rx.BehaviorSubject(0); @@ -45,6 +53,7 @@ export class ToolingLog implements SomeDevLog { this.written$ = options?.parent ? options.parent.written$ : new Rx.Subject(); this.type = options?.type; + this.context = options?.context; } /** @@ -93,31 +102,31 @@ export class ToolingLog implements SomeDevLog { } public verbose(...args: any[]) { - this.sendToWriters('verbose', args); + this.sendToWriters({ type: 'verbose', context: this.context }, args); } public debug(...args: any[]) { - this.sendToWriters('debug', args); + this.sendToWriters({ type: 'debug', context: this.context }, args); } public info(...args: any[]) { - this.sendToWriters('info', args); + this.sendToWriters({ type: 'info', context: this.context }, args); } public success(...args: any[]) { - this.sendToWriters('success', args); + this.sendToWriters({ type: 'success', context: this.context }, args); } public warning(...args: any[]) { - this.sendToWriters('warning', args); + this.sendToWriters({ type: 'warning', context: this.context }, args); } public error(error: Error | string) { - this.sendToWriters('error', [error]); + this.sendToWriters({ type: 'error', context: this.context }, [error]); } public write(...args: any[]) { - this.sendToWriters('write', args); + this.sendToWriters({ type: 'write' }, args); } public getWriters() { @@ -143,7 +152,7 @@ export class ToolingLog implements SomeDevLog { }); } - private sendToWriters(type: MessageTypes, args: any[]) { + private sendToWriters({ type, context }: { type: MessageTypes; context?: string }, args: any[]) { const indent = this.indentWidth$.getValue(); const writers = this.writers$.getValue(); const msg: Message = { @@ -151,6 +160,7 @@ export class ToolingLog implements SomeDevLog { indent, source: this.type, args, + context, }; let written = false; diff --git a/packages/kbn-tooling-log/src/tooling_log_collecting_writer.ts b/packages/kbn-tooling-log/src/tooling_log_collecting_writer.ts index 6f73563f4a2c5..5550d86181446 100644 --- a/packages/kbn-tooling-log/src/tooling_log_collecting_writer.ts +++ b/packages/kbn-tooling-log/src/tooling_log_collecting_writer.ts @@ -26,16 +26,16 @@ export class ToolingLogCollectingWriter extends ToolingLogTextWriter { } /** - * Called by ToolingLog, extends messages with the source if message includes one. + * Called by ToolingLog, extends messages with the source and context if message include it. */ write(msg: Message) { - if (msg.source) { - return super.write({ - ...msg, - args: [`source[${msg.source}]`, ...msg.args], - }); - } + const args = [ + msg.source ? `source[${msg.source}]` : null, + msg.context ? `context[${msg.context}]` : null, + ] + .filter(Boolean) + .concat(msg.args); - return super.write(msg); + return super.write({ ...msg, args }); } } diff --git a/packages/kbn-tooling-log/src/tooling_log_text_writer.ts b/packages/kbn-tooling-log/src/tooling_log_text_writer.ts index 063edd75a14cb..5d097d23de818 100644 --- a/packages/kbn-tooling-log/src/tooling_log_text_writer.ts +++ b/packages/kbn-tooling-log/src/tooling_log_text_writer.ts @@ -103,7 +103,8 @@ export class ToolingLogTextWriter implements Writer { } } - const prefix = has(MSG_PREFIXES, msg.type) ? MSG_PREFIXES[msg.type] : ''; + let prefix = has(MSG_PREFIXES, msg.type) ? MSG_PREFIXES[msg.type] : ''; + prefix = msg.context ? prefix + `[${msg.context}] ` : prefix; ToolingLogTextWriter.write(this.writeTo, prefix, msg); return true; } From be2f7f414dfa4ae791bef01120e461bc8298f430 Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Wed, 21 Aug 2024 20:59:33 +0200 Subject: [PATCH 09/45] [CI] Log error on fast-async-transform failure (#191012) ## Summary A missed catch+log during the fast transformation of typescript files to js files caused the task pooling library, `piscina` to terminate without a warning. The terminated workers would crash with unexpected messages, and that resulted in original errors to be lost, and misleading error messages. This PR adds some logging. See: https://buildkite.com/elastic/kibana-pull-request/builds/228663#019170d4-4f00-4ccc-915e-7d8b0a6ca020 - the original causes were incorrect `await` usages, but that was never revealed, because we only got the messages that arrived to a terminated worker. See also, for more background: https://elastic.slack.com/archives/C5UDAFZQU/p1724236149083029 --- packages/kbn-babel-transform/fast_async_transformer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kbn-babel-transform/fast_async_transformer.js b/packages/kbn-babel-transform/fast_async_transformer.js index fad1a1762ba4d..a9793e9faa0d9 100644 --- a/packages/kbn-babel-transform/fast_async_transformer.js +++ b/packages/kbn-babel-transform/fast_async_transformer.js @@ -39,6 +39,8 @@ async function withFastAsyncTransform(config, block) { try { await block(transform); success = true; + } catch (e) { + console.error('Error during transformation', e); } finally { try { await pool.destroy(); From 385281b2a6c9f12531c7672d2cb56468af080b62 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 21 Aug 2024 12:24:14 -0700 Subject: [PATCH 10/45] [DOCS] Improve connector privateKey and certificateAuthoritiesData setting examples (#190932) --- .../pre-configured-connectors.asciidoc | 27 ++++++++++++++----- docs/settings/alert-action-settings.asciidoc | 9 ++++--- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index b7ae9feac2d6f..8f9536331bb1c 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -513,14 +513,19 @@ xpack.actions.preconfigured: jwtKeyId: fedcbazyxwvutsrqponmlkjihgfedcba <4> secrets: clientSecret: secretsecret <5> - privateKey: -----BEGIN RSA PRIVATE KEY-----\nprivatekeyhere\n-----END RSA PRIVATE KEY----- <6> + privateKey: | <6> + -----BEGIN RSA PRIVATE KEY----- + MIIE... + KAgD... + ... multiple lines of key data ... + -----END RSA PRIVATE KEY----- -- <1> Specifies whether the connector uses basic or OAuth authentication. <2> The user identifier. <3> The client identifier assigned to your OAuth application. <4> The key identifier assigned to the JWT verifier map of your OAuth application. <5> The client secret assigned to your OAuth application. -<6> The RSA private key. If it has a password, you must also provide `privateKeyPassword`. +<6> The RSA private key in multiline format. If it has a password, you must also provide `privateKeyPassword`. [float] [[preconfigured-servicenow-configuration]] @@ -563,14 +568,19 @@ xpack.actions.preconfigured: jwtKeyId: fedcbazyxwvutsrqponmlkjihgfedcba <4> secrets: clientSecret: secretsecret <5> - privateKey: -----BEGIN RSA PRIVATE KEY-----\nprivatekeyhere\n-----END RSA PRIVATE KEY----- <6> + privateKey: | <6> + -----BEGIN RSA PRIVATE KEY----- + MIIE... + KAgD... + ... multiple lines of key data ... + -----END RSA PRIVATE KEY----- -- <1> Specifies whether the connector uses basic or OAuth authentication. <2> The user identifier. <3> The client identifier assigned to your OAuth application. <4> The key ID assigned to the JWT verifier map of your OAuth application. <5> The client secret assigned to the OAuth application. -<6> The RSA private key. If it has a password, you must also provide `privateKeyPassword`. +<6> The RSA private key in multiline format. If it has a password, you must also provide `privateKeyPassword`. [float] [[preconfigured-servicenow-sir-configuration]] @@ -613,14 +623,19 @@ xpack.actions.preconfigured: jwtKeyId: fedcbazyxwvutsrqponmlkjihgfedcba <4> secrets: clientSecret: secretsecret <5> - privateKey: -----BEGIN RSA PRIVATE KEY-----\nprivatekeyhere\n-----END RSA PRIVATE KEY----- <6> + privateKey: | <6> + -----BEGIN RSA PRIVATE KEY----- + MIIE... + KAgD... + ... multiple lines of key data ... + -----END RSA PRIVATE KEY----- -- <1> Specifies whether the connector uses basic or OAuth authentication. <2> The user identifier. <3> The client identifier assigned to the OAuth application. <4> The key ID assigned to the JWT verifier map of your OAuth application. <5> The client secret assigned to the OAuth application. -<6> The RSA private key. If it has a password, you must also specify +<6> The RSA private key in multiline format. If it has a password, you must also specify `privateKeyPassword`. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index d41871917c4a1..c688f933b0ff1 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -67,7 +67,9 @@ xpack.actions.customHostSettings: certificateAuthoritiesFiles: [ 'one.crt' ] certificateAuthoritiesData: | -----BEGIN CERTIFICATE----- - ... multiple lines of certificate data here ... + MIIDTD... + CwUAMD... + ... multiple lines of certificate data ... -----END CERTIFICATE----- smtp: requireTLS: true @@ -125,9 +127,8 @@ A file name or list of file names of PEM-encoded certificate files to use to validate the server. `xpack.actions.customHostSettings[n].ssl.certificateAuthoritiesData` {ess-icon}:: -The contents of a PEM-encoded certificate file, or multiple files appended -into a single string. This configuration can be used for environments where -the files cannot be made available. +The contents of one or more PEM-encoded certificate files in multiline format. +This configuration can be used for environments where the files cannot be made available. [[action-config-email-domain-allowlist]] `xpack.actions.email.domain_allowlist` {ess-icon}:: A list of allowed email domains which can be used with the email connector. When this setting is not used, all email domains are allowed. When this setting is used, if any email is attempted to be sent that (a) includes an addressee with an email domain that is not in the allowlist, or (b) includes a from address domain that is not in the allowlist, it will fail with a message indicating the email is not allowed. From 24b4eb4d18102077255d05cecde96485795e71b0 Mon Sep 17 00:00:00 2001 From: Saikat Sarkar <132922331+saikatsarkar056@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:34:08 -0600 Subject: [PATCH 11/45] Revert "Revert "[Index management] Unskip api_integration tests for inference endpoints"" (#190855) Reverts elastic/kibana#189742 --- .../apis/management/index_management/inference_endpoints.ts | 4 +--- .../common/index_management/inference_endpoints.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/x-pack/test/api_integration/apis/management/index_management/inference_endpoints.ts b/x-pack/test/api_integration/apis/management/index_management/inference_endpoints.ts index 4960d9aad5c4f..586c546414850 100644 --- a/x-pack/test/api_integration/apis/management/index_management/inference_endpoints.ts +++ b/x-pack/test/api_integration/apis/management/index_management/inference_endpoints.ts @@ -20,9 +20,7 @@ export default function ({ getService }: FtrProviderContext) { const service = 'elser'; const modelId = '.elser_model_2'; - // FLAKY: https://github.com/elastic/kibana/issues/189333 - // Failing: See https://github.com/elastic/kibana/issues/189333 - describe.skip('Inference endpoints', function () { + describe('Inference endpoints', function () { after(async () => { try { log.debug(`Deleting underlying trained model`); diff --git a/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts b/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts index 5828d5cc8fa59..f5f712fc7d5a1 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts @@ -26,8 +26,7 @@ export default function ({ getService }: FtrProviderContext) { let roleAuthc: RoleCredentials; let internalReqHeader: InternalRequestHeader; - // FLAKY: https://github.com/elastic/kibana/issues/189464 - describe.skip('Inference endpoints', function () { + describe('Inference endpoints', function () { before(async () => { roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); internalReqHeader = svlCommonApi.getInternalRequestHeader(); From ca6f3edf0d757a6edfcc9e8a4e68415760bbbf53 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 21 Aug 2024 16:34:33 -0400 Subject: [PATCH 12/45] feat(rca): items management api (#190852) --- packages/kbn-investigation-shared/index.ts | 20 +--- .../kbn-investigation-shared/src/index.ts | 10 ++ .../src/{schema => rest_specs}/create.ts | 9 +- .../src/rest_specs/create_item.ts | 28 +++++ .../create_note.ts} | 11 +- .../src/{schema => rest_specs}/delete.ts | 4 +- .../src/rest_specs/delete_item.ts | 23 ++++ .../src/{schema => rest_specs}/delete_note.ts | 4 +- .../src/{schema => rest_specs}/find.ts | 4 +- .../src/{schema => rest_specs}/get.ts | 2 +- .../src/rest_specs/get_items.ts | 23 ++++ .../src/{schema => rest_specs}/get_notes.ts | 2 +- .../src/rest_specs/index.ts | 31 +++++ .../src/rest_specs/investigation.ts | 17 +++ .../src/rest_specs/investigation_item.ts | 17 +++ .../src/rest_specs/investigation_note.ts | 17 +++ .../src/schema/index.ts | 12 ++ .../src/schema/investigation.ts | 13 +-- .../src/schema/investigation_item.ts | 27 +++++ .../src/schema/investigation_note.ts | 7 +- .../investigate/common/types.ts | 8 +- .../use_investigation/regenerate_item.ts | 5 +- .../investigate_widget_grid/index.stories.tsx | 3 - .../investigate_widget_grid/index.tsx | 2 - .../hooks/use_add_investigation_note.ts | 4 +- .../investigation_details/index.tsx | 4 - .../server/models/investigation.ts | 16 +-- .../server/models/investigation_item.ts | 12 ++ .../server/models/investigation_note.ts | 8 +- ...investigate_app_server_route_repository.ts | 108 ++++++++++++++---- .../server/services/create_investigation.ts | 5 +- .../services/create_investigation_item.ts | 34 ++++++ .../services/create_investigation_note.ts | 6 +- .../server/services/delete_investigation.ts | 4 +- .../services/delete_investigation_item.ts | 28 +++++ .../server/services/get_investigation.ts | 2 +- .../services/get_investigation_items.ts | 21 ++++ .../services/investigation_repository.ts | 5 +- .../hooks/use_create_investigation.tsx | 4 +- 39 files changed, 434 insertions(+), 126 deletions(-) create mode 100644 packages/kbn-investigation-shared/src/index.ts rename packages/kbn-investigation-shared/src/{schema => rest_specs}/create.ts (70%) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/create_item.ts rename packages/kbn-investigation-shared/src/{schema/create_notes.ts => rest_specs/create_note.ts} (79%) rename packages/kbn-investigation-shared/src/{schema => rest_specs}/delete.ts (87%) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/delete_item.ts rename packages/kbn-investigation-shared/src/{schema => rest_specs}/delete_note.ts (92%) rename packages/kbn-investigation-shared/src/{schema => rest_specs}/find.ts (89%) rename packages/kbn-investigation-shared/src/{schema => rest_specs}/get.ts (96%) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/get_items.ts rename packages/kbn-investigation-shared/src/{schema => rest_specs}/get_notes.ts (96%) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/index.ts create mode 100644 packages/kbn-investigation-shared/src/rest_specs/investigation.ts create mode 100644 packages/kbn-investigation-shared/src/rest_specs/investigation_item.ts create mode 100644 packages/kbn-investigation-shared/src/rest_specs/investigation_note.ts create mode 100644 packages/kbn-investigation-shared/src/schema/index.ts create mode 100644 packages/kbn-investigation-shared/src/schema/investigation_item.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/models/investigation_item.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation_items.ts diff --git a/packages/kbn-investigation-shared/index.ts b/packages/kbn-investigation-shared/index.ts index c5f92248adb6b..de0577ee3ed83 100644 --- a/packages/kbn-investigation-shared/index.ts +++ b/packages/kbn-investigation-shared/index.ts @@ -6,22 +6,4 @@ * Side Public License, v 1. */ -export type * from './src/schema/create'; -export type * from './src/schema/create_notes'; -export type * from './src/schema/delete'; -export type * from './src/schema/find'; -export type * from './src/schema/get'; -export type * from './src/schema/get_notes'; -export type * from './src/schema/origin'; -export type * from './src/schema/delete_note'; -export type * from './src/schema/investigation_note'; - -export * from './src/schema/create'; -export * from './src/schema/create_notes'; -export * from './src/schema/delete'; -export * from './src/schema/find'; -export * from './src/schema/get'; -export * from './src/schema/get_notes'; -export * from './src/schema/origin'; -export * from './src/schema/delete_note'; -export * from './src/schema/investigation_note'; +export * from './src'; diff --git a/packages/kbn-investigation-shared/src/index.ts b/packages/kbn-investigation-shared/src/index.ts new file mode 100644 index 0000000000000..cf2c6a5e96f54 --- /dev/null +++ b/packages/kbn-investigation-shared/src/index.ts @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './rest_specs'; +export * from './schema'; diff --git a/packages/kbn-investigation-shared/src/schema/create.ts b/packages/kbn-investigation-shared/src/rest_specs/create.ts similarity index 70% rename from packages/kbn-investigation-shared/src/schema/create.ts rename to packages/kbn-investigation-shared/src/rest_specs/create.ts index 99073087d0b43..cab2de27d5eb7 100644 --- a/packages/kbn-investigation-shared/src/schema/create.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/create.ts @@ -8,7 +8,7 @@ import * as t from 'io-ts'; import { investigationResponseSchema } from './investigation'; -import { alertOriginSchema, blankOriginSchema } from './origin'; +import { alertOriginSchema, blankOriginSchema } from '../schema'; const createInvestigationParamsSchema = t.type({ body: t.type({ @@ -23,9 +23,8 @@ const createInvestigationParamsSchema = t.type({ const createInvestigationResponseSchema = investigationResponseSchema; -type CreateInvestigationInput = t.OutputOf; // Raw payload sent by the frontend -type CreateInvestigationParams = t.TypeOf; // Parsed payload used by the backend -type CreateInvestigationResponse = t.OutputOf; // Raw response sent to the frontend +type CreateInvestigationParams = t.TypeOf; +type CreateInvestigationResponse = t.OutputOf; export { createInvestigationParamsSchema, createInvestigationResponseSchema }; -export type { CreateInvestigationInput, CreateInvestigationParams, CreateInvestigationResponse }; +export type { CreateInvestigationParams, CreateInvestigationResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/create_item.ts b/packages/kbn-investigation-shared/src/rest_specs/create_item.ts new file mode 100644 index 0000000000000..c94673313a50c --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/create_item.ts @@ -0,0 +1,28 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { investigationItemsSchema } from '../schema'; +import { investigationItemResponseSchema } from './investigation_item'; + +const createInvestigationItemParamsSchema = t.type({ + path: t.type({ + investigationId: t.string, + }), + body: investigationItemsSchema, +}); + +const createInvestigationItemResponseSchema = investigationItemResponseSchema; + +type CreateInvestigationItemParams = t.TypeOf< + typeof createInvestigationItemParamsSchema.props.body +>; +type CreateInvestigationItemResponse = t.OutputOf; + +export { createInvestigationItemParamsSchema, createInvestigationItemResponseSchema }; +export type { CreateInvestigationItemParams, CreateInvestigationItemResponse }; diff --git a/packages/kbn-investigation-shared/src/schema/create_notes.ts b/packages/kbn-investigation-shared/src/rest_specs/create_note.ts similarity index 79% rename from packages/kbn-investigation-shared/src/schema/create_notes.ts rename to packages/kbn-investigation-shared/src/rest_specs/create_note.ts index a920a41108e51..0132b86093185 100644 --- a/packages/kbn-investigation-shared/src/schema/create_notes.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/create_note.ts @@ -11,7 +11,7 @@ import { investigationNoteResponseSchema } from './investigation_note'; const createInvestigationNoteParamsSchema = t.type({ path: t.type({ - id: t.string, + investigationId: t.string, }), body: t.type({ content: t.string, @@ -20,17 +20,10 @@ const createInvestigationNoteParamsSchema = t.type({ const createInvestigationNoteResponseSchema = investigationNoteResponseSchema; -type CreateInvestigationNoteInput = t.OutputOf< - typeof createInvestigationNoteParamsSchema.props.body ->; type CreateInvestigationNoteParams = t.TypeOf< typeof createInvestigationNoteParamsSchema.props.body >; type CreateInvestigationNoteResponse = t.OutputOf; export { createInvestigationNoteParamsSchema, createInvestigationNoteResponseSchema }; -export type { - CreateInvestigationNoteInput, - CreateInvestigationNoteParams, - CreateInvestigationNoteResponse, -}; +export type { CreateInvestigationNoteParams, CreateInvestigationNoteResponse }; diff --git a/packages/kbn-investigation-shared/src/schema/delete.ts b/packages/kbn-investigation-shared/src/rest_specs/delete.ts similarity index 87% rename from packages/kbn-investigation-shared/src/schema/delete.ts rename to packages/kbn-investigation-shared/src/rest_specs/delete.ts index de0381a6161f3..52df3b250775d 100644 --- a/packages/kbn-investigation-shared/src/schema/delete.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/delete.ts @@ -10,11 +10,11 @@ import * as t from 'io-ts'; const deleteInvestigationParamsSchema = t.type({ path: t.type({ - id: t.string, + investigationId: t.string, }), }); -type DeleteInvestigationParams = t.TypeOf; // Parsed payload used by the backend +type DeleteInvestigationParams = t.TypeOf; export { deleteInvestigationParamsSchema }; export type { DeleteInvestigationParams }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/delete_item.ts b/packages/kbn-investigation-shared/src/rest_specs/delete_item.ts new file mode 100644 index 0000000000000..14ac6f763ce38 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/delete_item.ts @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +const deleteInvestigationItemParamsSchema = t.type({ + path: t.type({ + investigationId: t.string, + itemId: t.string, + }), +}); + +type DeleteInvestigationItemParams = t.TypeOf< + typeof deleteInvestigationItemParamsSchema.props.path +>; + +export { deleteInvestigationItemParamsSchema }; +export type { DeleteInvestigationItemParams }; diff --git a/packages/kbn-investigation-shared/src/schema/delete_note.ts b/packages/kbn-investigation-shared/src/rest_specs/delete_note.ts similarity index 92% rename from packages/kbn-investigation-shared/src/schema/delete_note.ts rename to packages/kbn-investigation-shared/src/rest_specs/delete_note.ts index 9af910b277756..80cae4a982721 100644 --- a/packages/kbn-investigation-shared/src/schema/delete_note.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/delete_note.ts @@ -10,14 +10,14 @@ import * as t from 'io-ts'; const deleteInvestigationNoteParamsSchema = t.type({ path: t.type({ - id: t.string, + investigationId: t.string, noteId: t.string, }), }); type DeleteInvestigationNoteParams = t.TypeOf< typeof deleteInvestigationNoteParamsSchema.props.path ->; // Parsed payload used by the backend +>; export { deleteInvestigationNoteParamsSchema }; export type { DeleteInvestigationNoteParams }; diff --git a/packages/kbn-investigation-shared/src/schema/find.ts b/packages/kbn-investigation-shared/src/rest_specs/find.ts similarity index 89% rename from packages/kbn-investigation-shared/src/schema/find.ts rename to packages/kbn-investigation-shared/src/rest_specs/find.ts index 048a2f01c064a..0bbb853117138 100644 --- a/packages/kbn-investigation-shared/src/schema/find.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/find.ts @@ -24,8 +24,8 @@ const findInvestigationsResponseSchema = t.type({ results: t.array(investigationResponseSchema), }); -type FindInvestigationsParams = t.TypeOf; // Parsed payload used by the backend -type FindInvestigationsResponse = t.OutputOf; // Raw response sent to the frontend +type FindInvestigationsParams = t.TypeOf; +type FindInvestigationsResponse = t.OutputOf; export { findInvestigationsParamsSchema, findInvestigationsResponseSchema }; export type { FindInvestigationsParams, FindInvestigationsResponse }; diff --git a/packages/kbn-investigation-shared/src/schema/get.ts b/packages/kbn-investigation-shared/src/rest_specs/get.ts similarity index 96% rename from packages/kbn-investigation-shared/src/schema/get.ts rename to packages/kbn-investigation-shared/src/rest_specs/get.ts index 6e2b7d6063ff1..f0cc850116087 100644 --- a/packages/kbn-investigation-shared/src/schema/get.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/get.ts @@ -11,7 +11,7 @@ import { investigationResponseSchema } from './investigation'; const getInvestigationParamsSchema = t.type({ path: t.type({ - id: t.string, + investigationId: t.string, }), }); diff --git a/packages/kbn-investigation-shared/src/rest_specs/get_items.ts b/packages/kbn-investigation-shared/src/rest_specs/get_items.ts new file mode 100644 index 0000000000000..dde19ceda9243 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/get_items.ts @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { investigationItemResponseSchema } from './investigation_item'; + +const getInvestigationItemsParamsSchema = t.type({ + path: t.type({ + investigationId: t.string, + }), +}); + +const getInvestigationItemsResponseSchema = t.array(investigationItemResponseSchema); + +type GetInvestigationItemsResponse = t.OutputOf; + +export { getInvestigationItemsParamsSchema, getInvestigationItemsResponseSchema }; +export type { GetInvestigationItemsResponse }; diff --git a/packages/kbn-investigation-shared/src/schema/get_notes.ts b/packages/kbn-investigation-shared/src/rest_specs/get_notes.ts similarity index 96% rename from packages/kbn-investigation-shared/src/schema/get_notes.ts rename to packages/kbn-investigation-shared/src/rest_specs/get_notes.ts index 6162d270a3439..7cd9aa1f3fe9c 100644 --- a/packages/kbn-investigation-shared/src/schema/get_notes.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/get_notes.ts @@ -11,7 +11,7 @@ import { investigationNoteResponseSchema } from './investigation_note'; const getInvestigationNotesParamsSchema = t.type({ path: t.type({ - id: t.string, + investigationId: t.string, }), }); diff --git a/packages/kbn-investigation-shared/src/rest_specs/index.ts b/packages/kbn-investigation-shared/src/rest_specs/index.ts new file mode 100644 index 0000000000000..50c1e300cd96a --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/index.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type * from './create'; +export type * from './create_note'; +export type * from './delete'; +export type * from './find'; +export type * from './get'; +export type * from './get_notes'; +export type * from './delete_note'; +export type * from './investigation_note'; +export type * from './create_item'; +export type * from './delete_item'; +export type * from './get_items'; + +export * from './create'; +export * from './create_note'; +export * from './delete'; +export * from './find'; +export * from './get'; +export * from './get_notes'; +export * from './delete_note'; +export * from './investigation_note'; +export * from './create_item'; +export * from './delete_item'; +export * from './get_items'; diff --git a/packages/kbn-investigation-shared/src/rest_specs/investigation.ts b/packages/kbn-investigation-shared/src/rest_specs/investigation.ts new file mode 100644 index 0000000000000..c2530ff0dc9a4 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/investigation.ts @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { investigationSchema } from '../schema'; + +const investigationResponseSchema = investigationSchema; + +type InvestigationResponse = t.OutputOf; + +export { investigationResponseSchema }; +export type { InvestigationResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/investigation_item.ts b/packages/kbn-investigation-shared/src/rest_specs/investigation_item.ts new file mode 100644 index 0000000000000..df9ec315e3277 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/investigation_item.ts @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { investigationItemSchema } from '../schema'; + +const investigationItemResponseSchema = investigationItemSchema; + +type InvestigationItemResponse = t.OutputOf; + +export { investigationItemResponseSchema }; +export type { InvestigationItemResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/investigation_note.ts b/packages/kbn-investigation-shared/src/rest_specs/investigation_note.ts new file mode 100644 index 0000000000000..5e6a15b327d8a --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/investigation_note.ts @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { investigationNoteSchema } from '../schema'; + +const investigationNoteResponseSchema = investigationNoteSchema; + +type InvestigationNoteResponse = t.OutputOf; + +export { investigationNoteResponseSchema }; +export type { InvestigationNoteResponse }; diff --git a/packages/kbn-investigation-shared/src/schema/index.ts b/packages/kbn-investigation-shared/src/schema/index.ts new file mode 100644 index 0000000000000..d6c017c963e7c --- /dev/null +++ b/packages/kbn-investigation-shared/src/schema/index.ts @@ -0,0 +1,12 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './investigation'; +export * from './investigation_item'; +export * from './investigation_note'; +export * from './origin'; diff --git a/packages/kbn-investigation-shared/src/schema/investigation.ts b/packages/kbn-investigation-shared/src/schema/investigation.ts index d47db8283d02c..299d077d71e6c 100644 --- a/packages/kbn-investigation-shared/src/schema/investigation.ts +++ b/packages/kbn-investigation-shared/src/schema/investigation.ts @@ -8,9 +8,10 @@ import * as t from 'io-ts'; import { alertOriginSchema, blankOriginSchema } from './origin'; -import { investigationNoteResponseSchema } from './investigation_note'; +import { investigationNoteSchema } from './investigation_note'; +import { investigationItemSchema } from './investigation_item'; -const investigationResponseSchema = t.type({ +const investigationSchema = t.type({ id: t.string, title: t.string, createdAt: t.number, @@ -20,10 +21,8 @@ const investigationResponseSchema = t.type({ }), origin: t.union([alertOriginSchema, blankOriginSchema]), status: t.union([t.literal('ongoing'), t.literal('closed')]), - notes: t.array(investigationNoteResponseSchema), + notes: t.array(investigationNoteSchema), + items: t.array(investigationItemSchema), }); -type InvestigationResponse = t.OutputOf; - -export { investigationResponseSchema }; -export type { InvestigationResponse }; +export { investigationSchema }; diff --git a/packages/kbn-investigation-shared/src/schema/investigation_item.ts b/packages/kbn-investigation-shared/src/schema/investigation_item.ts new file mode 100644 index 0000000000000..8689224960c52 --- /dev/null +++ b/packages/kbn-investigation-shared/src/schema/investigation_item.ts @@ -0,0 +1,27 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +const esqlItemSchema = t.type({ + title: t.string, + type: t.literal('esql'), + params: t.type({ + esql: t.string, + suggestion: t.any, + }), +}); + +const investigationItemsSchema = esqlItemSchema; // replace with union with various item types + +const investigationItemSchema = t.intersection([ + t.type({ id: t.string, createdAt: t.number, createdBy: t.string }), + investigationItemsSchema, +]); + +export { investigationItemSchema, investigationItemsSchema, esqlItemSchema }; diff --git a/packages/kbn-investigation-shared/src/schema/investigation_note.ts b/packages/kbn-investigation-shared/src/schema/investigation_note.ts index f678a70cb929c..53ecd11c77bb4 100644 --- a/packages/kbn-investigation-shared/src/schema/investigation_note.ts +++ b/packages/kbn-investigation-shared/src/schema/investigation_note.ts @@ -8,14 +8,11 @@ import * as t from 'io-ts'; -const investigationNoteResponseSchema = t.type({ +const investigationNoteSchema = t.type({ id: t.string, content: t.string, createdAt: t.number, createdBy: t.string, }); -type InvestigationNoteResponse = t.OutputOf; - -export { investigationNoteResponseSchema }; -export type { InvestigationNoteResponse }; +export { investigationNoteSchema }; diff --git a/x-pack/plugins/observability_solution/investigate/common/types.ts b/x-pack/plugins/observability_solution/investigate/common/types.ts index c31f432f19809..8a2bba966ed7e 100644 --- a/x-pack/plugins/observability_solution/investigate/common/types.ts +++ b/x-pack/plugins/observability_solution/investigate/common/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { AuthenticatedUser } from '@kbn/core/public'; import type { DeepPartial } from 'utility-types'; export interface GlobalWidgetParameters { @@ -36,13 +35,12 @@ export interface InvestigateWidget< TData extends Record = {} > { id: string; - created: number; - last_updated: number; + createdAt: number; + createdBy: string; + title: string; type: string; - user: AuthenticatedUser; parameters: GlobalWidgetParameters & TParameters; data: TData; - title: string; } export type InvestigateWidgetCreate = {}> = Pick< diff --git a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/regenerate_item.ts b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/regenerate_item.ts index 816220f0c72ce..7f7d6208ed9eb 100644 --- a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/regenerate_item.ts +++ b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/regenerate_item.ts @@ -42,12 +42,11 @@ export async function regenerateItem({ }); return { - created: now, + createdAt: now, id: v4(), ...widget, parameters: nextParameters, data: widgetData, - user, - last_updated: now, + createdBy: user.username, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.stories.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.stories.tsx index 836a3df552773..072d25b1e5526 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.stories.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.stories.tsx @@ -25,9 +25,6 @@ function WithPersistedChanges(props: React.ComponentProps) { return ( { - setItems(() => nextItems); - }} onItemCopy={async (item) => { setItems((prevItems) => prevItems.concat({ diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx index d05a0274f3f97..41c3b3a3e8b66 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx @@ -20,14 +20,12 @@ export interface InvestigateWidgetGridItem { interface InvestigateWidgetGridProps { items: InvestigateWidgetGridItem[]; - onItemsChange: (items: InvestigateWidgetGridItem[]) => Promise; onItemCopy: (item: InvestigateWidgetGridItem) => Promise; onItemDelete: (item: InvestigateWidgetGridItem) => Promise; } export function InvestigateWidgetGrid({ items, - onItemsChange, onItemDelete, onItemCopy, }: InvestigateWidgetGridProps) { diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts index 60c6c15b5e31c..1797174a6f4fa 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts @@ -7,7 +7,7 @@ import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; import { - CreateInvestigationNoteInput, + CreateInvestigationNoteParams, CreateInvestigationNoteResponse, } from '@kbn/investigation-shared'; import { useMutation } from '@tanstack/react-query'; @@ -26,7 +26,7 @@ export function useAddInvestigationNote() { return useMutation< CreateInvestigationNoteResponse, ServerError, - { investigationId: string; note: CreateInvestigationNoteInput }, + { investigationId: string; note: CreateInvestigationNoteParams }, { investigationId: string } >( ['addInvestigationNote'], diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx index 06dcc21fd05c7..c1c77ef564545 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx @@ -7,7 +7,6 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { noop } from 'lodash'; import React from 'react'; import useAsync from 'react-use/lib/useAsync'; import { AddObservationUI } from '../../../../components/add_observation_ui'; @@ -81,9 +80,6 @@ function InvestigationDetailsWithUser({ { - noop(); - }} onItemCopy={async (copiedItem) => { return copyItem(copiedItem.id); }} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/models/investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/models/investigation.ts index 9b66a71ce3a9b..8b826d0ed02ea 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/models/investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/models/investigation.ts @@ -5,22 +5,8 @@ * 2.0. */ -import { alertOriginSchema, blankOriginSchema } from '@kbn/investigation-shared'; +import { investigationSchema } from '@kbn/investigation-shared'; import * as t from 'io-ts'; -import { investigationNoteSchema } from './investigation_note'; - -export const investigationSchema = t.type({ - id: t.string, - title: t.string, - createdAt: t.number, - createdBy: t.string, - params: t.type({ - timeRange: t.type({ from: t.number, to: t.number }), - }), - origin: t.union([alertOriginSchema, blankOriginSchema]), - status: t.union([t.literal('ongoing'), t.literal('closed')]), - notes: t.array(investigationNoteSchema), -}); export type Investigation = t.TypeOf; export type StoredInvestigation = t.OutputOf; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_item.ts new file mode 100644 index 0000000000000..897bf4bff1f0c --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_item.ts @@ -0,0 +1,12 @@ +/* + * 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 * as t from 'io-ts'; +import { investigationItemSchema } from '@kbn/investigation-shared'; + +export type InvestigationItem = t.TypeOf; +export type StoredInvestigationItem = t.OutputOf; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_note.ts index d94ec1a94c108..0c3b996bfffe3 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/models/investigation_note.ts @@ -6,13 +6,7 @@ */ import * as t from 'io-ts'; - -export const investigationNoteSchema = t.type({ - id: t.string, - createdAt: t.number, - createdBy: t.string, - content: t.string, -}); +import { investigationNoteSchema } from '@kbn/investigation-shared'; export type InvestigationNote = t.TypeOf; export type StoredInvestigationNote = t.OutputOf; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts index 1edd6fb6c3c8f..0829a11762160 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts @@ -6,23 +6,29 @@ */ import { + createInvestigationItemParamsSchema, createInvestigationNoteParamsSchema, createInvestigationParamsSchema, + deleteInvestigationItemParamsSchema, deleteInvestigationNoteParamsSchema, deleteInvestigationParamsSchema, findInvestigationsParamsSchema, + getInvestigationItemsParamsSchema, getInvestigationNotesParamsSchema, getInvestigationParamsSchema, } from '@kbn/investigation-shared'; import { createInvestigation } from '../services/create_investigation'; +import { createInvestigationItem } from '../services/create_investigation_item'; import { createInvestigationNote } from '../services/create_investigation_note'; import { deleteInvestigation } from '../services/delete_investigation'; +import { deleteInvestigationItem } from '../services/delete_investigation_item'; +import { deleteInvestigationNote } from '../services/delete_investigation_note'; import { findInvestigations } from '../services/find_investigations'; import { getInvestigation } from '../services/get_investigation'; import { getInvestigationNotes } from '../services/get_investigation_notes'; import { investigationRepositoryFactory } from '../services/investigation_repository'; import { createInvestigateAppServerRoute } from './create_investigate_app_server_route'; -import { deleteInvestigationNote } from '../services/delete_investigation_note'; +import { getInvestigationItems } from '../services/get_investigation_items'; const createInvestigationRoute = createInvestigateAppServerRoute({ endpoint: 'POST /api/observability/investigations 2023-10-31', @@ -57,35 +63,35 @@ const findInvestigationsRoute = createInvestigateAppServerRoute({ }); const getInvestigationRoute = createInvestigateAppServerRoute({ - endpoint: 'GET /api/observability/investigations/{id} 2023-10-31', + endpoint: 'GET /api/observability/investigations/{investigationId} 2023-10-31', options: { tags: [], }, params: getInvestigationParamsSchema, - handler: async (params) => { - const soClient = (await params.context.core).savedObjects.client; - const repository = investigationRepositoryFactory({ soClient, logger: params.logger }); + handler: async ({ params, context, logger }) => { + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); - return await getInvestigation(params.params.path, repository); + return await getInvestigation(params.path, repository); }, }); const deleteInvestigationRoute = createInvestigateAppServerRoute({ - endpoint: 'DELETE /api/observability/investigations/{id} 2023-10-31', + endpoint: 'DELETE /api/observability/investigations/{investigationId} 2023-10-31', options: { tags: [], }, params: deleteInvestigationParamsSchema, - handler: async (params) => { - const soClient = (await params.context.core).savedObjects.client; - const repository = investigationRepositoryFactory({ soClient, logger: params.logger }); + handler: async ({ params, context, logger }) => { + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); - return await deleteInvestigation(params.params.path.id, repository); + return await deleteInvestigation(params.path.investigationId, repository); }, }); const createInvestigationNoteRoute = createInvestigateAppServerRoute({ - endpoint: 'POST /api/observability/investigations/{id}/notes 2023-10-31', + endpoint: 'POST /api/observability/investigations/{investigationId}/notes 2023-10-31', options: { tags: [], }, @@ -98,26 +104,29 @@ const createInvestigationNoteRoute = createInvestigateAppServerRoute({ const soClient = (await context.core).savedObjects.client; const repository = investigationRepositoryFactory({ soClient, logger }); - return await createInvestigationNote(params.path.id, params.body, { repository, user }); + return await createInvestigationNote(params.path.investigationId, params.body, { + repository, + user, + }); }, }); const getInvestigationNotesRoute = createInvestigateAppServerRoute({ - endpoint: 'GET /api/observability/investigations/{id}/notes 2023-10-31', + endpoint: 'GET /api/observability/investigations/{investigationId}/notes 2023-10-31', options: { tags: [], }, params: getInvestigationNotesParamsSchema, - handler: async (params) => { - const soClient = (await params.context.core).savedObjects.client; - const repository = investigationRepositoryFactory({ soClient, logger: params.logger }); + handler: async ({ params, context, request, logger }) => { + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); - return await getInvestigationNotes(params.params.path.id, repository); + return await getInvestigationNotes(params.path.investigationId, repository); }, }); const deleteInvestigationNotesRoute = createInvestigateAppServerRoute({ - endpoint: 'DELETE /api/observability/investigations/{id}/notes/{noteId} 2023-10-31', + endpoint: 'DELETE /api/observability/investigations/{investigationId}/notes/{noteId} 2023-10-31', options: { tags: [], }, @@ -130,7 +139,63 @@ const deleteInvestigationNotesRoute = createInvestigateAppServerRoute({ const soClient = (await context.core).savedObjects.client; const repository = investigationRepositoryFactory({ soClient, logger }); - return await deleteInvestigationNote(params.path.id, params.path.noteId, { + return await deleteInvestigationNote(params.path.investigationId, params.path.noteId, { + repository, + user, + }); + }, +}); + +const createInvestigationItemRoute = createInvestigateAppServerRoute({ + endpoint: 'POST /api/observability/investigations/{investigationId}/items 2023-10-31', + options: { + tags: [], + }, + params: createInvestigationItemParamsSchema, + handler: async ({ params, context, request, logger }) => { + const user = (await context.core).coreStart.security.authc.getCurrentUser(request); + if (!user) { + throw new Error('User is not authenticated'); + } + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); + + return await createInvestigationItem(params.path.investigationId, params.body, { + repository, + user, + }); + }, +}); + +const getInvestigationItemsRoute = createInvestigateAppServerRoute({ + endpoint: 'GET /api/observability/investigations/{investigationId}/items 2023-10-31', + options: { + tags: [], + }, + params: getInvestigationItemsParamsSchema, + handler: async ({ params, context, request, logger }) => { + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); + + return await getInvestigationItems(params.path.investigationId, repository); + }, +}); + +const deleteInvestigationItemRoute = createInvestigateAppServerRoute({ + endpoint: 'DELETE /api/observability/investigations/{investigationId}/items/{itemId} 2023-10-31', + options: { + tags: [], + }, + params: deleteInvestigationItemParamsSchema, + handler: async ({ params, context, request, logger }) => { + const user = (await context.core).coreStart.security.authc.getCurrentUser(request); + if (!user) { + throw new Error('User is not authenticated'); + } + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); + + return await deleteInvestigationItem(params.path.investigationId, params.path.itemId, { repository, user, }); @@ -146,6 +211,9 @@ export function getGlobalInvestigateAppServerRouteRepository() { ...createInvestigationNoteRoute, ...getInvestigationNotesRoute, ...deleteInvestigationNotesRoute, + ...createInvestigationItemRoute, + ...deleteInvestigationItemRoute, + ...getInvestigationItemsRoute, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts index 2bd08c531b9ab..3942b10eab171 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CreateInvestigationInput, CreateInvestigationResponse } from '@kbn/investigation-shared'; +import { CreateInvestigationParams, CreateInvestigationResponse } from '@kbn/investigation-shared'; import type { AuthenticatedUser } from '@kbn/core-security-common'; import { InvestigationRepository } from './investigation_repository'; @@ -15,7 +15,7 @@ enum InvestigationStatus { } export async function createInvestigation( - params: CreateInvestigationInput, + params: CreateInvestigationParams, { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } ): Promise { const investigation = { @@ -24,6 +24,7 @@ export async function createInvestigation( createdBy: user.username, status: InvestigationStatus.ongoing, notes: [], + items: [], }; await repository.save(investigation); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts new file mode 100644 index 0000000000000..1ed6f1289280b --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts @@ -0,0 +1,34 @@ +/* + * 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 type { AuthenticatedUser } from '@kbn/core-security-common'; +import { + CreateInvestigationItemParams, + CreateInvestigationItemResponse, +} from '@kbn/investigation-shared'; +import { v4 } from 'uuid'; +import { InvestigationRepository } from './investigation_repository'; + +export async function createInvestigationItem( + investigationId: string, + params: CreateInvestigationItemParams, + { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } +): Promise { + const investigation = await repository.findById(investigationId); + + const investigationItem = { + id: v4(), + createdBy: user.username, + createdAt: Date.now(), + ...params, + }; + investigation.items.push(investigationItem); + + await repository.save(investigation); + + return investigationItem; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts index 7928da61b3a8b..9ce727c0f2e08 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts @@ -5,17 +5,17 @@ * 2.0. */ +import type { AuthenticatedUser } from '@kbn/core-security-common'; import { - CreateInvestigationNoteInput, + CreateInvestigationNoteParams, CreateInvestigationNoteResponse, } from '@kbn/investigation-shared'; import { v4 } from 'uuid'; -import type { AuthenticatedUser } from '@kbn/core-security-common'; import { InvestigationRepository } from './investigation_repository'; export async function createInvestigationNote( investigationId: string, - params: CreateInvestigationNoteInput, + params: CreateInvestigationNoteParams, { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } ): Promise { const investigation = await repository.findById(investigationId); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation.ts index f615a6fb61a33..0a90782d1a522 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation.ts @@ -8,8 +8,8 @@ import { InvestigationRepository } from './investigation_repository'; export async function deleteInvestigation( - id: string, + investigationId: string, repository: InvestigationRepository ): Promise { - await repository.deleteById(id); + await repository.deleteById(investigationId); } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts new file mode 100644 index 0000000000000..d40938804badc --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts @@ -0,0 +1,28 @@ +/* + * 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 type { AuthenticatedUser } from '@kbn/core-security-common'; +import { InvestigationRepository } from './investigation_repository'; + +export async function deleteInvestigationItem( + investigationId: string, + itemId: string, + { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } +): Promise { + const investigation = await repository.findById(investigationId); + const item = investigation.items.find((currItem) => currItem.id === itemId); + if (!item) { + throw new Error('Note not found'); + } + + if (item.createdBy !== user.username) { + throw new Error('User does not have permission to delete note'); + } + + investigation.items = investigation.items.filter((currItem) => currItem.id !== itemId); + await repository.save(investigation); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation.ts index 1aed642da756d..f1d3c52661cdb 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation.ts @@ -16,7 +16,7 @@ export async function getInvestigation( params: GetInvestigationParams, repository: InvestigationRepository ): Promise { - const investigation = await repository.findById(params.id); + const investigation = await repository.findById(params.investigationId); return getInvestigationResponseSchema.encode(investigation); } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation_items.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation_items.ts new file mode 100644 index 0000000000000..35c3e6e742705 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_investigation_items.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 { + GetInvestigationItemsResponse, + getInvestigationItemsResponseSchema, +} from '@kbn/investigation-shared'; +import { InvestigationRepository } from './investigation_repository'; + +export async function getInvestigationItems( + investigationId: string, + repository: InvestigationRepository +): Promise { + const investigation = await repository.findById(investigationId); + + return getInvestigationItemsResponseSchema.encode(investigation.items); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts index 090930351fc14..ec25dbfd47d06 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts @@ -6,10 +6,11 @@ */ import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { investigationSchema } from '@kbn/investigation-shared'; import { isLeft } from 'fp-ts/lib/Either'; -import { Investigation, StoredInvestigation, investigationSchema } from '../models/investigation'; -import { SO_INVESTIGATION_TYPE } from '../saved_objects/investigation'; +import { Investigation, StoredInvestigation } from '../models/investigation'; import { Paginated, Pagination } from '../models/pagination'; +import { SO_INVESTIGATION_TYPE } from '../saved_objects/investigation'; export interface InvestigationRepository { save(investigation: Investigation): Promise; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/hooks/use_create_investigation.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/hooks/use_create_investigation.tsx index 11a797b775577..428e94ef15b15 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/hooks/use_create_investigation.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/hooks/use_create_investigation.tsx @@ -8,7 +8,7 @@ import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { - CreateInvestigationInput, + CreateInvestigationParams, CreateInvestigationResponse, FindInvestigationsResponse, } from '@kbn/investigation-shared'; @@ -26,7 +26,7 @@ export function useCreateInvestigation() { return useMutation< CreateInvestigationResponse, ServerError, - { investigation: CreateInvestigationInput }, + { investigation: CreateInvestigationParams }, { previousData?: FindInvestigationsResponse; queryKey?: QueryKey } >( ['createInvestigation'], From a25433c394a15afd1e56ab62c3e49ba592c79804 Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Wed, 21 Aug 2024 23:10:41 +0200 Subject: [PATCH 13/45] [CI] Parallelize quick checks (#190401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Use typescript's async processes to start quick checks in parallel 👍 Check out these for runs: - happy case: https://buildkite.com/elastic/kibana-pull-request/builds/227443#01914ca3-1f0d-4178-b539-263fbc588e98 - some broken checks: https://buildkite.com/elastic/kibana-pull-request/builds/228957#01917607-f7bd-4e08-8c70-7fdc3f9c12d1 Benefits: - with this (+more CPU) we can speed up the quick-check step's runtime, from ~15m to ~7m. - the added benefit is that all checks run so that we won't bail on the 1st one Disadvantage: - uglier error output, since we collect the logs asynchronously, and print it only upon failure - ~no output printed for happy checks (can be changed)~ Extra: - additionally, `yarn quick-checks` will now allow devs to run these checks locally (adjustments made so that the checks won't fail in local dev) - added the option to declare a 'context' for tooling loggers, so we can identify which script logs Solves 2/3 of https://github.com/elastic/kibana-operations/issues/124 (+speedup) --- .buildkite/pipelines/on_merge.yml | 2 +- .buildkite/pipelines/pull_request/base.yml | 2 +- .buildkite/scripts/common/util.sh | 2 +- .buildkite/scripts/steps/checks/event_log.sh | 20 +- .../scripts/steps/checks/quick_checks.txt | 19 ++ .buildkite/scripts/steps/quick_checks.sh | 26 +-- package.json | 1 + scripts/quick_checks.js | 10 + src/dev/run_quick_checks.ts | 196 ++++++++++++++++++ 9 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 .buildkite/scripts/steps/checks/quick_checks.txt create mode 100644 scripts/quick_checks.js create mode 100644 src/dev/run_quick_checks.ts diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 4eb15c16970ef..7719b338b13ef 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -37,7 +37,7 @@ steps: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp - machineType: n2-standard-2 + machineType: n2-highcpu-8 preemptible: true key: quick_checks timeout_in_minutes: 60 diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index e5da8ce788e5b..2f2e0a739a304 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -23,7 +23,7 @@ steps: - command: .buildkite/scripts/steps/quick_checks.sh label: 'Quick Checks' agents: - machineType: n2-standard-2 + machineType: n2-highcpu-8 preemptible: true key: quick_checks timeout_in_minutes: 60 diff --git a/.buildkite/scripts/common/util.sh b/.buildkite/scripts/common/util.sh index d50fafad6967b..dce418180c107 100755 --- a/.buildkite/scripts/common/util.sh +++ b/.buildkite/scripts/common/util.sh @@ -12,7 +12,7 @@ is_pr_with_label() { IFS=',' read -ra labels <<< "${GITHUB_PR_LABELS:-}" - for label in "${labels[@]}" + for label in "${labels[@]:-}" do if [ "$label" == "$match" ]; then return diff --git a/.buildkite/scripts/steps/checks/event_log.sh b/.buildkite/scripts/steps/checks/event_log.sh index dc9c01902c010..930908bb2d0ec 100755 --- a/.buildkite/scripts/steps/checks/event_log.sh +++ b/.buildkite/scripts/steps/checks/event_log.sh @@ -8,7 +8,25 @@ echo --- Check Event Log Schema # event log schema is pinned to a specific version of ECS ECS_STABLE_VERSION=1.8 -git clone --depth 1 -b $ECS_STABLE_VERSION https://github.com/elastic/ecs.git ../ecs + +# we can potentially skip this check on a local env, if ../ecs is present, and modified by the developer +if [[ "${CI:-false}" =~ ^(0|false)$ ]] && [[ -d '../ecs' ]]; then + LOCAL_ECS_BRANCH=$(git -C ../ecs branch --show-current) + if [[ "$LOCAL_ECS_BRANCH" != "$ECS_STABLE_VERSION" ]]; then + echo "Skipping event log schema check because ECS schema is not on $ECS_STABLE_VERSION." + exit 0 + fi + + TOUCHED_FILES=$(git -C ../ecs status --porcelain) + if [[ -n "$TOUCHED_FILES" ]]; then + echo "Skipping event log schema check because ECS schema files have been modified." + exit 0 + fi + + echo "../ecs is already cloned and @ $ECS_STABLE_VERSION" +else + git clone --depth 1 -b $ECS_STABLE_VERSION https://github.com/elastic/ecs.git ../ecs +fi node x-pack/plugins/event_log/scripts/create_schemas.js diff --git a/.buildkite/scripts/steps/checks/quick_checks.txt b/.buildkite/scripts/steps/checks/quick_checks.txt new file mode 100644 index 0000000000000..028aa47ff9567 --- /dev/null +++ b/.buildkite/scripts/steps/checks/quick_checks.txt @@ -0,0 +1,19 @@ +.buildkite/scripts/steps/checks/precommit_hook.sh +.buildkite/scripts/steps/checks/ts_projects.sh +.buildkite/scripts/steps/checks/packages.sh +.buildkite/scripts/steps/checks/bazel_packages.sh +.buildkite/scripts/steps/checks/verify_notice.sh +.buildkite/scripts/steps/checks/plugin_list_docs.sh +.buildkite/scripts/steps/checks/event_log.sh +.buildkite/scripts/steps/checks/telemetry.sh +.buildkite/scripts/steps/checks/jest_configs.sh +.buildkite/scripts/steps/checks/bundle_limits.sh +.buildkite/scripts/steps/checks/i18n.sh +.buildkite/scripts/steps/checks/file_casing.sh +.buildkite/scripts/steps/checks/licenses.sh +.buildkite/scripts/steps/checks/test_projects.sh +.buildkite/scripts/steps/checks/test_hardening.sh +.buildkite/scripts/steps/checks/ftr_configs.sh +.buildkite/scripts/steps/checks/yarn_deduplicate.sh +.buildkite/scripts/steps/checks/prettier_topology.sh +.buildkite/scripts/steps/checks/renovate.sh diff --git a/.buildkite/scripts/steps/quick_checks.sh b/.buildkite/scripts/steps/quick_checks.sh index eb14209f97f5a..1b1613d42dc8d 100755 --- a/.buildkite/scripts/steps/quick_checks.sh +++ b/.buildkite/scripts/steps/quick_checks.sh @@ -2,25 +2,9 @@ set -euo pipefail -export DISABLE_BOOTSTRAP_VALIDATION=false -.buildkite/scripts/bootstrap.sh +if [[ "${CI:-}" =~ ^(1|true)$ ]]; then + export DISABLE_BOOTSTRAP_VALIDATION=false + .buildkite/scripts/bootstrap.sh +fi -.buildkite/scripts/steps/checks/precommit_hook.sh -.buildkite/scripts/steps/checks/ts_projects.sh -.buildkite/scripts/steps/checks/packages.sh -.buildkite/scripts/steps/checks/bazel_packages.sh -.buildkite/scripts/steps/checks/verify_notice.sh -.buildkite/scripts/steps/checks/plugin_list_docs.sh -.buildkite/scripts/steps/checks/event_log.sh -.buildkite/scripts/steps/checks/telemetry.sh -.buildkite/scripts/steps/checks/jest_configs.sh -.buildkite/scripts/steps/checks/bundle_limits.sh -.buildkite/scripts/steps/checks/i18n.sh -.buildkite/scripts/steps/checks/file_casing.sh -.buildkite/scripts/steps/checks/licenses.sh -.buildkite/scripts/steps/checks/test_projects.sh -.buildkite/scripts/steps/checks/test_hardening.sh -.buildkite/scripts/steps/checks/ftr_configs.sh -.buildkite/scripts/steps/checks/yarn_deduplicate.sh -.buildkite/scripts/steps/checks/prettier_topology.sh -.buildkite/scripts/steps/checks/renovate.sh +node scripts/quick_checks --file .buildkite/scripts/steps/checks/quick_checks.txt diff --git a/package.json b/package.json index ee6ebf60ce187..0b93cb96d4e47 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "lint:es": "node scripts/eslint", "lint:style": "node scripts/stylelint", "makelogs": "node scripts/makelogs", + "quick-checks": "node scripts/quick_checks", "serverless": "node scripts/kibana --dev --serverless", "serverless-es": "node scripts/kibana --dev --serverless=es", "serverless-oblt": "node scripts/kibana --dev --serverless=oblt", diff --git a/scripts/quick_checks.js b/scripts/quick_checks.js new file mode 100644 index 0000000000000..db6a3b1daeef4 --- /dev/null +++ b/scripts/quick_checks.js @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +require('../src/setup_node_env'); +require('../src/dev/run_quick_checks'); diff --git a/src/dev/run_quick_checks.ts b/src/dev/run_quick_checks.ts new file mode 100644 index 0000000000000..cdb59bdce3cb2 --- /dev/null +++ b/src/dev/run_quick_checks.ts @@ -0,0 +1,196 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { exec } from 'child_process'; +import { availableParallelism } from 'os'; +import { join, isAbsolute } from 'path'; +import { readdirSync, readFileSync } from 'fs'; + +import { run, RunOptions } from '@kbn/dev-cli-runner'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { ToolingLog } from '@kbn/tooling-log'; + +const MAX_PARALLELISM = availableParallelism(); +const buildkiteQuickchecksFolder = join('.buildkite', 'scripts', 'steps', 'checks'); +const quickChecksList = join(buildkiteQuickchecksFolder, 'quick_checks.txt'); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface CheckResult { + success: boolean; + script: string; + output: string; + durationMs: number; +} + +const scriptOptions: RunOptions = { + description: ` + Runs sanity-testing quick-checks in parallel. + - arguments (--file, --dir, --checks) are exclusive - only one can be used at a time. + `, + flags: { + string: ['dir', 'checks', 'file'], + help: ` + --file Run all checks from a given file. (default='${quickChecksList}') + --dir Run all checks in a given directory. + --checks Runs all scripts given in this parameter. (comma or newline delimited) + `, + }, + log: { + context: 'quick-checks', + defaultLevel: process.env.CI === 'true' ? 'debug' : 'info', + }, +}; + +let logger: ToolingLog; +void run(async ({ log, flagsReader }) => { + logger = log; + + const scriptsToRun = collectScriptsToRun({ + targetFile: flagsReader.string('file'), + targetDir: flagsReader.string('dir'), + checks: flagsReader.string('checks'), + }); + + logger.write( + `--- Running ${scriptsToRun.length} checks, with parallelism ${MAX_PARALLELISM}...`, + scriptsToRun + ); + const startTime = Date.now(); + const results = await runAllChecks(scriptsToRun); + + logger.write('--- All checks finished.'); + printResults(startTime, results); + + const failedChecks = results.filter((check) => !check.success); + if (failedChecks.length > 0) { + logger.write(`--- ${failedChecks.length} quick check(s) failed. ❌`); + logger.write(`See above for details.`); + process.exitCode = 1; + } else { + logger.write('--- All checks passed. ✅'); + return results; + } +}, scriptOptions); + +function collectScriptsToRun(inputOptions: { + targetFile: string | undefined; + targetDir: string | undefined; + checks: string | undefined; +}) { + const { targetFile, targetDir, checks } = inputOptions; + if ([targetFile, targetDir, checks].filter(Boolean).length > 1) { + throw new Error('Only one of --file, --dir, or --checks can be used at a time.'); + } + + if (targetDir) { + const targetDirAbsolute = isAbsolute(targetDir) ? targetDir : join(REPO_ROOT, targetDir); + return readdirSync(targetDirAbsolute).map((file) => join(targetDir, file)); + } else if (checks) { + return checks + .trim() + .split(/[,\n]/) + .map((script) => script.trim()); + } else { + const targetFileWithDefault = targetFile || quickChecksList; + const targetFileAbsolute = isAbsolute(targetFileWithDefault) + ? targetFileWithDefault + : join(REPO_ROOT, targetFileWithDefault); + + return readFileSync(targetFileAbsolute, 'utf-8') + .trim() + .split('\n') + .map((line) => line.trim()); + } +} + +async function runAllChecks(scriptsToRun: string[]) { + const checksRunning: Array> = []; + const checksFinished: CheckResult[] = []; + + while (scriptsToRun.length > 0 || checksRunning.length > 0) { + while (scriptsToRun.length > 0 && checksRunning.length < MAX_PARALLELISM) { + const script = scriptsToRun.shift(); + if (!script) { + continue; + } + + const check = runCheckAsync(script); + checksRunning.push(check); + check.then((result) => { + checksRunning.splice(checksRunning.indexOf(check), 1); + checksFinished.push(result); + }); + } + + await sleep(1000); + } + + return checksFinished; +} + +async function runCheckAsync(script: string): Promise { + logger.info(`Starting check: ${script}`); + const startTime = Date.now(); + + return new Promise((resolve) => { + const scriptProcess = exec(script); + let output = ''; + const appendToOutput = (data: string | Buffer) => (output += data); + + scriptProcess.stdout?.on('data', appendToOutput); + scriptProcess.stderr?.on('data', appendToOutput); + + scriptProcess.on('exit', (code) => { + const result: CheckResult = { + success: code === 0, + script, + output, + durationMs: Date.now() - startTime, + }; + if (code === 0) { + logger.info(`Passed check: ${script} in ${humanizeTime(result.durationMs)}`); + } else { + logger.warning(`Failed check: ${script} in ${humanizeTime(result.durationMs)}`); + } + + resolve(result); + }); + }); +} + +function printResults(startTimestamp: number, results: CheckResult[]) { + const totalDuration = results.reduce((acc, result) => acc + result.durationMs, 0); + const total = humanizeTime(totalDuration); + const effective = humanizeTime(Date.now() - startTimestamp); + logger.info(`- Total time: ${total}, effective: ${effective}`); + + results.forEach((result) => { + logger.write( + `--- ${result.success ? '✅' : '❌'} ${result.script}: ${humanizeTime(result.durationMs)}` + ); + if (result.success) { + logger.debug(result.output); + } else { + logger.warning(result.output); + } + }); +} + +function humanizeTime(ms: number) { + if (ms < 1000) { + return `${ms}ms`; + } + + const minutes = Math.floor(ms / 1000 / 60); + const seconds = Math.floor((ms - minutes * 60 * 1000) / 1000); + if (minutes === 0) { + return `${seconds}s`; + } else { + return `${minutes}m ${seconds}s`; + } +} From 4b28aa8ef0a36de53259a0f9916f93ea3ddac549 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 21 Aug 2024 18:28:13 -0500 Subject: [PATCH 14/45] [ci] Add retry to saved object migration step (#191034) This is running on a spot instance and should retry if the agent is lost. --- .buildkite/pipelines/on_merge.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 7719b338b13ef..e37e99c9c4df1 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -589,6 +589,11 @@ steps: preemptible: true artifact_paths: "target/plugin_so_types_snapshot.json" + timeout_in_minutes: 30 + retry: + automatic: + - exit_status: '-1' + limit: 3 - wait: ~ continue_on_failure: true From 16c7ca2428765a380a7b234bea0d68409c3f8a87 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 21 Aug 2024 19:31:30 -0400 Subject: [PATCH 15/45] [Detection Engine] addressing icon color (#190744) ## Summary Addresses https://github.com/elastic/kibana/issues/131306 - makes trash icon red. --- .../components/value_lists_management_flyout/table_helpers.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_flyout/table_helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_flyout/table_helpers.tsx index 3e8cde7e00a23..9c6488fb0cb83 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_flyout/table_helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_flyout/table_helpers.tsx @@ -88,6 +88,7 @@ export const buildColumns = ( aria-label={i18n.ACTION_DELETE_DESCRIPTION} data-test-subj={`action-delete-value-list-${item.name}`} iconType="trash" + color="danger" onClick={() => onDelete(item)} /> )} From f512acf9c3c28a8f0b4762b7301e5706bea0ad11 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 21 Aug 2024 17:20:54 -0700 Subject: [PATCH 16/45] [SharedUX] Resolve Sass Mixed Declaration issues (#191033) ## Summary Closes https://github.com/elastic/kibana/issues/190886 Closes https://github.com/elastic/kibana/issues/190887 This resolves Sass mixed declaration issues by moving declarations to the top of the rule, above all nesting. The following screenshots show that no style regressions were created by this PR: ---- **`.kbnCollapsibleNav__recentsListGroup`:** ![image](https://github.com/user-attachments/assets/74218960-3c9f-48c3-a0de-e1924b9f89a8) ---- **`.kbnSolutionNav`:** ![image](https://github.com/user-attachments/assets/b59088f3-d845-4b8a-b0eb-44642de90a60) ### Checklist Delete any items that are not applicable to this PR. - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../src/ui/header/collapsible_nav.scss | 2 +- packages/shared-ux/page/solution_nav/src/solution_nav.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/header/collapsible_nav.scss b/packages/core/chrome/core-chrome-browser-internal/src/ui/header/collapsible_nav.scss index a121ac4c02b25..1086e30ebec0e 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/header/collapsible_nav.scss +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/header/collapsible_nav.scss @@ -7,9 +7,9 @@ $screenHeightBreakpoint: $euiSize * 15; } .kbnCollapsibleNav__recentsListGroup { - @include euiYScroll; max-height: $euiSize * 10; margin-right: -$euiSizeS; + @include euiYScroll; } .kbnCollapsibleNav__solutions { diff --git a/packages/shared-ux/page/solution_nav/src/solution_nav.scss b/packages/shared-ux/page/solution_nav/src/solution_nav.scss index 3456364009e60..694045f9a8c94 100644 --- a/packages/shared-ux/page/solution_nav/src/solution_nav.scss +++ b/packages/shared-ux/page/solution_nav/src/solution_nav.scss @@ -10,11 +10,11 @@ } .kbnSolutionNav { - @include euiYScroll; - display: flex; flex-direction: column; + @include euiYScroll; + @include euiBreakpoint('m', 'l', 'xl') { width: $solutionNavWidth; padding: $euiSizeL; From 01aae2331d01e727f20b9f5a37435fa23cfd3cc2 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 21 Aug 2024 18:43:38 -0600 Subject: [PATCH 17/45] [Control group] Open "add control editor" with last used width and grow settings (#190749) ### test instructions 1. run kibana with `yarn start --run-examples` 2. navigate to the "Controls" developer example 3. Click "Add new data-control" button. Notice how the menu sets "width" to medium and selects "grow". 4. Add new control, setting "width" to small and unselected "grow". Click "Save and close". 5. Click "Add new data-control" button. Notice how the menu sets "width" and "grow" to last used values. --------- Co-authored-by: Elastic Machine Co-authored-by: Hannah Mudge --- .../get_control_group_factory.tsx | 21 +------- .../init_controls_manager.test.ts | 50 +++++++++++++++++++ .../control_group/init_controls_manager.ts | 20 +++++++- .../control_group/serialization_utils.ts | 3 -- .../react_controls/control_group/types.ts | 15 ++---- .../data_controls/data_control_editor.tsx | 12 +---- .../public/react_controls/controls/types.ts | 8 +-- 7 files changed, 78 insertions(+), 51 deletions(-) diff --git a/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx b/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx index ee241dcfea0fc..45802689e81a1 100644 --- a/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx +++ b/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx @@ -27,11 +27,8 @@ import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetc import { ControlStyle, ParentIgnoreSettings } from '../..'; import { ControlGroupChainingSystem, - ControlWidth, CONTROL_GROUP_TYPE, - DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_STYLE, - DEFAULT_CONTROL_WIDTH, } from '../../../common'; import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch'; import { initControlsManager } from './init_controls_manager'; @@ -64,8 +61,6 @@ export const getControlGroupEmbeddableFactory = (services: { ) => { const { initialChildControlState, - defaultControlGrow, - defaultControlWidth, labelPosition: initialLabelPosition, chainingSystem, autoApplySelections, @@ -89,12 +84,6 @@ export const getControlGroupEmbeddableFactory = (services: { const ignoreParentSettings$ = new BehaviorSubject( ignoreParentSettings ); - const grow = new BehaviorSubject( - defaultControlGrow === undefined ? DEFAULT_CONTROL_GROW : defaultControlGrow - ); - const width = new BehaviorSubject( - defaultControlWidth ?? DEFAULT_CONTROL_WIDTH - ); const labelPosition$ = new BehaviorSubject( // TODO: Rename `ControlStyle` initialLabelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE` ); @@ -175,13 +164,9 @@ export const getControlGroupEmbeddableFactory = (services: { controlInputTransform: (state) => state, }; openDataControlEditor({ - initialState: { - grow: api.grow.getValue(), - width: api.width.getValue(), - dataViewId: controlsManager.api.lastUsedDataViewId$.value, - }, + initialState: controlsManager.getNewControlState(), onSave: ({ type: controlType, state: initialState }) => { - api.addNewPanel({ + controlsManager.api.addNewPanel({ panelType: controlType, initialState: controlInputTransform!( initialState as Partial, @@ -206,8 +191,6 @@ export const getControlGroupEmbeddableFactory = (services: { references, }; }, - grow, - width, dataViews, labelPosition: labelPosition$, saveNotification$: apiHasSaveNotification(parentApi) diff --git a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts index 76899c3d9d0b4..3e381123ecd9a 100644 --- a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts +++ b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ +import { DefaultDataControlState } from '../controls/data_controls/types'; import { DefaultControlApi } from '../controls/types'; import { initControlsManager, getLastUsedDataViewId } from './init_controls_manager'; +import { ControlPanelState } from './types'; jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('delta'), @@ -187,3 +189,51 @@ describe('getLastUsedDataViewId', () => { expect(dataViewId).toBeUndefined(); }); }); + +describe('getNewControlState', () => { + test('should contain defaults when there are no existing controls', () => { + const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID); + expect(controlsManager.getNewControlState()).toEqual({ + grow: true, + width: 'medium', + dataViewId: DEFAULT_DATA_VIEW_ID, + }); + }); + + test('should start with defaults if there are existing controls', () => { + const controlsManager = initControlsManager( + { + alpha: { + type: 'testControl', + order: 1, + dataViewId: 'myOtherDataViewId', + width: 'small', + grow: false, + } as ControlPanelState & Pick, + }, + DEFAULT_DATA_VIEW_ID + ); + expect(controlsManager.getNewControlState()).toEqual({ + grow: true, + width: 'medium', + dataViewId: 'myOtherDataViewId', + }); + }); + + test('should contain values of last added control', () => { + const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID); + controlsManager.api.addNewPanel({ + panelType: 'testControl', + initialState: { + grow: false, + width: 'small', + dataViewId: 'myOtherDataViewId', + }, + }); + expect(controlsManager.getNewControlState()).toEqual({ + grow: false, + width: 'small', + dataViewId: 'myOtherDataViewId', + }); + }); +}); diff --git a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts index 0e762bfd53fa3..07b533f329631 100644 --- a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts +++ b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts @@ -22,6 +22,7 @@ import { ControlGroupApi, ControlPanelsState, ControlPanelState } from './types' import { DefaultControlApi, DefaultControlState } from '../controls/types'; import { ControlGroupComparatorState } from './control_group_unsaved_changes_api'; import { DefaultDataControlState } from '../controls/data_controls/types'; +import { ControlWidth, DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../../common'; export type ControlsInOrder = Array<{ id: string; type: string }>; @@ -54,6 +55,8 @@ export function initControlsManager( defaultDataViewId ?? undefined ); + const lastUsedWidth$ = new BehaviorSubject(DEFAULT_CONTROL_WIDTH); + const lastUsedGrow$ = new BehaviorSubject(DEFAULT_CONTROL_GROW); function untilControlLoaded( id: string @@ -91,6 +94,13 @@ export function initControlsManager( if ((initialState as DefaultDataControlState)?.dataViewId) { lastUsedDataViewId$.next((initialState as DefaultDataControlState).dataViewId); } + if (initialState?.width) { + lastUsedWidth$.next(initialState.width); + } + if (typeof initialState?.grow === 'boolean') { + lastUsedGrow$.next(initialState.grow); + } + const id = generateId(); const nextControlsInOrder = [...controlsInOrder$.value]; nextControlsInOrder.splice(index, 0, { @@ -110,6 +120,13 @@ export function initControlsManager( return { controlsInOrder$, + getNewControlState: () => { + return { + grow: lastUsedGrow$.value, + width: lastUsedWidth$.value, + dataViewId: lastUsedDataViewId$.value, + }; + }, getControlApi, setControlApi: (uuid: string, controlApi: DefaultControlApi) => { children$.next({ @@ -168,7 +185,6 @@ export function initControlsManager( return controlsRuntimeState; }, api: { - lastUsedDataViewId$: lastUsedDataViewId$ as PublishingSubject, getSerializedStateForChild: (childId: string) => { const controlPanelState = controlsPanelState[childId]; return controlPanelState ? { rawState: controlPanelState } : undefined; @@ -210,7 +226,7 @@ export function initControlsManager( }, } as PresentationContainer & HasSerializedChildState & - Pick, + Pick, comparators: { controlsInOrder: [ controlsInOrder$, diff --git a/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts b/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts index f57fbd801804f..eb3706c3913a1 100644 --- a/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts +++ b/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts @@ -8,7 +8,6 @@ import { SerializedPanelState } from '@kbn/presentation-containers'; import { omit } from 'lodash'; -import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../../common'; import { ControlGroupRuntimeState, ControlGroupSerializedState } from './types'; export const deserializeControlGroup = ( @@ -46,7 +45,5 @@ export const deserializeControlGroup = ( ? !state.rawState.showApplySelections : false, // Rename "showApplySelections" to "autoApplySelections" labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition" - defaultControlGrow: DEFAULT_CONTROL_GROW, - defaultControlWidth: DEFAULT_CONTROL_WIDTH, }; }; diff --git a/src/plugins/controls/public/react_controls/control_group/types.ts b/src/plugins/controls/public/react_controls/control_group/types.ts index f00aaeae578b4..d009712e52a5b 100644 --- a/src/plugins/controls/public/react_controls/control_group/types.ts +++ b/src/plugins/controls/public/react_controls/control_group/types.ts @@ -31,21 +31,17 @@ import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/p import { ParentIgnoreSettings } from '../..'; import { ControlInputTransform } from '../../../common'; import { ControlGroupChainingSystem } from '../../../common/control_group/types'; -import { ControlStyle, ControlWidth } from '../../types'; -import { DefaultControlState, PublishesControlDisplaySettings } from '../controls/types'; +import { ControlStyle } from '../../types'; +import { DefaultControlState } from '../controls/types'; import { ControlFetchContext } from './control_fetch/control_fetch'; -/** The control display settings published by the control group are the "default" */ -type PublishesControlGroupDisplaySettings = PublishesControlDisplaySettings & { - labelPosition: PublishingSubject; -}; export interface ControlPanelsState { [panelId: string]: ControlState; } export type ControlGroupUnsavedChanges = Omit< ControlGroupRuntimeState, - 'initialChildControlState' | 'defaultControlGrow' | 'defaultControlWidth' + 'initialChildControlState' > & { filters: Filter[] | undefined; }; @@ -60,7 +56,6 @@ export type ControlGroupApi = PresentationContainer & HasEditCapabilities & PublishesDataLoading & Pick & - PublishesControlGroupDisplaySettings & PublishesTimeslice & Partial & HasSaveNotification & PublishesReload> & { asyncResetUnsavedChanges: () => Promise; @@ -73,13 +68,11 @@ export type ControlGroupApi = PresentationContainer & openAddDataControlFlyout: (settings?: { controlInputTransform?: ControlInputTransform; }) => void; - lastUsedDataViewId$: PublishingSubject; + labelPosition: PublishingSubject; }; export interface ControlGroupRuntimeState { chainingSystem: ControlGroupChainingSystem; - defaultControlGrow?: boolean; - defaultControlWidth?: ControlWidth; labelPosition: ControlStyle; // TODO: Rename this type to ControlLabelPosition autoApplySelections: boolean; ignoreParentSettings?: ParentIgnoreSettings; diff --git a/src/plugins/controls/public/react_controls/controls/data_controls/data_control_editor.tsx b/src/plugins/controls/public/react_controls/controls/data_controls/data_control_editor.tsx index a39b0ea03b422..d26d156d1e09a 100644 --- a/src/plugins/controls/public/react_controls/controls/data_controls/data_control_editor.tsx +++ b/src/plugins/controls/public/react_controls/controls/data_controls/data_control_editor.tsx @@ -32,7 +32,6 @@ import { } from '@elastic/eui'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { LazyDataViewPicker, LazyFieldPicker, @@ -145,10 +144,6 @@ export const DataControlEditor = ) => { - const [defaultGrow, defaultWidth] = useBatchedPublishingSubjects( - controlGroupApi.grow, - controlGroupApi.width - ); const [editorState, setEditorState] = useState>(initialState); const [defaultPanelTitle, setDefaultPanelTitle] = useState( initialDefaultPanelTitle ?? initialState.fieldName ?? '' @@ -368,7 +363,7 @@ export const DataControlEditor = setEditorState({ ...editorState, width: newWidth as ControlWidth }) } @@ -377,10 +372,7 @@ export const DataControlEditor = setEditorState({ ...editorState, grow: !editorState.grow })} data-test-subj="control-editor-grow-switch" /> diff --git a/src/plugins/controls/public/react_controls/controls/types.ts b/src/plugins/controls/public/react_controls/controls/types.ts index d89db77e1f53b..2d71ae0dd1298 100644 --- a/src/plugins/controls/public/react_controls/controls/types.ts +++ b/src/plugins/controls/public/react_controls/controls/types.ts @@ -26,11 +26,6 @@ import { CanClearSelections, ControlWidth } from '../../types'; import { ControlGroupApi } from '../control_group/types'; -export interface PublishesControlDisplaySettings { - grow: PublishingSubject; - width: PublishingSubject; -} - export interface HasCustomPrepend { CustomPrependComponent: React.FC<{}>; } @@ -38,7 +33,6 @@ export interface HasCustomPrepend { export type DefaultControlApi = PublishesDataLoading & PublishesBlockingError & PublishesUnsavedChanges & - PublishesControlDisplaySettings & Partial & CanClearSelections & HasType & @@ -51,6 +45,8 @@ export type DefaultControlApi = PublishesDataLoading & /** TODO: Make these non-public as part of https://github.com/elastic/kibana/issues/174961 */ setDataLoading: (loading: boolean) => void; setBlockingError: (error: Error | undefined) => void; + grow: PublishingSubject; + width: PublishingSubject; }; export interface DefaultControlState { From a2873c0c87345fcb49fe58509d786918f89c2b5a Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:55:00 -0700 Subject: [PATCH 18/45] [Response Ops] Rule Specific Flapping - Create/Edit Rule Flyout Frontend Changes (#189341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Issue: https://github.com/elastic/kibana/issues/189135 Frontend changes for the rule specific flapping feature in the existing rule flyout. To test: Simply go to `x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts` and set line 89 `IS_RULE_SPECIFIC_FLAPPING_ENABLED = false` to `true`. This acts as a feature flag. Then you may go to the create/edit rule flyout to see this new input. This PR does not contain changes to allow for saving/editing of this field. That will come with the backend PR. ### Flapping Enabled Without Override Screenshot 2024-07-29 at 12 11 02 AM ### Flapping Enabled With Override Screenshot 2024-07-29 at 12 11 07 AM ### Flapping Disabled With or Without Override image ### 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 --------- Co-authored-by: Elastic Machine --- packages/kbn-alerting-types/index.ts | 1 + packages/kbn-alerting-types/rule_flapping.ts | 12 + packages/kbn-alerting-types/rule_types.ts | 4 + .../apis/create_rule/create_rule.test.ts | 16 + .../transform_create_rule_body.test.ts | 9 +- .../create_rule/transform_create_rule_body.ts | 16 + .../src/common/apis/create_rule/types.ts | 1 + .../transform_update_rule_body.test.ts | 8 + .../update_rule/transform_update_rule_body.ts | 16 + .../src/common/apis/update_rule/types.ts | 1 + .../apis/update_rule/update_rule.test.ts | 154 +++++--- .../common/apis/update_rule/update_rule.ts | 2 + .../common/transformations/transform_rule.ts | 15 + .../rule_settings_flapping_inputs.tsx | 110 ++++++ .../rule_settings_flapping_message.tsx | 56 +++ .../rule_settings_range_input.tsx | 16 +- .../plugins/alerting/common/rules_settings.ts | 17 +- .../translations/translations/fr-FR.json | 8 - .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - .../rules_settings_flapping_form_section.tsx | 106 +----- .../rules_settings_flapping_section.tsx | 8 +- .../rules_settings_query_delay_section.tsx | 4 +- .../hooks/use_get_flapping_settings.ts | 5 +- .../sections/rule_form/rule_add.tsx | 5 +- .../sections/rule_form/rule_edit.tsx | 11 +- .../sections/rule_form/rule_form.test.tsx | 140 ++++--- .../sections/rule_form/rule_form.tsx | 106 +++--- .../rule_form_advanced_options.test.tsx | 222 +++++++++++ .../rule_form/rule_form_advanced_options.tsx | 360 ++++++++++++++++++ .../public/common/constants/index.ts | 3 + 31 files changed, 1143 insertions(+), 305 deletions(-) create mode 100644 packages/kbn-alerting-types/rule_flapping.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx rename x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_range.tsx => packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_range_input.tsx (64%) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx diff --git a/packages/kbn-alerting-types/index.ts b/packages/kbn-alerting-types/index.ts index dcf9cfe297ccb..da0603f60b3e7 100644 --- a/packages/kbn-alerting-types/index.ts +++ b/packages/kbn-alerting-types/index.ts @@ -17,4 +17,5 @@ export * from './r_rule_types'; export * from './rule_notify_when_type'; export * from './rule_type_types'; export * from './rule_types'; +export * from './rule_flapping'; export * from './search_strategy_types'; diff --git a/packages/kbn-alerting-types/rule_flapping.ts b/packages/kbn-alerting-types/rule_flapping.ts new file mode 100644 index 0000000000000..27f4edb3c693b --- /dev/null +++ b/packages/kbn-alerting-types/rule_flapping.ts @@ -0,0 +1,12 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const MIN_LOOK_BACK_WINDOW = 2; +export const MAX_LOOK_BACK_WINDOW = 20; +export const MIN_STATUS_CHANGE_THRESHOLD = 2; +export const MAX_STATUS_CHANGE_THRESHOLD = 20; diff --git a/packages/kbn-alerting-types/rule_types.ts b/packages/kbn-alerting-types/rule_types.ts index 5387144ed6345..a844914778c35 100644 --- a/packages/kbn-alerting-types/rule_types.ts +++ b/packages/kbn-alerting-types/rule_types.ts @@ -239,6 +239,10 @@ export interface Rule { running?: boolean | null; viewInAppRelativeUrl?: string; alertDelay?: AlertDelay | null; + flapping?: { + lookBackWindow: number; + statusChangeThreshold: number; + }; } export type SanitizedRule = Omit< diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/create_rule.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/create_rule.test.ts index 8735a9bebca73..e1947f6d8890e 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/create_rule.test.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/create_rule.test.ts @@ -62,6 +62,10 @@ describe('createRule', () => { alert_delay: { active: 10, }, + flapping: { + look_back_window: 10, + status_change_threshold: 10, + }, }; const ruleToCreate: CreateRuleBody = { @@ -109,10 +113,18 @@ describe('createRule', () => { alertDelay: { active: 10, }, + flapping: { + lookBackWindow: 10, + statusChangeThreshold: 10, + }, }; http.post.mockResolvedValueOnce(resolvedValue); const result = await createRule({ http, rule: ruleToCreate as CreateRuleBody }); + expect(http.post).toHaveBeenCalledWith('/api/alerting/rule', { + body: '{"params":{"aggType":"count","termSize":5,"thresholdComparator":">","timeWindowSize":5,"timeWindowUnit":"m","groupBy":"all","threshold":[1000],"index":[".kibana"],"timeField":"alert.executionStatus.lastExecutionDate"},"consumer":"alerts","schedule":{"interval":"1m"},"tags":[],"name":"test","enabled":true,"throttle":null,"notifyWhen":"onActionGroupChange","rule_type_id":".index-threshold","actions":[{"group":"threshold met","id":"83d4d860-9316-11eb-a145-93ab369a4461","params":{"level":"info","message":"Rule \'{{rule.name}}\' is active for group \'{{context.group}}\':\\n\\n- Value: {{context.value}}\\n- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}\\n- Timestamp: {{context.date}}"},"frequency":{"notify_when":"onActionGroupChange","throttle":null,"summary":false}},{"id":".test-system-action","params":{}}],"alert_delay":{"active":10},"flapping":{"look_back_window":10,"status_change_threshold":10}}', + }); + expect(result).toEqual({ actions: [ { @@ -169,6 +181,10 @@ describe('createRule', () => { alertDelay: { active: 10, }, + flapping: { + lookBackWindow: 10, + statusChangeThreshold: 10, + }, }); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.test.ts index 8b41e38d14ec1..7c5c80efa3ee2 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.test.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.test.ts @@ -55,6 +55,10 @@ const ruleToCreate: CreateRuleBody = { alertDelay: { active: 10, }, + flapping: { + lookBackWindow: 10, + statusChangeThreshold: 10, + }, }; describe('transformCreateRuleBody', () => { @@ -96,8 +100,11 @@ describe('transformCreateRuleBody', () => { }, { id: '.test-system-action', params: {} }, ], - alert_delay: { active: 10 }, + flapping: { + look_back_window: 10, + status_change_threshold: 10, + }, }); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts index 9baae0267d22f..dd8d5483ef0d7 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts @@ -8,11 +8,26 @@ import { RewriteResponseCase } from '@kbn/actions-types'; import { CreateRuleBody } from './types'; +import { Rule } from '../../types'; + +const transformCreateRuleFlapping = (flapping: Rule['flapping']) => { + if (!flapping) { + return flapping; + } + + return { + flapping: { + look_back_window: flapping.lookBackWindow, + status_change_threshold: flapping.statusChangeThreshold, + }, + }; +}; export const transformCreateRuleBody: RewriteResponseCase = ({ ruleTypeId, actions = [], alertDelay, + flapping, ...res }): any => ({ ...res, @@ -44,4 +59,5 @@ export const transformCreateRuleBody: RewriteResponseCase = ({ }; }), ...(alertDelay ? { alert_delay: alertDelay } : {}), + ...(flapping !== undefined ? transformCreateRuleFlapping(flapping) : {}), }); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts index e40059d5e6860..1527744ced38c 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts @@ -20,4 +20,5 @@ export interface CreateRuleBody throttle?: Rule['throttle']; notifyWhen?: Rule['notifyWhen']; alertDelay?: Rule['alertDelay']; + flapping?: Rule['flapping']; } diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.test.ts index c30807884a646..d5efc7b8fd19f 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.test.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.test.ts @@ -52,6 +52,10 @@ const ruleToUpdate: UpdateRuleBody = { alertDelay: { active: 10, }, + flapping: { + lookBackWindow: 10, + statusChangeThreshold: 10, + }, }; describe('transformUpdateRuleBody', () => { @@ -98,6 +102,10 @@ describe('transformUpdateRuleBody', () => { }, tags: [], throttle: null, + flapping: { + look_back_window: 10, + status_change_threshold: 10, + }, }); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts index 959cf5c7cdd3c..45c34833fad9b 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts @@ -8,10 +8,25 @@ import { RewriteResponseCase } from '@kbn/actions-types'; import { UpdateRuleBody } from './types'; +import { Rule } from '../../types'; + +const transformUpdateRuleFlapping = (flapping: Rule['flapping']) => { + if (!flapping) { + return flapping; + } + + return { + flapping: { + look_back_window: flapping.lookBackWindow, + status_change_threshold: flapping.statusChangeThreshold, + }, + }; +}; export const transformUpdateRuleBody: RewriteResponseCase = ({ actions = [], alertDelay, + flapping, ...res }): any => ({ ...res, @@ -41,4 +56,5 @@ export const transformUpdateRuleBody: RewriteResponseCase = ({ }; }), ...(alertDelay ? { alert_delay: alertDelay } : {}), + ...(flapping !== undefined ? transformUpdateRuleFlapping(flapping) : {}), }); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts index dac91fa2076f6..7a466ac0b6d86 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts @@ -17,4 +17,5 @@ export interface UpdateRuleBody throttle?: Rule['throttle']; notifyWhen?: Rule['notifyWhen']; alertDelay?: Rule['alertDelay']; + flapping?: Rule['flapping']; } diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts index 7cb64b51427e0..9668e60a79417 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts @@ -7,28 +7,74 @@ */ import { httpServiceMock } from '@kbn/core/public/mocks'; -import { Rule } from '../../types'; import { updateRule, UpdateRuleBody } from '.'; const http = httpServiceMock.createStartContract(); describe('updateRule', () => { test('should call rule update API', async () => { - const ruleToUpdate = { + const updatedRule = { + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'alert.executionStatus.lastExecutionDate', + }, consumer: 'alerts', + schedule: { interval: '1m' }, + tags: [], name: 'test', - tags: ['foo'], + rule_type_id: '.index-threshold', + actions: [ + { + group: 'threshold met', + id: '1', + params: { + level: 'info', + message: 'alert ', + }, + connector_type_id: '.server-log', + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + { + id: '.test-system-action', + params: {}, + connector_type_id: '.system-action', + }, + ], + scheduled_task_id: '1', + execution_status: { status: 'pending', last_execution_date: '2021-04-01T21:33:13.250Z' }, + create_at: '2021-04-01T21:33:13.247Z', + updated_at: '2021-04-01T21:33:13.247Z', + create_by: 'user', + updated_by: 'user', + alert_delay: { + active: 10, + }, + flapping: { + look_back_window: 10, + status_change_threshold: 10, + }, + }; + + const updateRuleBody = { + name: 'test-update', + tags: ['foo', 'bar'], schedule: { - interval: '1m', + interval: '5m', }, params: {}, - createdAt: new Date('1970-01-01T00:00:00.000Z'), - updatedAt: new Date('1970-01-01T00:00:00.000Z'), - apiKey: null, - apiKeyOwner: null, - revision: 0, alertDelay: { - active: 10, + active: 50, }, actions: [ { @@ -43,37 +89,29 @@ describe('updateRule', () => { summary: false, }, }, - { - id: '.test-system-action', - params: {}, - actionTypeId: '.system-action', - }, ], - }; - - const resolvedValue: Rule = { - ...ruleToUpdate, - id: '12/3', - enabled: true, - ruleTypeId: 'test', - createdBy: null, - updatedBy: null, - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + flapping: { + lookBackWindow: 10, + statusChangeThreshold: 10, }, - revision: 1, }; http.put.mockResolvedValueOnce({ - ...resolvedValue, + ...updatedRule, + name: 'test-update', + tags: ['foo', 'bar'], + schedule: { + interval: '5m', + }, + params: {}, + alert_delay: { + active: 50, + }, actions: [ { group: 'default', id: '2', - connector_type_id: 'test', + action_type_dd: 'test', params: {}, use_alert_data_for_template: false, frequency: { @@ -82,41 +120,57 @@ describe('updateRule', () => { summary: false, }, }, - { - id: '.test-system-action', - params: {}, - connector_type_id: '.system-action', - }, ], + flapping: { + look_back_window: 10, + status_change_threshold: 10, + }, }); - const result = await updateRule({ http, id: '12/3', rule: ruleToUpdate as UpdateRuleBody }); + const result = await updateRule({ http, id: '12/3', rule: updateRuleBody as UpdateRuleBody }); expect(result).toEqual({ - ...resolvedValue, actions: [ { - group: 'default', - id: '2', - actionTypeId: 'test', - params: {}, - useAlertDataForTemplate: false, frequency: { notifyWhen: 'onActionGroupChange', - throttle: null, summary: false, + throttle: null, }, - }, - { - id: '.test-system-action', + group: 'default', + id: '2', params: {}, - actionTypeId: '.system-action', + useAlertDataForTemplate: false, }, ], + alertDelay: { + active: 50, + }, + consumer: 'alerts', + create_at: '2021-04-01T21:33:13.247Z', + create_by: 'user', + executionStatus: { + lastExecutionDate: '2021-04-01T21:33:13.250Z', + status: 'pending', + }, + flapping: { + lookBackWindow: 10, + statusChangeThreshold: 10, + }, + name: 'test-update', + params: {}, + ruleTypeId: '.index-threshold', + schedule: { + interval: '5m', + }, + scheduledTaskId: '1', + tags: ['foo', 'bar'], + updatedAt: '2021-04-01T21:33:13.247Z', + updatedBy: 'user', }); expect(http.put).toHaveBeenCalledWith('/api/alerting/rule/12%2F3', { - body: '{"name":"test","tags":["foo"],"schedule":{"interval":"1m"},"params":{},"actions":[{"group":"default","id":"2","params":{},"frequency":{"notify_when":"onActionGroupChange","throttle":null,"summary":false},"use_alert_data_for_template":false},{"id":".test-system-action","params":{}}],"alert_delay":{"active":10}}', + body: '{"name":"test-update","tags":["foo","bar"],"schedule":{"interval":"5m"},"params":{},"actions":[{"group":"default","id":"2","params":{},"frequency":{"notify_when":"onActionGroupChange","throttle":null,"summary":false},"use_alert_data_for_template":false}],"alert_delay":{"active":50},"flapping":{"look_back_window":10,"status_change_threshold":10}}', }); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts index 841778eaa52ee..97e5ed6bef480 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts @@ -21,6 +21,7 @@ export const UPDATE_FIELDS: Array = [ 'schedule', 'params', 'alertDelay', + 'flapping', ]; export const UPDATE_FIELDS_WITH_ACTIONS: Array = [ @@ -30,6 +31,7 @@ export const UPDATE_FIELDS_WITH_ACTIONS: Array = [ 'params', 'alertDelay', 'actions', + 'flapping', ]; export async function updateRule({ diff --git a/packages/kbn-alerts-ui-shared/src/common/transformations/transform_rule.ts b/packages/kbn-alerts-ui-shared/src/common/transformations/transform_rule.ts index 46717052d70b2..5081ef0314e4d 100644 --- a/packages/kbn-alerts-ui-shared/src/common/transformations/transform_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/transformations/transform_rule.ts @@ -33,6 +33,19 @@ const transformLastRun: RewriteRequestCase = ({ ...rest, }); +const transformFlapping = (flapping: AsApiContract) => { + if (!flapping) { + return flapping; + } + + return { + flapping: { + lookBackWindow: flapping.look_back_window, + statusChangeThreshold: flapping.status_change_threshold, + }, + }; +}; + export const transformRule: RewriteRequestCase = ({ rule_type_id: ruleTypeId, created_by: createdBy, @@ -53,6 +66,7 @@ export const transformRule: RewriteRequestCase = ({ last_run: lastRun, next_run: nextRun, alert_delay: alertDelay, + flapping, ...rest }: any) => ({ ruleTypeId, @@ -76,6 +90,7 @@ export const transformRule: RewriteRequestCase = ({ ...(nextRun ? { nextRun } : {}), ...(apiKeyCreatedByUser !== undefined ? { apiKeyCreatedByUser } : {}), ...(alertDelay ? { alertDelay } : {}), + ...(flapping !== undefined ? transformFlapping(flapping) : {}), ...rest, }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs.tsx new file mode 100644 index 0000000000000..6b3086df3f952 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs.tsx @@ -0,0 +1,110 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + MIN_LOOK_BACK_WINDOW, + MAX_LOOK_BACK_WINDOW, + MIN_STATUS_CHANGE_THRESHOLD, + MAX_STATUS_CHANGE_THRESHOLD, +} from '@kbn/alerting-types'; +import { i18n } from '@kbn/i18n'; +import { RuleSettingsRangeInput } from './rule_settings_range_input'; + +const lookBackWindowLabel = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingInputsProps.lookBackWindowLabel', + { + defaultMessage: 'Rule run look back window', + } +); + +const lookBackWindowHelp = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingInputsProps.lookBackWindowHelp', + { + defaultMessage: 'The minimum number of runs in which the threshold must be met.', + } +); + +const statusChangeThresholdLabel = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingInputsProps.statusChangeThresholdLabel', + { + defaultMessage: 'Alert status change threshold', + } +); + +const statusChangeThresholdHelp = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingInputsProps.statusChangeThresholdHelp', + { + defaultMessage: + 'The minimum number of times an alert must switch states in the look back window.', + } +); + +export interface RuleSettingsFlappingInputsProps { + lookBackWindow: number; + statusChangeThreshold: number; + isDisabled?: boolean; + onLookBackWindowChange: (value: number) => void; + onStatusChangeThresholdChange: (value: number) => void; +} + +export const RuleSettingsFlappingInputs = (props: RuleSettingsFlappingInputsProps) => { + const { + lookBackWindow, + statusChangeThreshold, + isDisabled = false, + onLookBackWindowChange, + onStatusChangeThresholdChange, + } = props; + + const internalOnLookBackWindowChange = useCallback( + (e) => { + onLookBackWindowChange(parseInt(e.currentTarget.value, 10)); + }, + [onLookBackWindowChange] + ); + + const internalOnStatusChangeThresholdChange = useCallback( + (e) => { + onStatusChangeThresholdChange(parseInt(e.currentTarget.value, 10)); + }, + [onStatusChangeThresholdChange] + ); + + return ( + + + + + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx new file mode 100644 index 0000000000000..7752ef7ba5e86 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx @@ -0,0 +1,56 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; + +const getLookBackWindowLabelRuleRuns = (amount: number) => { + return i18n.translate('alertsUIShared.ruleSettingsFlappingMessage.lookBackWindowLabelRuleRuns', { + defaultMessage: '{amount, number} rule {amount, plural, one {run} other {runs}}', + values: { amount }, + }); +}; + +const getStatusChangeThresholdRuleRuns = (amount: number) => { + return i18n.translate('alertsUIShared.ruleSettingsFlappingMessage.statusChangeThresholdTimes', { + defaultMessage: '{amount, number} {amount, plural, one {time} other {times}}', + values: { amount }, + }); +}; + +export const flappingOffMessage = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingMessage.flappingOffMessage', + { + defaultMessage: + 'Alert flapping detection is off. Alerts will be generated based on the rule interval, which might result in higher alert volumes.', + } +); + +export interface RuleSettingsFlappingMessageProps { + lookBackWindow: number; + statusChangeThreshold: number; +} + +export const RuleSettingsFlappingMessage = (props: RuleSettingsFlappingMessageProps) => { + const { lookBackWindow, statusChangeThreshold } = props; + + return ( + + {getLookBackWindowLabelRuleRuns(lookBackWindow)}, + statusChangeThreshold: {getStatusChangeThresholdRuleRuns(statusChangeThreshold)}, + }} + /> + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_range.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_range_input.tsx similarity index 64% rename from x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_range.tsx rename to packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_range_input.tsx index 95f0f4f618ccf..2103a7c2adbd4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_range.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_range_input.tsx @@ -1,25 +1,28 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import React, { memo } from 'react'; import { EuiFormRow, EuiFormRowProps, EuiIconTip, EuiRange, EuiRangeProps } from '@elastic/eui'; -export interface RulesSettingsRangeProps { +export interface RuleSettingsRangeInputProps { label: EuiFormRowProps['label']; labelPopoverText?: string; min: number; max: number; value: number; + fullWidth?: EuiRangeProps['fullWidth']; disabled?: EuiRangeProps['disabled']; onChange?: EuiRangeProps['onChange']; } -export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => { - const { label, labelPopoverText, min, max, value, disabled, onChange, ...rest } = props; +export const RuleSettingsRangeInput = memo((props: RuleSettingsRangeInputProps) => { + const { label, labelPopoverText, min, max, value, fullWidth, disabled, onChange, ...rest } = + props; const renderLabel = () => { return ( @@ -34,8 +37,9 @@ export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => { }; return ( - + ; -const lookBackWindowLabel = i18n.translate( - 'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel', - { - defaultMessage: 'Rule run look back window', - } -); - -const lookBackWindowHelp = i18n.translate( - 'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowHelp', - { - defaultMessage: 'The minimum number of runs in which the threshold must be met.', - } -); - -const statusChangeThresholdLabel = i18n.translate( - 'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel', - { - defaultMessage: 'Alert status change threshold', - } -); - -const statusChangeThresholdHelp = i18n.translate( - 'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdHelp', - { - defaultMessage: - 'The minimum number of times an alert must switch states in the look back window.', - } -); - -const getLookBackWindowLabelRuleRuns = (amount: number) => { - return i18n.translate( - 'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns', - { - defaultMessage: '{amount, number} rule {amount, plural, one {run} other {runs}}', - values: { amount }, - } - ); -}; - -const getStatusChangeThresholdRuleRuns = (amount: number) => { - return i18n.translate( - 'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes', - { - defaultMessage: '{amount, number} {amount, plural, one {time} other {times}}', - values: { amount }, - } - ); -}; - export const RulesSettingsFlappingTitle = () => { return ( @@ -123,44 +68,21 @@ export const RulesSettingsFlappingFormSection = memo( )} - - onChange('lookBackWindow', parseInt(e.currentTarget.value, 10))} - label={lookBackWindowLabel} - labelPopoverText={lookBackWindowHelp} - disabled={!canWrite} - /> - - - onChange('statusChangeThreshold', parseInt(e.currentTarget.value, 10))} - label={statusChangeThresholdLabel} - labelPopoverText={statusChangeThresholdHelp} - disabled={!canWrite} + + onChange('lookBackWindow', value)} + onStatusChangeThresholdChange={(value) => onChange('statusChangeThreshold', value)} /> - - {getLookBackWindowLabelRuleRuns(lookBackWindow)}, - statusChangeThreshold: ( - {getStatusChangeThresholdRuleRuns(statusChangeThreshold)} - ), - }} - /> - + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_section.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_section.tsx index a6e2f282d8894..e78e1bfe6df2b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_section.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_section.tsx @@ -20,6 +20,7 @@ import { EuiText, EuiEmptyPrompt, } from '@elastic/eui'; +import { flappingOffMessage } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_message'; import { RulesSettingsFlappingFormSection, RulesSettingsFlappingFormSectionProps, @@ -121,12 +122,7 @@ export const RulesSettingsFlappingFormRight = memo((props: RulesSettingsFlapping return ( - - - + {flappingOffMessage} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/query_delay/rules_settings_query_delay_section.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/query_delay/rules_settings_query_delay_section.tsx index 468774fed6a29..3adc9257b869d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/query_delay/rules_settings_query_delay_section.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/query_delay/rules_settings_query_delay_section.tsx @@ -22,7 +22,7 @@ import { EuiEmptyPrompt, EuiTitle, } from '@elastic/eui'; -import { RulesSettingsRange } from '../rules_settings_range'; +import { RuleSettingsRangeInput } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_range_input'; const queryDelayDescription = i18n.translate( 'xpack.triggersActionsUI.rulesSettings.modal.queryDelayDescription', @@ -107,7 +107,7 @@ export const RulesSettingsQueryDelaySection = memo((props: RulesSettingsQueryDel - void; + onSuccess?: (settings: RulesSettingsFlapping) => void; } export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => { @@ -23,7 +23,7 @@ export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => { return getFlappingSettings({ http }); }; - const { data, isFetching, isError, isLoadingError, isLoading } = useQuery({ + const { data, isFetching, isError, isLoadingError, isLoading, isInitialLoading } = useQuery({ queryKey: ['getFlappingSettings'], queryFn, onSuccess, @@ -33,6 +33,7 @@ export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => { }); return { + isInitialLoading, isLoading: isLoading || isFetching, isError: isError || isLoadingError, data, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index a623c04eeea6f..e3d564afaeda8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -37,6 +37,7 @@ import { hasShowActionsCapability } from '../../lib/capabilities'; import RuleAddFooter from './rule_add_footer'; import { HealthContextProvider } from '../../context/health_context'; import { useKibana } from '../../../common/lib/kibana'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../../common/constants'; import { hasRuleChanged, haveRuleParamsChanged } from './has_rule_changed'; import { getRuleWithInvalidatedFields } from '../../lib/value_validators'; import { DEFAULT_RULE_INTERVAL, MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants'; @@ -253,11 +254,13 @@ const RuleAdd = < async function onSaveRule(): Promise { try { + const { flapping, ...restRule } = rule; const newRule = await createRule({ http, rule: { - ...rule, + ...restRule, ...(selectableConsumer && selectedConsumer ? { consumer: selectedConsumer } : {}), + ...(IS_RULE_SPECIFIC_FLAPPING_ENABLED ? { flapping } : {}), } as CreateRuleBody, }); toasts.addSuccess( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx index 592518f33edd2..9c0abae3eb2a3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx @@ -30,6 +30,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common'; import { updateRule } from '@kbn/alerts-ui-shared/src/common/apis/update_rule'; import { fetchUiConfig as triggersActionsUiConfig } from '@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../../common/constants'; import { Rule, RuleFlyoutCloseReason, @@ -204,7 +205,15 @@ export const RuleEdit = < isValidRule(rule, ruleErrors, ruleActionsErrors) && !hasActionsWithBrokenConnector ) { - const newRule = await updateRule({ http, rule, id: rule.id }); + const { flapping, ...restRule } = rule; + const newRule = await updateRule({ + http, + rule: { + ...restRule, + ...(IS_RULE_SPECIFIC_FLAPPING_ENABLED ? { flapping } : {}), + }, + id: rule.id, + }); toasts.addSuccess( i18n.translate('xpack.triggersActionsUI.sections.ruleEdit.saveSuccessNotificationText', { defaultMessage: "Updated ''{ruleName}''", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx index 8b12245f0599b..7eafae8518df0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx @@ -26,9 +26,19 @@ import { } from '../../../types'; import { RuleForm } from './rule_form'; import { coreMock } from '@kbn/core/public/mocks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ALERTING_FEATURE_ID, RecoveredActionGroup } from '@kbn/alerting-plugin/common'; import { useKibana } from '../../../common/lib/kibana'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + const toMapById = [ (acc: Map, val: { id: unknown }) => acc.set(val.id, val), new Map(), @@ -225,19 +235,21 @@ describe('rule_form', () => { } as unknown as Rule; wrapper = mountWithIntl( - {}} - errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} - operation="create" - actionTypeRegistry={actionTypeRegistry} - ruleTypeRegistry={ruleTypeRegistry} - onChangeMetaData={jest.fn()} - /> + + {}} + errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} + operation="create" + actionTypeRegistry={actionTypeRegistry} + ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={jest.fn()} + /> + ); await act(async () => { @@ -381,28 +393,30 @@ describe('rule_form', () => { } as unknown as Rule; wrapper = mountWithIntl( - {}} - errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} - operation="create" - actionTypeRegistry={actionTypeRegistry} - ruleTypeRegistry={ruleTypeRegistry} - connectorFeatureId={featureId} - onChangeMetaData={jest.fn()} - validConsumers={validConsumers} - setConsumer={mockSetConsumer} - useRuleProducer={useRuleProducer} - selectedConsumer={selectedConsumer} - /> + + {}} + errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} + operation="create" + actionTypeRegistry={actionTypeRegistry} + ruleTypeRegistry={ruleTypeRegistry} + connectorFeatureId={featureId} + onChangeMetaData={jest.fn()} + validConsumers={validConsumers} + setConsumer={mockSetConsumer} + useRuleProducer={useRuleProducer} + selectedConsumer={selectedConsumer} + /> + ); await act(async () => { @@ -1105,19 +1119,21 @@ describe('rule_form', () => { } as unknown as Rule; wrapper = mountWithIntl( - {}} - errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} - operation="create" - actionTypeRegistry={actionTypeRegistry} - ruleTypeRegistry={ruleTypeRegistry} - onChangeMetaData={jest.fn()} - /> + + {}} + errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} + operation="create" + actionTypeRegistry={actionTypeRegistry} + ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={jest.fn()} + /> + ); await act(async () => { @@ -1164,19 +1180,21 @@ describe('rule_form', () => { } as unknown as Rule; wrapper = mountWithIntl( - {}} - errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} - operation="create" - actionTypeRegistry={actionTypeRegistry} - ruleTypeRegistry={ruleTypeRegistry} - onChangeMetaData={jest.fn()} - /> + + {}} + errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }} + operation="create" + actionTypeRegistry={actionTypeRegistry} + ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={jest.fn()} + /> + ); await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index e226cee384dff..4c233a17d1b01 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -62,6 +62,7 @@ import { isActionGroupDisabledForActionTypeId, RuleActionAlertsFilterProperty, RuleActionKey, + RuleSpecificFlappingProperties, } from '@kbn/alerting-plugin/common'; import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common'; import { AlertConsumers } from '@kbn/rule-data-utils'; @@ -91,12 +92,16 @@ import { ruleTypeGroupCompare, ruleTypeUngroupedCompare, } from '../../lib/rule_type_compare'; -import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; +import { + IS_RULE_SPECIFIC_FLAPPING_ENABLED, + VIEW_LICENSE_OPTIONS_LINK, +} from '../../../common/constants'; import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants'; import { SectionLoading } from '../../components/section_loading'; import { RuleFormConsumerSelection, VALID_CONSUMERS } from './rule_form_consumer_selection'; import { getInitialInterval } from './get_initial_interval'; import { useLoadRuleTypesQuery } from '../../hooks/use_load_rule_types_query'; +import { RuleFormAdvancedOptions } from './rule_form_advanced_options'; const ENTER_KEY = 13; @@ -410,6 +415,16 @@ export const RuleForm = ({ dispatch({ command: { type: 'setAlertDelayProperty' }, payload: { key, value } }); }; + const setFlapping = (flapping: RuleSpecificFlappingProperties | null) => { + dispatch({ command: { type: 'setProperty' }, payload: { key: 'flapping', value: flapping } }); + }; + + const onAlertDelayChange = (value: string) => { + const parsedValue = value === '' ? '' : parseInt(value, 10); + setAlertDelayProperty('active', parsedValue || 1); + setAlertDelay(parsedValue || undefined); + }; + useEffect(() => { const searchValue = searchText ? searchText.trim().toLocaleLowerCase() : null; setFilteredRuleTypes( @@ -617,20 +632,6 @@ export const RuleForm = ({ )); - const labelForRuleChecked = [ - i18n.translate('xpack.triggersActionsUI.sections.ruleForm.checkFieldLabel', { - defaultMessage: 'Check every', - }), - , - ]; - const getHelpTextForInterval = () => { if (!config || !config.minimumScheduleInterval) { return ''; @@ -797,6 +798,27 @@ export const RuleForm = ({ + + {i18n.translate('xpack.triggersActionsUI.sections.ruleForm.ruleScheduleLabel', { + defaultMessage: 'Rule schedule', + })} + + + + + + } data-test-subj="intervalFormRow" display="rowCompressed" helpText={getHelpTextForInterval()} @@ -806,7 +828,12 @@ export const RuleForm = ({ } > - - - - } - />, - ]} - append={i18n.translate( - 'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldAppendLabel', - { - defaultMessage: 'consecutive matches', - } - )} - onChange={(e) => { - const value = e.target.value; - if (value === '' || INTEGER_REGEX.test(value)) { - const parsedValue = value === '' ? '' : parseInt(value, 10); - setAlertDelayProperty('active', parsedValue || 1); - setAlertDelay(parsedValue || undefined); - } - }} - /> - + + {shouldShowConsumerSelect && ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx new file mode 100644 index 0000000000000..02aa89ae20d08 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx @@ -0,0 +1,222 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { RuleFormAdvancedOptions } from './rule_form_advanced_options'; +import { useKibana } from '../../../common/lib/kibana'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; + +const queryClient = new QueryClient(); + +const http = httpServiceMock.createStartContract(); + +const mockFlappingSettings = { + lookBackWindow: 5, + statusChangeThreshold: 5, +}; + +const mockOnflappingChange = jest.fn(); +const mockAlertDelayChange = jest.fn(); + +describe('ruleFormAdvancedOptions', () => { + beforeEach(() => { + http.get.mockResolvedValue({ + look_back_window: 10, + status_change_threshold: 3, + enabled: true, + }); + useKibanaMock().services.http = http; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render correctly', async () => { + render( + + + + + + ); + + expect(await screen.findByTestId('ruleFormAdvancedOptions')).toBeInTheDocument(); + expect(await screen.findByTestId('alertDelayFormRow')).toBeInTheDocument(); + expect(await screen.findByTestId('alertFlappingFormRow')).toBeInTheDocument(); + }); + + test('should initialize correctly when global flapping is on and override is not applied', async () => { + render( + + + + + + ); + + expect(await screen.findByText('ON')).toBeInTheDocument(); + expect(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')).not.toBeChecked(); + expect(screen.queryByText('Override')).not.toBeInTheDocument(); + expect(screen.getByTestId('ruleSettingsFlappingMessage')).toHaveTextContent( + 'An alert is flapping if it changes status at least 3 times in the last 10 rule runs.' + ); + + userEvent.click(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')); + expect(mockOnflappingChange).toHaveBeenCalledWith({ + lookBackWindow: 10, + statusChangeThreshold: 3, + }); + }); + + test('should initialize correctly when global flapping is on and override is appplied', async () => { + render( + + + + + + ); + + expect(await screen.findByTestId('ruleFormAdvancedOptionsOverrideSwitch')).toBeChecked(); + expect(screen.getByText('Override')).toBeInTheDocument(); + expect(screen.getByTestId('lookBackWindowRangeInput')).toHaveValue('6'); + expect(screen.getByTestId('statusChangeThresholdRangeInput')).toHaveValue('4'); + expect(screen.getByTestId('ruleSettingsFlappingMessage')).toHaveTextContent( + 'An alert is flapping if it changes status at least 4 times in the last 6 rule runs.' + ); + + userEvent.click(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')); + expect(mockOnflappingChange).toHaveBeenCalledWith(null); + }); + + test('should not allow override when global flapping is off', async () => { + http.get.mockResolvedValue({ + look_back_window: 10, + status_change_threshold: 3, + enabled: false, + }); + useKibanaMock().services.http = http; + + render( + + + + + + ); + + expect(await screen.findByText('OFF')).toBeInTheDocument(); + expect(screen.queryByText('Override')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ruleFormAdvancedOptionsOverrideSwitch')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ruleSettingsFlappingMessage')).not.toBeInTheDocument(); + }); + + test('should allow for flapping inputs to be modified', async () => { + render( + + + + + + ); + + expect(await screen.findByTestId('lookBackWindowRangeInput')).toBeInTheDocument(); + + const lookBackWindowInput = screen.getByTestId('lookBackWindowRangeInput'); + const statusChangeThresholdInput = screen.getByTestId('statusChangeThresholdRangeInput'); + + // Change lookBackWindow to a smaller value + fireEvent.change(lookBackWindowInput, { target: { value: 5 } }); + // statusChangeThresholdInput gets pinned to be 5 + expect(mockOnflappingChange).toHaveBeenLastCalledWith({ + lookBackWindow: 5, + statusChangeThreshold: 5, + }); + + // Try making statusChangeThreshold bigger + fireEvent.change(statusChangeThresholdInput, { target: { value: 20 } }); + // Still pinned + expect(mockOnflappingChange).toHaveBeenLastCalledWith({ + lookBackWindow: 10, + statusChangeThreshold: 10, + }); + + fireEvent.change(statusChangeThresholdInput, { target: { value: 3 } }); + expect(mockOnflappingChange).toHaveBeenLastCalledWith({ + lookBackWindow: 10, + statusChangeThreshold: 3, + }); + }); + + test('should not render flapping if enableFlapping is false', () => { + render( + + + + + + ); + + expect(screen.queryByTestId('alertFlappingFormRow')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx new file mode 100644 index 0000000000000..c9ec2adc6d770 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx @@ -0,0 +1,360 @@ +/* + * 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, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiBadge, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiPanel, + EuiSwitch, + EuiText, + useIsWithinMinBreakpoint, + useEuiTheme, + EuiHorizontalRule, + EuiSpacer, + EuiSplitPanel, + EuiLoadingSpinner, + EuiLink, +} from '@elastic/eui'; +import { RuleSettingsFlappingInputs } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs'; +import { RuleSettingsFlappingMessage } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_message'; +import { RuleSpecificFlappingProperties } from '@kbn/alerting-plugin/common'; +import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings'; + +const alertDelayFormRowLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.alertDelayLabel', + { + defaultMessage: 'Alert delay', + } +); + +const alertDelayIconTipDescription = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldHelp', + { + defaultMessage: + 'An alert occurs only when the specified number of consecutive runs meet the rule conditions.', + } +); + +const alertDelayPrependLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldLabel', + { + defaultMessage: 'Alert after', + } +); + +const alertDelayAppendLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldAppendLabel', + { + defaultMessage: 'consecutive matches', + } +); + +const flappingLabel = i18n.translate( + 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingLabel', + { + defaultMessage: 'Flapping Detection', + } +); + +const flappingOnLabel = i18n.translate('xpack.triggersActionsUI.ruleFormAdvancedOptions.onLabel', { + defaultMessage: 'ON', +}); + +const flappingOffLabel = i18n.translate( + 'xpack.triggersActionsUI.ruleFormAdvancedOptions.offLabel', + { + defaultMessage: 'OFF', + } +); + +const flappingOverrideLabel = i18n.translate( + 'xpack.triggersActionsUI.ruleFormAdvancedOptions.overrideLabel', + { + defaultMessage: 'Override', + } +); + +const flappingOverrideConfiguration = i18n.translate( + 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingOverrideConfiguration', + { + defaultMessage: 'Override Configuration', + } +); + +const flappingExternalLinkLabel = i18n.translate( + 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingExternalLinkLabel', + { + defaultMessage: "What's this?", + } +); + +const flappingFormRowLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.flappingLabel', + { + defaultMessage: 'Alert flapping detection', + } +); + +const flappingIconTipDescription = i18n.translate( + 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingIconTipDescription', + { + defaultMessage: + 'Detect alerts that switch quickly between active and recovered states and reduce unwanted noise for these flapping alerts.', + } +); + +const clampFlappingValues = (flapping: RuleSpecificFlappingProperties) => { + return { + ...flapping, + statusChangeThreshold: Math.min(flapping.lookBackWindow, flapping.statusChangeThreshold), + }; +}; + +const INTEGER_REGEX = /^[1-9][0-9]*$/; + +export interface RuleFormAdvancedOptionsProps { + alertDelay?: number; + flappingSettings?: RuleSpecificFlappingProperties; + onAlertDelayChange: (value: string) => void; + onFlappingChange: (value: RuleSpecificFlappingProperties | null) => void; + enabledFlapping?: boolean; +} + +export const RuleFormAdvancedOptions = (props: RuleFormAdvancedOptionsProps) => { + const { + alertDelay, + flappingSettings, + enabledFlapping = true, + onAlertDelayChange, + onFlappingChange, + } = props; + + const cachedFlappingSettings = useRef(); + + const isDesktop = useIsWithinMinBreakpoint('xl'); + + const { euiTheme } = useEuiTheme(); + + const { data: spaceFlappingSettings, isInitialLoading } = useGetFlappingSettings({ + enabled: enabledFlapping, + }); + + const internalOnAlertDelayChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || INTEGER_REGEX.test(value)) { + onAlertDelayChange(value); + } + }, + [onAlertDelayChange] + ); + + const internalOnFlappingChange = useCallback( + (flapping: RuleSpecificFlappingProperties) => { + const clampedValue = clampFlappingValues(flapping); + onFlappingChange(clampedValue); + cachedFlappingSettings.current = clampedValue; + }, + [onFlappingChange] + ); + + const onLookBackWindowChange = useCallback( + (value: number) => { + if (!flappingSettings) { + return; + } + internalOnFlappingChange({ + ...flappingSettings, + lookBackWindow: value, + }); + }, + [flappingSettings, internalOnFlappingChange] + ); + + const onStatusChangeThresholdChange = useCallback( + (value: number) => { + if (!flappingSettings) { + return; + } + internalOnFlappingChange({ + ...flappingSettings, + statusChangeThreshold: value, + }); + }, + [flappingSettings, internalOnFlappingChange] + ); + + const onFlappingToggle = useCallback(() => { + if (!spaceFlappingSettings) { + return; + } + if (flappingSettings) { + cachedFlappingSettings.current = flappingSettings; + return onFlappingChange(null); + } + const initialFlappingSettings = cachedFlappingSettings.current || spaceFlappingSettings; + onFlappingChange({ + lookBackWindow: initialFlappingSettings.lookBackWindow, + statusChangeThreshold: initialFlappingSettings.statusChangeThreshold, + }); + }, [spaceFlappingSettings, flappingSettings, onFlappingChange]); + + const flappingFormHeader = useMemo(() => { + if (!spaceFlappingSettings) { + return null; + } + const { enabled } = spaceFlappingSettings; + + return ( + + + + + {flappingLabel} + + + {enabled ? flappingOnLabel : flappingOffLabel} + + {flappingSettings && enabled && ( + {flappingOverrideLabel} + )} + + + {enabled && ( + + )} + {!enabled && ( + // TODO: Add the help link here + + {flappingExternalLinkLabel} + + )} + + + {flappingSettings && ( + <> + + + + )} + + ); + }, [isDesktop, euiTheme, spaceFlappingSettings, flappingSettings, onFlappingToggle]); + + const flappingFormBody = useMemo(() => { + if (!flappingSettings) { + return null; + } + return ( + + + + ); + }, [flappingSettings, onLookBackWindowChange, onStatusChangeThresholdChange]); + + const flappingFormMessage = useMemo(() => { + if (!spaceFlappingSettings || !spaceFlappingSettings.enabled) { + return null; + } + const settingsToUse = flappingSettings || spaceFlappingSettings; + return ( + + + + ); + }, [spaceFlappingSettings, flappingSettings, euiTheme]); + + return ( + + + + + {alertDelayFormRowLabel} + + + + + } + data-test-subj="alertDelayFormRow" + display="rowCompressed" + > + + + + {isInitialLoading && } + {spaceFlappingSettings && enabledFlapping && ( + + + {flappingFormRowLabel} + + + + + } + data-test-subj="alertFlappingFormRow" + display="rowCompressed" + > + + + + {flappingFormHeader} + {flappingFormBody} + + + {flappingFormMessage} + + + + )} + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index ad559429df728..a2b54a0562f66 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -26,6 +26,9 @@ export { I18N_WEEKDAY_OPTIONS_DDD, } from '@kbn/alerts-ui-shared/src/common/constants/i18n_weekdays'; +// Feature flag for frontend rule specific flapping in rule flyout +export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = false; + export const builtInComparators: { [key: string]: Comparator } = { [COMPARATORS.GREATER_THAN]: { text: i18n.translate('xpack.triggersActionsUI.common.constants.comparators.isAboveLabel', { From 2a8b6d0a4490fbf09ccd3d03ab1b3a1fcfa9ec1c Mon Sep 17 00:00:00 2001 From: Ilya Nikokoshev Date: Thu, 22 Aug 2024 01:25:12 +0000 Subject: [PATCH 19/45] [Automatic Import] Better recognize (ND)JSON formats and send samplesFormat to the backend (#190588) ## Summary This adds a `samplesFormat` group to the API. This group is filled out by the frontend when parsing the provided samples and used to set the log parsing specification for the produced integration. We check this parameter to add toggle to support multiline newline-delimited JSON in the filestream input. ## Release note Automatic Import now supports the 'multiline newline-delimited JSON' log sample format for the Filestream input. ## Detailed Explanation We add the optional `samplesFormat` group to the API, consisting of - `name`, - (optional) `multiline`, - and (optional) `json_path`. Example values of this parameter: - `{ name: 'ndjson', multiline: false }` for a newline-delimited JSON, known as [NDJSON](https://github.com/ndjson/ndjson-spec) (where each entry only takes one line) - `{ name: 'ndjson', multiline: true }` for newline-delimited JSON where each entry can span multiline lines - `{ name: 'json', json_path: [] }` for valid JSON with the structure `[{"key": "message1"}, {"key": "message2"}]` - `{ name: 'json', json_path: ['events'] }` for valid JSON with the structure `{"events": [{"key": "message1"}, {"key": "message2"}]}` The `json_path` parameter is only relevant for `name: 'json'` and refers to the path in the original JSON to the array representing the events to ingest. Currently only one level is recognized: Not all combinations of a log format with input type will work; more supported combinations as well as better user feedback on unsupported combinations will come later (see https://github.com/elastic/security-team/issues/10290). In this PR we add support for the multiline NDJSON format for the `fileinput` input type. This support comes in the form of the user-changeable toggle under "Advanced Settings" that will be set to on in cases where we multiline NDJSON format --------- Co-authored-by: Marius Iversen Co-authored-by: Elastic Machine --- .../__jest__/fixtures/build_integration.ts | 1 + .../__jest__/fixtures/ecs_mapping.ts | 2 +- .../common/api/model/api_test.mock.ts | 1 + .../api/model/common_attributes.schema.yaml | 28 +++ .../common/api/model/common_attributes.ts | 32 +++- .../integration_assistant/common/index.ts | 1 + .../mocks/state.ts | 1 + .../sample_logs_input.test.tsx | 167 +++++++++++++++++- .../data_stream_step/sample_logs_input.tsx | 113 +++++++++--- .../steps/deploy_step/deploy_step.test.tsx | 1 + .../deploy_step/use_deploy_integration.ts | 7 + .../create_integration_assistant/types.ts | 3 +- .../server/graphs/ecs/graph.ts | 2 +- .../server/graphs/ecs/pipeline.ts | 2 +- .../server/integration_builder/data_stream.ts | 3 + .../routes/build_integration_routes.test.ts | 1 + .../server/templates/agent/filestream.yml.hbs | 7 + .../manifest/filestream_manifest.yml.njk | 10 ++ .../integration_assistant/server/types.ts | 2 +- 19 files changed, 350 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/integration_assistant/__jest__/fixtures/build_integration.ts b/x-pack/plugins/integration_assistant/__jest__/fixtures/build_integration.ts index 78228d5a4cbca..3161f06f8a6ae 100644 --- a/x-pack/plugins/integration_assistant/__jest__/fixtures/build_integration.ts +++ b/x-pack/plugins/integration_assistant/__jest__/fixtures/build_integration.ts @@ -42,6 +42,7 @@ export const testIntegration: Integration = { }, ], }, + samplesFormat: { name: 'ndjson', multiline: false }, }, ], }; diff --git a/x-pack/plugins/integration_assistant/__jest__/fixtures/ecs_mapping.ts b/x-pack/plugins/integration_assistant/__jest__/fixtures/ecs_mapping.ts index d9195f3eca1a9..112f9f2348dec 100644 --- a/x-pack/plugins/integration_assistant/__jest__/fixtures/ecs_mapping.ts +++ b/x-pack/plugins/integration_assistant/__jest__/fixtures/ecs_mapping.ts @@ -446,7 +446,7 @@ export const ecsTestState = { missingKeys: [], invalidEcsFields: [], results: { test: 'testresults' }, - logFormat: 'testlogformat', + samplesFormat: 'testsamplesFormat', ecsVersion: 'testversion', currentMapping: { test1: 'test1' }, lastExecutedChain: 'testchain', diff --git a/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts b/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts index 92208abd04832..e0205a231babd 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts +++ b/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts @@ -26,6 +26,7 @@ export const getDataStreamMock = (): DataStream => ({ ], rawSamples, pipeline: getPipelineMock(), + samplesFormat: { name: 'ndjson', multiline: false }, }); export const getIntegrationMock = (): Integration => ({ diff --git a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml index 6ded459c876a1..7839a2dd3eaf7 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml +++ b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml @@ -36,6 +36,30 @@ components: items: type: object + SamplesFormatName: + type: string + description: The name of the log samples format. + enum: + - ndjson + - json + + SamplesFormat: + type: object + description: Format of the provided log samples. + required: + - name + properties: + name: + $ref: "#/components/schemas/SamplesFormatName" + multiline: + type: boolean + description: For some formats, specifies whether the samples can be multiline. + json_path: + type: array + description: For a JSON format, describes how to get to the sample array from the root of the JSON. + items: + type: string + Pipeline: type: object description: The pipeline object. @@ -92,6 +116,7 @@ components: - rawSamples - pipeline - docs + - samplesFormat properties: name: type: string @@ -116,6 +141,9 @@ components: docs: $ref: "#/components/schemas/Docs" description: The documents of the dataStream. + samplesFormat: + $ref: "#/components/schemas/SamplesFormat" + description: The format of log samples in this dataStream. Integration: type: object diff --git a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.ts b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.ts index 1c5bcf970a1b4..07d5323dc0969 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.ts +++ b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.ts @@ -45,6 +45,30 @@ export const Connector = z.string(); export type Docs = z.infer; export const Docs = z.array(z.object({}).passthrough()); +/** + * The name of the log samples format. + */ +export type SamplesFormatName = z.infer; +export const SamplesFormatName = z.enum(['ndjson', 'json']); +export type SamplesFormatNameEnum = typeof SamplesFormatName.enum; +export const SamplesFormatNameEnum = SamplesFormatName.enum; + +/** + * Format of the provided log samples. + */ +export type SamplesFormat = z.infer; +export const SamplesFormat = z.object({ + name: SamplesFormatName, + /** + * For some formats, specifies whether the samples can be multiline. + */ + multiline: z.boolean().optional(), + /** + * For a JSON format, describes how to get to the sample array from the root of the JSON. + */ + json_path: z.array(z.string()).optional(), +}); + /** * The pipeline object. */ @@ -128,6 +152,10 @@ export const DataStream = z.object({ * The documents of the dataStream. */ docs: Docs, + /** + * The format of log samples in this dataStream. + */ + samplesFormat: SamplesFormat, }); /** @@ -163,11 +191,11 @@ export const Integration = z.object({ export type LangSmithOptions = z.infer; export const LangSmithOptions = z.object({ /** - * The project name to use with tracing. + * The project name. */ projectName: z.string(), /** - * The api key for the project + * The apiKey to use for tracing. */ apiKey: z.string(), }); diff --git a/x-pack/plugins/integration_assistant/common/index.ts b/x-pack/plugins/integration_assistant/common/index.ts index c49e2825d8206..6a473d976fa88 100644 --- a/x-pack/plugins/integration_assistant/common/index.ts +++ b/x-pack/plugins/integration_assistant/common/index.ts @@ -22,6 +22,7 @@ export type { Integration, Pipeline, Docs, + SamplesFormat, } from './api/model/common_attributes'; export type { ESProcessorItem } from './api/model/processor_attributes'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts index 3b356c39dea7f..aa310f034290c 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts @@ -420,6 +420,7 @@ export const mockState: State = { dataStreamDescription: 'Mocked Data Stream Description', inputTypes: ['filestream'], logsSampleParsed: rawSamples, + samplesFormat: { name: 'ndjson', multiline: false }, }, isGenerating: false, result, diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.test.tsx index a137933afed3f..4c15aa8a4785c 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { act, fireEvent, render, waitFor, type RenderResult } from '@testing-library/react'; import { TestProvider } from '../../../../../mocks/test_provider'; -import { SampleLogsInput } from './sample_logs_input'; +import { parseNDJSON, parseJSONArray, SampleLogsInput } from './sample_logs_input'; import { ActionsProvider } from '../../state'; import { mockActions } from '../../mocks/state'; import { mockServices } from '../../../../../services/mocks/services'; @@ -27,6 +27,119 @@ const changeFile = async (input: HTMLElement, file: File) => { }); }; +const simpleNDJSON = `{"message":"test message 1"}\n{"message":"test message 2"}`; +const multilineNDJSON = `{"message":"test message 1"}\n\n{\n "message":\n "test message 2"\n}\n\n`; +const splitNDJSON = simpleNDJSON.split('\n'); +const complexEventsJSON = `{"events":[\n{"message":"test message 1"},\n{"message":"test message 2"}\n]}`; +const nonIdentifierLikeKeyInJSON = `{"1event":[\n{"message":"test message 1"},\n{"message":"test message 2"}\n]}`; + +describe('parseNDJSON', () => { + const content = [{ message: 'test message 1' }, { message: 'test message 2' }]; + const validNDJSONWithSpaces = `{"message":"test message 1"} + {"message":"test message 2"}`; + const singlelineArray = '[{"message":"test message 1"}, {"message":"test message 2"}]'; + const multilineArray = '[{"message":"test message 1"},\n{"message":"test message 2"}]'; + + it('should parse valid NDJSON', () => { + expect(parseNDJSON(simpleNDJSON, false)).toEqual(content); + expect(parseNDJSON(simpleNDJSON, true)).toEqual(content); + }); + + it('should parse valid NDJSON with extra spaces in single-line mode', () => { + expect(parseNDJSON(validNDJSONWithSpaces, false)).toEqual(content); + }); + + it('should not parse valid NDJSON with extra spaces in multiline mode', () => { + expect(() => parseNDJSON(validNDJSONWithSpaces, true)).toThrow(); + }); + + it('should not parse multiline NDJSON in single-line mode', () => { + expect(() => parseNDJSON(multilineNDJSON, false)).toThrow(); + }); + + it('should parse multiline NDJSON in multiline mode', () => { + expect(parseNDJSON(multilineNDJSON, true)).toEqual(content); + }); + + it('should parse single-line JSON Array', () => { + expect(parseNDJSON(singlelineArray, false)).toEqual([content]); + expect(parseNDJSON(singlelineArray, true)).toEqual([content]); + }); + + it('should not parse a multi-line JSON Array', () => { + expect(() => parseNDJSON(multilineArray, false)).toThrow(); + expect(() => parseNDJSON(multilineArray, true)).toThrow(); + }); + + it('should parse single-line JSON with one entry', () => { + const fileContent = '{"message":"test message 1"}'; + expect(parseNDJSON(fileContent)).toEqual([{ message: 'test message 1' }]); + }); + + it('should handle empty content', () => { + expect(parseNDJSON(' ', false)).toEqual([]); + expect(parseNDJSON(' ', true)).toEqual([]); + }); + + it('should handle empty lines in file content', () => { + const fileContent = '\n\n{"message":"test message 1"}\n\n{"message":"test message 2"}\n\n'; + expect(parseNDJSON(fileContent, false)).toEqual(content); + expect(parseNDJSON(fileContent, true)).toEqual(content); + }); +}); + +describe('parseJSONArray', () => { + const content = [{ message: 'test message 1' }, { message: 'test message 2' }]; + const singlelineArray = '[{"message":"test message 1"},{"message":"test message 2"}]'; + const multilineArray = '[{"message":"test message 1"},\n{"message":"test message 2"}]'; + const multilineWithSpacesArray = + ' [ \n\n{"message": "test message 1"},\n{"message" :\n\n"test message 2"}\n]\n'; + const malformedJSON = '[{"message":"test message 1"}'; + + it('should parse valid JSON array', () => { + const expected = { + entries: content, + pathToEntries: [], + errorNoArrayFound: false, + }; + expect(parseJSONArray(singlelineArray)).toEqual(expected); + expect(parseJSONArray(multilineArray)).toEqual(expected); + expect(parseJSONArray(multilineWithSpacesArray)).toEqual(expected); + }); + + it('should parse valid JSON object with array entries', () => { + const expected = { + entries: content, + pathToEntries: ['events'], + errorNoArrayFound: false, + }; + expect(parseJSONArray(complexEventsJSON)).toEqual(expected); + }); + + it('should pass even if the JSON object with array entries has not an identifier-like key', () => { + const expected = { + entries: content, + pathToEntries: ['1event'], + errorNoArrayFound: false, + }; + expect(parseJSONArray(nonIdentifierLikeKeyInJSON)).toEqual(expected); + }); + + it('should return error for JSON that does not contain an array', () => { + const fileContent = '{"records" : {"message": "test message 1"}}'; + const expected = { + entries: [], + pathToEntries: [], + errorNoArrayFound: true, + }; + expect(parseJSONArray(fileContent)).toEqual(expected); + }); + + it('should throw an error for invalid JSON object', () => { + expect(() => parseJSONArray(malformedJSON)).toThrow(); + }); +}); + describe('SampleLogsInput', () => { let result: RenderResult; let input: HTMLElement; @@ -49,6 +162,7 @@ describe('SampleLogsInput', () => { it('should set the integrationSetting correctly', () => { expect(mockActions.setIntegrationSettings).toBeCalledWith({ logsSampleParsed: logsSampleRaw.split(','), + samplesFormat: { name: 'json', json_path: [] }, }); }); @@ -61,6 +175,7 @@ describe('SampleLogsInput', () => { it('should truncate the logs sample', () => { expect(mockActions.setIntegrationSettings).toBeCalledWith({ logsSampleParsed: tooLargeLogsSample.split(',').slice(0, 10), + samplesFormat: { name: 'json', json_path: [] }, }); }); it('should add a notification toast', () => { @@ -71,6 +186,19 @@ describe('SampleLogsInput', () => { }); }); + describe('when the file is a json array under a key', () => { + beforeEach(async () => { + await changeFile(input, new File([complexEventsJSON], 'test.json', { type })); + }); + + it('should set the integrationSetting correctly', () => { + expect(mockActions.setIntegrationSettings).toBeCalledWith({ + logsSampleParsed: splitNDJSON, + samplesFormat: { name: 'json', json_path: ['events'] }, + }); + }); + }); + describe('when the file is invalid', () => { describe.each([ [ @@ -91,6 +219,7 @@ describe('SampleLogsInput', () => { it('should set the integrationSetting correctly', () => { expect(mockActions.setIntegrationSettings).toBeCalledWith({ logsSampleParsed: undefined, + samplesFormat: undefined, }); }); }); @@ -101,19 +230,19 @@ describe('SampleLogsInput', () => { const type = 'application/x-ndjson'; describe('when the file is valid ndjson', () => { - const logsSampleRaw = `{"message":"test message 1"}\n{"message":"test message 2"}`; beforeEach(async () => { - await changeFile(input, new File([logsSampleRaw], 'test.json', { type })); + await changeFile(input, new File([simpleNDJSON], 'test.json', { type })); }); it('should set the integrationSetting correctly', () => { expect(mockActions.setIntegrationSettings).toBeCalledWith({ - logsSampleParsed: logsSampleRaw.split('\n'), + logsSampleParsed: splitNDJSON, + samplesFormat: { name: 'ndjson', multiline: false }, }); }); describe('when the file has too many rows', () => { - const tooLargeLogsSample = Array(6).fill(logsSampleRaw).join('\n'); // 12 entries + const tooLargeLogsSample = Array(6).fill(simpleNDJSON).join('\n'); // 12 entries beforeEach(async () => { await changeFile(input, new File([tooLargeLogsSample], 'test.json', { type })); }); @@ -121,6 +250,7 @@ describe('SampleLogsInput', () => { it('should truncate the logs sample', () => { expect(mockActions.setIntegrationSettings).toBeCalledWith({ logsSampleParsed: tooLargeLogsSample.split('\n').slice(0, 10), + samplesFormat: { name: 'ndjson', multiline: false }, }); }); it('should add a notification toast', () => { @@ -131,6 +261,32 @@ describe('SampleLogsInput', () => { }); }); + describe('when the file is a an ndjson with a single record', () => { + beforeEach(async () => { + await changeFile(input, new File([multilineNDJSON.split('\n')[0]], 'test.json', { type })); + }); + + it('should set the integrationSetting correctly', () => { + expect(mockActions.setIntegrationSettings).toBeCalledWith({ + logsSampleParsed: [splitNDJSON[0]], + samplesFormat: { name: 'ndjson', multiline: false }, + }); + }); + }); + + describe('when the file is multiline ndjson', () => { + beforeEach(async () => { + await changeFile(input, new File([multilineNDJSON], 'test.json', { type })); + }); + + it('should set the integrationSetting correctly', () => { + expect(mockActions.setIntegrationSettings).toBeCalledWith({ + logsSampleParsed: splitNDJSON, + samplesFormat: { name: 'ndjson', multiline: true }, + }); + }); + }); + describe('when the file is invalid', () => { describe.each([ [ @@ -151,6 +307,7 @@ describe('SampleLogsInput', () => { it('should set the integrationSetting correctly', () => { expect(mockActions.setIntegrationSettings).toBeCalledWith({ logsSampleParsed: undefined, + samplesFormat: undefined, }); }); }); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.tsx index cb4f735cc707c..fd9c2e3f8c362 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/sample_logs_input.tsx @@ -12,46 +12,105 @@ import { isPlainObject } from 'lodash/fp'; import type { IntegrationSettings } from '../../types'; import * as i18n from './translations'; import { useActions } from '../../state'; +import type { SamplesFormat } from '../../../../../../common'; const MaxLogsSampleRows = 10; +/** + * Parse the logs sample file content as newiline-delimited JSON (NDJSON). + * + * This supports multiline JSON objects if passed multiline flag. + * Note that in that case the { character must happen at the beginning of the + * line if and only if it denotes the start of a new JSON object. Thus some + * inputs that will be parsed as NDJSON without the multiline flag will _not_ be + * parsed as NDJSON with the multiline flag. + */ +export const parseNDJSON = (fileContent: string, multiline: boolean = false): unknown[] => { + const separator = multiline ? /\n(?=\{)/ : '\n'; + + return fileContent + .split(separator) // For multiline, split at newline followed by '{'. + .filter((entry) => entry.trim() !== '') // Remove empty entries. + .map((entry) => JSON.parse(entry)); // Parse each entry as JSON. +}; + +/** + * Parse the logs sample file content as a JSON, find an array of entries there. + * + * If the JSON object can be parsed, but is not an array, we try to find a candidate + * among the dictionary keys (it must be identifier-like and its value must be an array). + * + * @returns Both the parsed entries and the path to the entries in the JSON object in case of + * success. Otherwise, an errorNoArrayFound if appropriate. If the parsing failed, raises an error. + */ +export const parseJSONArray = ( + fileContent: string +): { entries: unknown[]; pathToEntries: string[]; errorNoArrayFound: boolean } => { + const jsonContent = JSON.parse(fileContent); + if (Array.isArray(jsonContent)) { + return { entries: jsonContent, pathToEntries: [], errorNoArrayFound: false }; + } + if (typeof jsonContent === 'object' && jsonContent !== null) { + const arrayKeys = Object.keys(jsonContent).filter((key) => Array.isArray(jsonContent[key])); + if (arrayKeys.length === 1) { + const key = arrayKeys[0]; + return { + entries: jsonContent[key], + pathToEntries: [key], + errorNoArrayFound: false, + }; + } + } + return { errorNoArrayFound: true, entries: [], pathToEntries: [] }; +}; + /** * Parse the logs sample file content (json or ndjson) and return the parsed logs sample */ const parseLogsContent = ( fileContent: string | undefined -): { error?: string; isTruncated?: boolean; logsSampleParsed?: string[] } => { +): { + error?: string; + isTruncated?: boolean; + logsSampleParsed?: string[]; + samplesFormat?: SamplesFormat; +} => { if (fileContent == null) { return { error: i18n.LOGS_SAMPLE_ERROR.CAN_NOT_READ }; } - let parsedContent; + let parsedContent: unknown[]; + let samplesFormat: SamplesFormat; + try { - parsedContent = fileContent - .split('\n') - .filter((line) => line.trim() !== '') - .map((line) => JSON.parse(line)); + parsedContent = parseNDJSON(fileContent); // Special case for files that can be parsed as both JSON and NDJSON: - // for a one-line array [] -> extract its contents - // for a one-line object {} -> do nothing - if ( - Array.isArray(parsedContent) && - parsedContent.length === 1 && - Array.isArray(parsedContent[0]) - ) { + // for a one-line array [] -> extract its contents (it's a JSON) + // for a one-line object {} -> do nothing (keep as NDJSON) + if (parsedContent.length === 1 && Array.isArray(parsedContent[0])) { parsedContent = parsedContent[0]; + samplesFormat = { name: 'json', json_path: [] }; + } else { + samplesFormat = { name: 'ndjson', multiline: false }; } } catch (parseNDJSONError) { try { - parsedContent = JSON.parse(fileContent); + const { entries, pathToEntries, errorNoArrayFound } = parseJSONArray(fileContent); + if (errorNoArrayFound) { + return { error: i18n.LOGS_SAMPLE_ERROR.NOT_ARRAY }; + } + parsedContent = entries; + samplesFormat = { name: 'json', json_path: pathToEntries }; } catch (parseJSONError) { - return { error: i18n.LOGS_SAMPLE_ERROR.CAN_NOT_PARSE }; + try { + parsedContent = parseNDJSON(fileContent, true); + samplesFormat = { name: 'ndjson', multiline: true }; + } catch (parseMultilineNDJSONError) { + return { error: i18n.LOGS_SAMPLE_ERROR.CAN_NOT_PARSE }; + } } } - if (!Array.isArray(parsedContent)) { - return { error: i18n.LOGS_SAMPLE_ERROR.NOT_ARRAY }; - } if (parsedContent.length === 0) { return { error: i18n.LOGS_SAMPLE_ERROR.EMPTY }; } @@ -67,7 +126,7 @@ const parseLogsContent = ( } const logsSampleParsed = parsedContent.map((log) => JSON.stringify(log)); - return { isTruncated, logsSampleParsed }; + return { isTruncated, logsSampleParsed, samplesFormat }; }; interface SampleLogsInputProps { @@ -84,18 +143,27 @@ export const SampleLogsInput = React.memo(({ integrationSe const logsSampleFile = files?.[0]; if (logsSampleFile == null) { setSampleFileError(undefined); - setIntegrationSettings({ ...integrationSettings, logsSampleParsed: undefined }); + setIntegrationSettings({ + ...integrationSettings, + logsSampleParsed: undefined, + samplesFormat: undefined, + }); return; } const reader = new FileReader(); reader.onload = function (e) { const fileContent = e.target?.result as string | undefined; // We can safely cast to string since we call `readAsText` to load the file. - const { error, isTruncated, logsSampleParsed } = parseLogsContent(fileContent); + const { error, isTruncated, logsSampleParsed, samplesFormat } = + parseLogsContent(fileContent); setIsParsing(false); setSampleFileError(error); if (error) { - setIntegrationSettings({ ...integrationSettings, logsSampleParsed: undefined }); + setIntegrationSettings({ + ...integrationSettings, + logsSampleParsed: undefined, + samplesFormat: undefined, + }); return; } @@ -106,6 +174,7 @@ export const SampleLogsInput = React.memo(({ integrationSe setIntegrationSettings({ ...integrationSettings, logsSampleParsed, + samplesFormat, }); }; setIsParsing(true); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.test.tsx index 094d4bd37ad31..d4920ba927d20 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.test.tsx @@ -34,6 +34,7 @@ const parameters: BuildIntegrationRequestBody = { rawSamples: integrationSettings.logsSampleParsed!, docs: results.docs!, pipeline: results.pipeline, + samplesFormat: integrationSettings.samplesFormat!, }, ], }, diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts index 7e12cdad8f611..c1451a9d81a9d 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts @@ -46,6 +46,12 @@ export const useDeployIntegration = ({ (async () => { try { + if (integrationSettings.samplesFormat == null) { + throw new Error( + 'Logic error: samplesFormat is required and cannot be null or undefined when creating integration.' + ); + } + const parameters: BuildIntegrationRequestBody = { integration: { title: integrationSettings.title ?? '', @@ -61,6 +67,7 @@ export const useDeployIntegration = ({ rawSamples: integrationSettings.logsSampleParsed ?? [], docs: result.docs ?? [], pipeline: result.pipeline, + samplesFormat: integrationSettings.samplesFormat, }, ], }, diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts index ec0ea443d37c7..c924415ec53e1 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts @@ -8,7 +8,7 @@ import type { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import type { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; -import type { InputType } from '../../../../common'; +import type { InputType, SamplesFormat } from '../../../../common'; interface GenAiConfig { apiUrl?: string; @@ -34,4 +34,5 @@ export interface IntegrationSettings { dataStreamName?: string; inputTypes?: InputType[]; logsSampleParsed?: string[]; + samplesFormat?: SamplesFormat; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts index 2c8e7283d4728..1d02f3c8970d8 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts @@ -82,7 +82,7 @@ const graphState: StateGraphArgs['channels'] = { value: (x: object, y?: object) => y ?? x, default: () => ({}), }, - logFormat: { + samplesFormat: { value: (x: string, y?: string) => y ?? x, default: () => 'json', }, diff --git a/x-pack/plugins/integration_assistant/server/graphs/ecs/pipeline.ts b/x-pack/plugins/integration_assistant/server/graphs/ecs/pipeline.ts index d925f443873e4..0dc7e772a94cf 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/ecs/pipeline.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/ecs/pipeline.ts @@ -173,7 +173,7 @@ export function createPipeline(state: EcsMappingState): IngestPipeline { ecs_version: state.ecsVersion, package_name: state.packageName, data_stream_name: state.dataStreamName, - log_format: state.logFormat, + log_format: state.samplesFormat, fields_to_remove: fieldsToRemove, }; const templatesPath = joinPath(__dirname, '../../templates'); diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts index 3fb1fa21dc753..02b3f12f53d68 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts @@ -19,6 +19,8 @@ export function createDataStream( const pipelineDir = joinPath(specificDataStreamDir, 'elasticsearch', 'ingest_pipeline'); const title = dataStream.title; const description = dataStream.description; + const samplesFormat = dataStream.samplesFormat; + const useMultilineNDJSON = samplesFormat.name === 'ndjson' && samplesFormat.multiline === true; ensureDirSync(specificDataStreamDir); createDataStreamFolders(specificDataStreamDir, pipelineDir); @@ -31,6 +33,7 @@ export function createDataStream( data_stream_description: description, package_name: packageName, data_stream_name: dataStreamName, + multiline_ndjson: useMultilineNDJSON, }; const dataStreamManifest = nunjucks.render( `${inputType.replaceAll('-', '_')}_manifest.yml.njk`, diff --git a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts index 3b73e4afb1c94..b57dd670df03f 100644 --- a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts @@ -40,6 +40,7 @@ describe('registerIntegrationBuilderRoutes', () => { processors: [{ script: { source: {} } }], }, docs: [], + samplesFormat: { name: 'ndjson', multiline: false }, }, ], }, diff --git a/x-pack/plugins/integration_assistant/server/templates/agent/filestream.yml.hbs b/x-pack/plugins/integration_assistant/server/templates/agent/filestream.yml.hbs index 437accfc32650..5732a1e67e41f 100644 --- a/x-pack/plugins/integration_assistant/server/templates/agent/filestream.yml.hbs +++ b/x-pack/plugins/integration_assistant/server/templates/agent/filestream.yml.hbs @@ -8,6 +8,13 @@ prospector.scanner.exclude_files: - {{pattern}} {{/each}} {{/if}} +{{#if multiline_json}} +multiline.pattern: '^{' +multiline.negate: true +multiline.match: after +multiline.max_lines: 5000 +multiline.timeout: 10 +{{/if}} {{#if custom}} {{custom}} {{/if}} \ No newline at end of file diff --git a/x-pack/plugins/integration_assistant/server/templates/manifest/filestream_manifest.yml.njk b/x-pack/plugins/integration_assistant/server/templates/manifest/filestream_manifest.yml.njk index f4e0781a76c27..28870a2e2d338 100644 --- a/x-pack/plugins/integration_assistant/server/templates/manifest/filestream_manifest.yml.njk +++ b/x-pack/plugins/integration_assistant/server/templates/manifest/filestream_manifest.yml.njk @@ -22,6 +22,16 @@ show_user: true default: - '\.gz$' + {% if multiline_ndjson %} + - name: multiline_ndjson + type: bool + title: Parse multiline JSON events + description: >- + Enables parsing of newline-delimited JSON-formatted events that take more than one line. Each event must start with the curly brace at the first column. + required: false + show_user: false + default: true + {% endif %} - name: custom type: yaml title: Additional Filestream Configuration Options diff --git a/x-pack/plugins/integration_assistant/server/types.ts b/x-pack/plugins/integration_assistant/server/types.ts index f98686145690e..3bbe25a8fbd0f 100644 --- a/x-pack/plugins/integration_assistant/server/types.ts +++ b/x-pack/plugins/integration_assistant/server/types.ts @@ -71,7 +71,7 @@ export interface EcsMappingState { missingKeys: string[]; invalidEcsFields: string[]; results: object; - logFormat: string; + samplesFormat: string; ecsVersion: string; } From 29a45fc645831a94d9ccf4028d91ec662f800085 Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 22 Aug 2024 08:22:34 +0100 Subject: [PATCH 20/45] [ResponseOps] Fix scss deprecation issue (#190948) Fixes #190927 ## Summary This pr fixes a small deprecation issue after the SASS upgrade. I opted into the new syntax. --- .../src/add_message_variables/add_message_variables.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kbn-alerts-ui-shared/src/add_message_variables/add_message_variables.scss b/packages/kbn-alerts-ui-shared/src/add_message_variables/add_message_variables.scss index 521d0f399b19b..d53223bd6ad0d 100644 --- a/packages/kbn-alerts-ui-shared/src/add_message_variables/add_message_variables.scss +++ b/packages/kbn-alerts-ui-shared/src/add_message_variables/add_message_variables.scss @@ -1,5 +1,6 @@ .messageVariablesPanel { - @include euiYScrollWithShadows; max-height: $euiSize * 20; max-width: $euiSize * 20; + + @include euiYScrollWithShadows; } \ No newline at end of file From 3ac931fb796b2053c68b454804e643788dc598d9 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 22 Aug 2024 09:48:35 +0200 Subject: [PATCH 21/45] fix(security, http): expose authentication headers in the authentication result when HTTP authentication is used (#190998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When Kibana tries to authenticate a request that already has an `Authorization` header (not a cookie, client certificate, or Kerberos ticket), the authentication is performed by the [HTTP authentication provider](https://www.elastic.co/guide/en/kibana/current/kibana-authentication.html#http-authentication). Unlike session/Kerberos/PKI providers, this provider returns an authentication result that doesn't explicitly tell Core which authorization headers should be used (e.g., `t.authenticated({ state: authenticationResult.user, --> requestHeaders: authenticationResult.authHeaders <-- ... });`), assuming that Core will just use the headers from the request. The `Authorization` header is forwarded to Elasticsearch by default, no additional configuration is required. This worked well previously, but I think with the introduction of the the [`getSecondaryAuthHeaders`](https://github.com/elastic/kibana/pull/184901) method this is the first time where this assumption doesn't hold. Internally, this method tries to reuse authentication headers that were provided to Core by the authentication provider during the request authentication stage — headers that the HTTP authentication provider never provided before. This PR makes the HTTP authentication provider behave consistently with the rest of the providers we support today. --- .../authentication/providers/http.test.ts | 32 +++++++++---------- .../server/authentication/providers/http.ts | 6 +++- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 90ff62294ff3f..d599b6be2d9c3 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -133,10 +133,10 @@ describe('HTTPAuthenticationProvider', () => { }); await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded({ - ...user, - authentication_provider: { type: 'http', name: 'http' }, - }) + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) ); expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); @@ -160,10 +160,10 @@ describe('HTTPAuthenticationProvider', () => { }); await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded({ - ...user, - authentication_provider: { type: 'http', name: 'http' }, - }) + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) ); expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); @@ -187,10 +187,10 @@ describe('HTTPAuthenticationProvider', () => { }); await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded({ - ...user, - authentication_provider: { type: 'http', name: 'http' }, - }) + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) ); expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); @@ -217,10 +217,10 @@ describe('HTTPAuthenticationProvider', () => { }); await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded({ - ...user, - authentication_provider: { type: 'http', name: 'http' }, - }) + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) ); expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index e23ec826ed2e1..ab7971871c704 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -113,7 +113,11 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } - return AuthenticationResult.succeeded(user); + return AuthenticationResult.succeeded(user, { + // Even though the `Authorization` header is already present in the HTTP headers of the original request, + // we still need to expose it to the Core authentication service for consistency. + authHeaders: { authorization: authorizationHeader.toString() }, + }); } catch (err) { this.logger.debug( () => From 2d44619f07daefba27b019a7dbb8ed12a5d4e3dc Mon Sep 17 00:00:00 2001 From: florent-leborgne Date: Thu, 22 Aug 2024 10:28:39 +0200 Subject: [PATCH 22/45] [Docs] Reorder What's new (#190976) ## Summary This PR updates the structure and ordering of the What's new document for 8.15 and deletes some duplicate entries ![Whats New Guide](https://github.com/user-attachments/assets/34b76d60-467f-44cb-b0d6-b6408872dddf) image image image --- docs/user/whats-new.asciidoc | 121 ++++++++++++++--------------------- 1 file changed, 49 insertions(+), 72 deletions(-) diff --git a/docs/user/whats-new.asciidoc b/docs/user/whats-new.asciidoc index 3410a889c8f26..2a726ba3dc4f3 100644 --- a/docs/user/whats-new.asciidoc +++ b/docs/user/whats-new.asciidoc @@ -7,28 +7,44 @@ check the <>. Previous versions: {kibana-ref-all}/8.14/whats-new.html[8.14] | {kibana-ref-all}/8.13/whats-new.html[8.13] | {kibana-ref-all}/8.12/whats-new.html[8.12] | {kibana-ref-all}/8.11/whats-new.html[8.11] | {kibana-ref-all}/8.10/whats-new.html[8.10] | {kibana-ref-all}/8.9/whats-new.html[8.9] | {kibana-ref-all}/8.8/whats-new.html[8.8] | {kibana-ref-all}/8.7/whats-new.html[8.7] | {kibana-ref-all}/8.6/whats-new.html[8.6] | {kibana-ref-all}/8.5/whats-new.html[8.5] | {kibana-ref-all}/8.4/whats-new.html[8.4] | {kibana-ref-all}/8.3/whats-new.html[8.3] | {kibana-ref-all}/8.2/whats-new.html[8.2] | {kibana-ref-all}/8.1/whats-new.html[8.1] | {kibana-ref-all}/8.0/whats-new.html[8.0] - [discrete] -=== Analyst Experience +=== ES|QL [discrete] -==== View dashboard creator and last editor +==== Filter UX improvements in ES|QL -You can now see who created and who last updated a dashboard. +We're thrilled to unveil a complete overhaul of filtering in the ES|QL UX. Now, you can seamlessly filter data by browsing a time series chart, allowing for quick and intuitive time-based filtering. Interactive chart filtering lets you refine your data directly by clicking on any chart, while creating WHERE clause filters from the Discover table or sidebar has never been easier. These enhancements streamline data exploration and analysis, making your ES|QL experience more efficient and user-friendly than ever. -You can find the creator information right from the dashboard list. +*Filter by clicking a chart:* -image::images/dashboard-creator.png[Dashboard creator column in dashboard list] +image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt965a5190f246f7c8/669a7d41e5f7c84793b031cb/filter-by-clicking-chart.gif[Filter by clicking a chart] -Quickly find all dashboards created by the same user with a simple filter. +*Filter by browsing a time series chart:* -image::images/dashboard-creator-filter.png[Filtering dashboards by creator] +image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blta20c9a93dded707c/669a7d40843f93a02fe51013/filter-by-brushing-time-series.gif[Filter by browsing a time series chart] -Note that the creator information will be visible only for dashboards created on or after version 8.14. +*Create WHERE clause filters from Discover table or sidebar:* -You can also see who last updated a dashboard by clicking the dashboard information icon from the dashboard list. The creator is also visible next to it. This information is immutable and cannot be changed. +image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt50ac35ab3af29ff8/669a7d4006a6fafe4c7cb39d/create-where-clause-filters-from-sidebar.gif[Create WHERE clause filters from Discover table or sidebar] + + +[discrete] +==== Field statistics in ES|QL + +Field statistics are now available in ES|QL. This feature is designed to provide comprehensive insights for each data field. With this enhancement, you can access detailed statistics such as distributions, averages, and other key metrics, helping you quickly understand your data. This makes data exploration and quality assessment more efficient, providing deeper insights and streamlining the analysis of field-level data in ES|QL. + +image::images/field-statistics-esql.png[Field statistics in ES|QL] + +[discrete] +==== Integrations support in the ES|QL editor when using FROM command. + +We're excited to announce enhanced support for integrations in the ES|QL editor with the *FROM* command. Previously, you could only access indices, but now you can also view a list of installed integrations directly within the editor. This improvement streamlines your workflow, making it easier to manage and utilize various integrations while working with your data. + +image::images/integrations-in-esql.png[Accessing an integration from ES|QL] -image::images/dashboard-last-editor.png[Dashboard details panel with the name of the last editor] + +[discrete] +=== Dashboards [discrete] ==== Field statistics in Dashboards @@ -48,50 +64,36 @@ You can find the option to select statistics for your legends along with an expl image::images/statistics-in-legends2.png[Select statistics in legends] -[discrete] -==== Array of values for Metrics - -The new **Metrics** now supports fields that show an array of values. - -image::images/array-in-metrics.png[A metric showing an array of values, width=35%] [discrete] -==== Push flyout for Discover document viewer +==== View dashboard creator and last editor -You can now seamlessly view document details and the main table simultaneously in **Discover** with the new _push_ flyout. You can adjust the width of the flyout to suit your needs and explore your data much more easily. +You can now see who created and who last updated a dashboard. -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/bltb40a408acf4ab688/669a58ea9fecd85219d58ed2/discover-push-flyout.gif[Resizable push flyout in Discover] +You can find the creator information right from the dashboard list. -[discrete] -==== Integrations support in the ES|QL editor when using FROM command. +image::images/dashboard-creator.png[Dashboard creator column in dashboard list] -We're excited to announce enhanced support for integrations in the ES|QL editor with the *FROM* command. Previously, you could only access indices, but now you can also view a list of installed integrations directly within the editor. This improvement streamlines your workflow, making it easier to manage and utilize various integrations while working with your data. +Quickly find all dashboards created by the same user with a simple filter. -image::images/integrations-in-esql.png[Accessing an integration from ES|QL] +image::images/dashboard-creator-filter.png[Filtering dashboards by creator] -[discrete] -==== Field statistics in ES|QL +Note that the creator information will be visible only for dashboards created on or after version 8.14. -Field statistics are now available in ES|QL. This feature is designed to provide comprehensive insights for each data field. With this enhancement, you can access detailed statistics such as distributions, averages, and other key metrics, helping you quickly understand your data. This makes data exploration and quality assessment more efficient, providing deeper insights and streamlining the analysis of field-level data in ES|QL. +You can also see who last updated a dashboard by clicking the dashboard information icon from the dashboard list. The creator is also visible next to it. This information is immutable and cannot be changed. -image::images/field-statistics-esql.png[Field statistics in ES|QL] +image::images/dashboard-last-editor.png[Dashboard details panel with the name of the last editor] [discrete] -==== Filter UX improvements in ES|QL - -We're thrilled to unveil a complete overhaul of filtering in the ES|QL UX. Now, you can seamlessly filter data by browsing a time series chart, allowing for quick and intuitive time-based filtering. Interactive chart filtering lets you refine your data directly by clicking on any chart, while creating WHERE clause filters from the Discover table or sidebar has never been easier. These enhancements streamline data exploration and analysis, making your ES|QL experience more efficient and user-friendly than ever. +=== Discover -*Filter by clicking a chart:* - -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt965a5190f246f7c8/669a7d41e5f7c84793b031cb/filter-by-clicking-chart.gif[Filter by clicking a chart] - -*Filter by browsing a time series chart:* +[discrete] +==== Push flyout for Discover document viewer -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blta20c9a93dded707c/669a7d40843f93a02fe51013/filter-by-brushing-time-series.gif[Filter by browsing a time series chart] +You can now seamlessly view document details and the main table simultaneously in **Discover** with the new _push_ flyout. You can adjust the width of the flyout to suit your needs and explore your data much more easily. -*Create WHERE clause filters from Discover table or sidebar:* +image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/bltb40a408acf4ab688/669a58ea9fecd85219d58ed2/discover-push-flyout.gif[Resizable push flyout in Discover] -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt50ac35ab3af29ff8/669a7d4006a6fafe4c7cb39d/create-where-clause-filters-from-sidebar.gif[Create WHERE clause filters from Discover table or sidebar] [discrete] === Alerting, cases, and connectors @@ -134,20 +136,6 @@ Analyze large volumes of logs efficiently, in very short times with Log Pattern image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt7e63d7e764ab183e/669a807bd316c7015db35458/ml-log-pattern-analysis.gif[New log pattern analysis interface] -[discrete] -==== ES|QL support for field statistics in Discover - -The Field statistics functionality now supports ES|QL, Elastic's primary query language. - -image::images/esql-field-statistics.png[Field statistics in ES|QL] - -[discrete] -==== Field statistics embeddable panel in Dashboards - -You can now add field statistics panels with ES|QL support straight within your dashboards, eliminating the need to transition between **Discover** and **Dashboards**. - -image::images/field-statistics-panel-in-dashboards.png[Field statistics embeddable panel in Dashboards] - [discrete] ==== Log Rate Analysis contextual insights in serverless Observability @@ -156,42 +144,31 @@ You can now see insights in natural language, for example for the root cause of image::images/obs-log-rate-analysis-insigths.png[Log Rate Analysis contextual insights in serverless Observability] [discrete] -==== Anthropic integration with the Inference API +==== Inference API improvements -The inference API provides a seamless, intuitive interface to perform inference and other tasks against proprietary, hosted, and integrated external services. In 8.15, we're extending it to support Anthropic's chat completion API. +The inference API provides a seamless, intuitive interface to perform inference and other tasks against proprietary, hosted, and integrated external services. In 8.15, we're extending it with the following capabilities: -[discrete] -==== Support for reranking with the Inference API +* Support for Anthropic's chat completion API. +* Ability to host cross encoder models and perform the reranking task. -In 8.15, we're also extending the inference API with the ability to host cross encoder models in Elastic and perform the reranking task. [discrete] -=== Global Experience +=== Managing {kib} users and objects [discrete] -==== Simplified Sharing +==== Sharing improvements -You can now share a dashboard, search, or lens object in one click. When sharing an object, the most common actions are directly presented to you, and a short link is automatically generated, making it simpler than ever to share your work. +You can now share a dashboard, search, or Lens object in one click. When sharing an object, the most common actions are directly presented to you, and a short link is automatically generated, making it simpler than ever to share your work. image::images/share-modal.png[New object share modal, width=50%] [discrete] -==== “My dashboards” filter - -The days of manually scrolling through an endless list of dashboards are behind you. You can now filter by creator to go directly to the dashboards created by a specific teammate. - -NOTE: Only dashboards created on or after 8.14 will have a creator. - -[discrete] -==== Quick API keys +==== Quick API key creation Many API keys don’t require custom settings, so we made it simple to generate a standard key. From the **Endpoints & API keys** top menu in Search, you can create a key in seconds. image::images/create-simple-api-key.png[Shortcut to create an API key, width=60%] -[discrete] -=== Platform Security - [discrete] ==== Filtering by User in Kibana Audit Logs From a04e5f94e4ba00808d2e9337689c87faa8fe0627 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Thu, 22 Aug 2024 11:06:42 +0200 Subject: [PATCH 23/45] [Dataset quality] Use dockerized package registry in api tests (#190688) Closes https://github.com/elastic/kibana/issues/189802. This PR aims to use a dockerized package registry version for testing. In order to test it locally you have to set the value of `FLEET_PACKAGE_REGISTRY_PORT` env var in your terminal, and you also need to have a docker daemon running. For example, you can open a terminal and start the server ``` node x-pack/plugins/observability_solution/dataset_quality/scripts/api --server ``` then open a new terminal set the var value and start the runner with the specific test using this configuration ``` export set FLEET_PACKAGE_REGISTRY_PORT=12345 node x-pack/plugins/observability_solution/dataset_quality/scripts/api --runner --grep-files=integrations ``` If you want to test again without the dockerized version, you should remove the value of the var ``` unset FLEET_PACKAGE_REGISTRY_PORT ``` --- .../dataset_quality/README.md | 15 +++++++ .../common/config.ts | 45 +++++++++++++++---- .../fixtures/package_registry_config.yml | 2 + .../integration_dashboards.spec.ts | 43 ++++++------------ .../tests/integrations/integrations.spec.ts | 33 +++----------- .../tests/integrations/package_utils.ts | 24 +++++----- 6 files changed, 85 insertions(+), 77 deletions(-) create mode 100644 x-pack/test/dataset_quality_api_integration/common/fixtures/package_registry_config.yml diff --git a/x-pack/plugins/observability_solution/dataset_quality/README.md b/x-pack/plugins/observability_solution/dataset_quality/README.md index 25f01ceb6fa60..32218e9982b6e 100755 --- a/x-pack/plugins/observability_solution/dataset_quality/README.md +++ b/x-pack/plugins/observability_solution/dataset_quality/README.md @@ -50,6 +50,21 @@ node x-pack/plugins/observability_solution/dataset_quality/scripts/api --server node x-pack/plugins/observability_solution/dataset_quality/scripts/api --runner --grep-files=data_stream_settings.spec.ts ``` +### Using dockerized package registry + +For tests using package registry we have enabled a configuration that uses a dockerized lite version to execute the tests in the CI, this will reduce the flakyness of them when calling the real endpoint. + +To be able to run this version locally you must have a docker daemon running in your systema and set `FLEET_PACKAGE_REGISTRY_PORT` env var. In order to set this variable execute + +``` +export set FLEET_PACKAGE_REGISTRY_PORT=12345 +``` + +To unset the variable, and run the tests against the real endpoint again, execute + +``` +unset FLEET_PACKAGE_REGISTRY_PORT +``` ### Functional Tests diff --git a/x-pack/test/dataset_quality_api_integration/common/config.ts b/x-pack/test/dataset_quality_api_integration/common/config.ts index e297d8eaf0354..3807addecbc71 100644 --- a/x-pack/test/dataset_quality_api_integration/common/config.ts +++ b/x-pack/test/dataset_quality_api_integration/common/config.ts @@ -5,24 +5,26 @@ * 2.0. */ +import { LogLevel, LogsSynthtraceEsClient, createLogger } from '@kbn/apm-synthtrace'; +import { createDatasetQualityUsers } from '@kbn/dataset-quality-plugin/server/test_helpers/create_dataset_quality_users'; import { - DatasetQualityUsername, DATASET_QUALITY_TEST_PASSWORD, + DatasetQualityUsername, } from '@kbn/dataset-quality-plugin/server/test_helpers/create_dataset_quality_users/authentication'; -import { createDatasetQualityUsers } from '@kbn/dataset-quality-plugin/server/test_helpers/create_dataset_quality_users'; -import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrConfigProviderContext, defineDockerServersConfig } from '@kbn/test'; +import path from 'path'; import supertest from 'supertest'; -import { format, UrlObject } from 'url'; -import { createLogger, LogLevel, LogsSynthtraceEsClient } from '@kbn/apm-synthtrace'; +import { UrlObject, format } from 'url'; +import { dockerImage } from '../../fleet_api_integration/config.base'; +import { DatasetQualityFtrConfigName } from '../configs'; +import { createDatasetQualityApiClient } from './dataset_quality_api_supertest'; import { FtrProviderContext, InheritedFtrProviderContext, InheritedServices, } from './ftr_provider_context'; -import { createDatasetQualityApiClient } from './dataset_quality_api_supertest'; -import { RegistryProvider } from './registry'; -import { DatasetQualityFtrConfigName } from '../configs'; import { PackageService } from './package_service'; +import { RegistryProvider } from './registry'; export interface DatasetQualityFtrConfig { name: DatasetQualityFtrConfigName; @@ -84,19 +86,41 @@ export function createTestConfig( const { license, name, kibanaConfig } = config; return async ({ readConfigFile }: FtrConfigProviderContext) => { + const packageRegistryConfig = path.join(__dirname, './fixtures/package_registry_config.yml'); const xPackAPITestsConfig = await readConfigFile( require.resolve('../../api_integration/config.ts') ); + const dockerArgs: string[] = ['-v', `${packageRegistryConfig}:/package-registry/config.yml`]; + const services = xPackAPITestsConfig.get('services'); const servers = xPackAPITestsConfig.get('servers'); const kibanaServer = servers.kibana as UrlObject; const kibanaServerUrl = format(kibanaServer); const esServer = servers.elasticsearch as UrlObject; + /** + * This is used by CI to set the docker registry port + * you can also define this environment variable locally when running tests which + * will spin up a local docker package registry locally for you + * if this is defined it takes precedence over the `packageRegistryOverride` variable + */ + const dockerRegistryPort: string | undefined = process.env.FLEET_PACKAGE_REGISTRY_PORT; + return { testFiles: [require.resolve('../tests')], servers, + dockerServers: defineDockerServersConfig({ + registry: { + enabled: !!dockerRegistryPort, + image: dockerImage, + portInContainer: 8080, + port: dockerRegistryPort, + args: dockerArgs, + waitForLogLine: 'package manifests loaded', + waitForLogLineTimeoutMs: 60 * 2 * 10000, // 2 minutes + }, + }), servicesRequiredForTestAnalysis: ['datasetQualityFtrConfig', 'registry'], services: { ...services, @@ -157,6 +181,11 @@ export function createTestConfig( kbnTestServer: { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ + `--xpack.fleet.packages.0.name=endpoint`, + `--xpack.fleet.packages.0.version=latest`, + ...(dockerRegistryPort + ? [`--xpack.fleet.registryUrl=http://localhost:${dockerRegistryPort}`] + : []), ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), ...(kibanaConfig ? Object.entries(kibanaConfig).map(([key, value]) => diff --git a/x-pack/test/dataset_quality_api_integration/common/fixtures/package_registry_config.yml b/x-pack/test/dataset_quality_api_integration/common/fixtures/package_registry_config.yml new file mode 100644 index 0000000000000..1885fa5c2ebe5 --- /dev/null +++ b/x-pack/test/dataset_quality_api_integration/common/fixtures/package_registry_config.yml @@ -0,0 +1,2 @@ +package_paths: + - /packages/package-storage diff --git a/x-pack/test/dataset_quality_api_integration/tests/integrations/integration_dashboards.spec.ts b/x-pack/test/dataset_quality_api_integration/tests/integrations/integration_dashboards.spec.ts index 4481e0120c8b4..63b1b029acdeb 100644 --- a/x-pack/test/dataset_quality_api_integration/tests/integrations/integration_dashboards.spec.ts +++ b/x-pack/test/dataset_quality_api_integration/tests/integrations/integration_dashboards.spec.ts @@ -8,25 +8,14 @@ import expect from '@kbn/expect'; import { DatasetQualityApiClientKey } from '../../common/config'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { installPackage, IntegrationPackage, uninstallPackage } from './package_utils'; +import { installPackage, uninstallPackage } from './package_utils'; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); const supertest = getService('supertest'); const datasetQualityApiClient = getService('datasetQualityApiClient'); - const integrationPackages: IntegrationPackage[] = [ - { - // with dashboards - name: 'postgresql', - version: '1.19.0', - }, - { - // without dashboards - name: 'apm', - version: '8.4.2', - }, - ]; + const integrationPackages = ['nginx', 'apm']; async function callApiAs(integration: string) { const user = 'datasetQualityLogsUser' as DatasetQualityApiClientKey; @@ -43,13 +32,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { registry.when('Integration dashboards', { config: 'basic' }, () => { describe('gets the installed integration dashboards', () => { before(async () => { - await Promise.all( - integrationPackages.map((pkg: IntegrationPackage) => installPackage({ supertest, pkg })) - ); + await Promise.all(integrationPackages.map((pkg) => installPackage({ supertest, pkg }))); }); it('returns a non-empty body', async () => { - const resp = await callApiAs(integrationPackages[0].name); + const resp = await callApiAs(integrationPackages[0]); expect(resp.body).not.empty(); }); @@ -57,20 +44,20 @@ export default function ApiTest({ getService }: FtrProviderContext) { const expectedResult = { dashboards: [ { - id: 'postgresql-158be870-87f4-11e7-ad9c-db80de0bf8d3', - title: '[Logs PostgreSQL] Overview', + id: 'nginx-023d2930-f1a5-11e7-a9ef-93c69af7b129', + title: '[Metrics Nginx] Overview', }, { - id: 'postgresql-4288b790-b79f-11e9-a579-f5c0a5d81340', - title: '[Metrics PostgreSQL] Database Overview', + id: 'nginx-046212a0-a2a1-11e7-928f-5dbe6f6f5519', + title: '[Logs Nginx] Access and error logs', }, { - id: 'postgresql-e4c5f230-87f3-11e7-ad9c-db80de0bf8d3', - title: '[Logs PostgreSQL] Query Duration Overview', + id: 'nginx-55a9e6e0-a29e-11e7-928f-5dbe6f6f5519', + title: '[Logs Nginx] Overview', }, ], }; - const resp = await callApiAs(integrationPackages[0].name); + const resp = await callApiAs(integrationPackages[0]); expect(resp.body).to.eql(expectedResult); }); @@ -78,7 +65,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { const expectedResult = { dashboards: [], }; - const resp = await callApiAs(integrationPackages[1].name); + const resp = await callApiAs(integrationPackages[1]); expect(resp.body).to.eql(expectedResult); }); @@ -92,11 +79,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { after( async () => - await Promise.all( - integrationPackages.map((pkg: IntegrationPackage) => - uninstallPackage({ supertest, pkg }) - ) - ) + await Promise.all(integrationPackages.map((pkg) => uninstallPackage({ supertest, pkg }))) ); }); }); diff --git a/x-pack/test/dataset_quality_api_integration/tests/integrations/integrations.spec.ts b/x-pack/test/dataset_quality_api_integration/tests/integrations/integrations.spec.ts index ee8c392e75317..13011410a0317 100644 --- a/x-pack/test/dataset_quality_api_integration/tests/integrations/integrations.spec.ts +++ b/x-pack/test/dataset_quality_api_integration/tests/integrations/integrations.spec.ts @@ -12,7 +12,6 @@ import { CustomIntegration, installCustomIntegration, installPackage, - IntegrationPackage, uninstallPackage, } from './package_utils'; @@ -21,23 +20,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const datasetQualityApiClient = getService('datasetQualityApiClient'); - const integrationPackages: IntegrationPackage[] = [ - { - // logs based integration - name: 'system', - version: '1.0.0', - }, - { - // logs based integration - name: 'apm', - version: '8.0.0', - }, - { - // non-logs based integration - name: 'synthetics', - version: '1.0.0', - }, - ]; + const integrationPackages = ['system', 'apm', 'endpoint', 'synthetics']; const customIntegrations: CustomIntegration[] = [ { @@ -67,9 +50,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { registry.when('Integration', { config: 'basic' }, () => { describe('gets the installed integrations', () => { before(async () => { - await Promise.all( - integrationPackages.map((pkg: IntegrationPackage) => installPackage({ supertest, pkg })) - ); + await Promise.all(integrationPackages.map((pkg) => installPackage({ supertest, pkg }))); }); it('returns only log based integrations and its datasets map', async () => { @@ -77,20 +58,18 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(resp.body.integrations.map((integration) => integration.name)).to.eql([ 'apm', + 'endpoint', 'system', ]); expect(resp.body.integrations[0].datasets).not.empty(); expect(resp.body.integrations[1].datasets).not.empty(); + expect(resp.body.integrations[2].datasets).not.empty(); }); after( async () => - await Promise.all( - integrationPackages.map((pkg: IntegrationPackage) => - uninstallPackage({ supertest, pkg }) - ) - ) + await Promise.all(integrationPackages.map((pkg) => uninstallPackage({ supertest, pkg }))) ); }); @@ -121,7 +100,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { customIntegrations.map((customIntegration: CustomIntegration) => uninstallPackage({ supertest, - pkg: { name: customIntegration.integrationName, version: '1.0.0' }, + pkg: customIntegration.integrationName, }) ) ) diff --git a/x-pack/test/dataset_quality_api_integration/tests/integrations/package_utils.ts b/x-pack/test/dataset_quality_api_integration/tests/integrations/package_utils.ts index ae2b9a01fa475..8d9dd9d1b051d 100644 --- a/x-pack/test/dataset_quality_api_integration/tests/integrations/package_utils.ts +++ b/x-pack/test/dataset_quality_api_integration/tests/integrations/package_utils.ts @@ -7,11 +7,6 @@ import { Agent as SuperTestAgent } from 'supertest'; -export interface IntegrationPackage { - name: string; - version: string; -} - export interface CustomIntegration { integrationName: string; datasets: IntegrationDataset[]; @@ -42,12 +37,19 @@ export async function installPackage({ pkg, }: { supertest: SuperTestAgent; - pkg: IntegrationPackage; + pkg: string; }) { - const { name, version } = pkg; + const { + body: { + item: { latestVersion: version }, + }, + } = await supertest + .get(`/api/fleet/epm/packages/${pkg}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }); return supertest - .post(`/api/fleet/epm/packages/${name}/${version}`) + .post(`/api/fleet/epm/packages/${pkg}/${version}`) .set('kbn-xsrf', 'xxxx') .send({ force: true }); } @@ -57,9 +59,7 @@ export async function uninstallPackage({ pkg, }: { supertest: SuperTestAgent; - pkg: IntegrationPackage; + pkg: string; }) { - const { name, version } = pkg; - - return supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx'); + return supertest.delete(`/api/fleet/epm/packages/${pkg}`).set('kbn-xsrf', 'xxxx'); } From 5c0991b03e63c086102f186c0fe6cc98772ecdef Mon Sep 17 00:00:00 2001 From: Gergely Kalapos Date: Thu, 22 Aug 2024 11:07:24 +0200 Subject: [PATCH 24/45] Add otel datastream patterns to APM indices (#190533) ## Summary Part of the OTel effort. This PR adds otel datastream patterns into the default indices that are used by the APM UI. ### 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 - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Caue Marcondes Co-authored-by: Elastic Machine --- docs/settings/apm-settings.asciidoc | 8 ++++---- .../apm/scripts/shared/read_kibana_config.ts | 8 ++++---- .../has_storage_explorer_privileges.ts | 14 ++++++++++++-- .../apm_data_access/server/index.ts | 8 ++++---- .../tests/data_view/static.spec.ts | 7 +++++-- .../tests/diagnostics/privileges.spec.ts | 6 ++++++ .../tests/inspect/inspect.spec.ts | 2 +- .../tests/settings/apm_indices/apm_indices.spec.ts | 8 ++++---- 8 files changed, 40 insertions(+), 21 deletions(-) diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 3cd04a4ff3733..901988cf67c29 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -82,19 +82,19 @@ Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to Set to `false` to disable cloud APM migrations. Defaults to `true`. `xpack.apm.indices.error` {ess-icon}:: -Matcher for all error indices. Defaults to `logs-apm*,apm-*`. +Matcher for all error indices. Defaults to `logs-apm*,apm-*,traces-*.otel-*`. `xpack.apm.indices.onboarding` {ess-icon}:: Matcher for all onboarding indices. Defaults to `apm-*`. `xpack.apm.indices.span` {ess-icon}:: -Matcher for all span indices. Defaults to `traces-apm*,apm-*`. +Matcher for all span indices. Defaults to `traces-apm*,apm-*,traces-*.otel-*`. `xpack.apm.indices.transaction` {ess-icon}:: -Matcher for all transaction indices. Defaults to `traces-apm*,apm-*`. +Matcher for all transaction indices. Defaults to `traces-apm*,apm-*,traces-*.otel-*`. `xpack.apm.indices.metric` {ess-icon}:: -Matcher for all metrics indices. Defaults to `metrics-apm*,apm-*`. +Matcher for all metrics indices. Defaults to `metrics-apm*,apm-*,metrics-*.otel-*`. `xpack.apm.indices.sourcemap` {ess-icon}:: Matcher for all source map indices. Defaults to `apm-*`. diff --git a/x-pack/plugins/observability_solution/apm/scripts/shared/read_kibana_config.ts b/x-pack/plugins/observability_solution/apm/scripts/shared/read_kibana_config.ts index 5cc21d6789591..c440fc5dfcea4 100644 --- a/x-pack/plugins/observability_solution/apm/scripts/shared/read_kibana_config.ts +++ b/x-pack/plugins/observability_solution/apm/scripts/shared/read_kibana_config.ts @@ -35,10 +35,10 @@ export const readKibanaConfig = () => { }; return { - 'xpack.apm.indices.transaction': 'traces-apm*,apm-*', - 'xpack.apm.indices.metric': 'metrics-apm*,apm-*', - 'xpack.apm.indices.error': 'logs-apm*,apm-*', - 'xpack.apm.indices.span': 'traces-apm*,apm-*', + 'xpack.apm.indices.transaction': 'traces-apm*,apm-*,traces-*.otel-*', + 'xpack.apm.indices.metric': 'metrics-apm*,apm-*,metrics-*.otel-*', + 'xpack.apm.indices.error': 'logs-apm*,apm-*,logs-*.otel-*', + 'xpack.apm.indices.span': 'traces-apm*,apm-*,traces-*.otel-*', 'xpack.apm.indices.onboarding': 'apm-*', 'elasticsearch.hosts': 'http://localhost:9200', ...loadedKibanaConfig, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/storage_explorer/has_storage_explorer_privileges.ts b/x-pack/plugins/observability_solution/apm/server/routes/storage_explorer/has_storage_explorer_privileges.ts index 69f61c15f2991..e3ba5053640f8 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/storage_explorer/has_storage_explorer_privileges.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/storage_explorer/has_storage_explorer_privileges.ts @@ -18,12 +18,22 @@ export async function hasStorageExplorerPrivileges({ apmEventClient: APMEventClient; }) { const { - indices: { transaction, span, metric, error }, + // Only use apm index patterns and ignore OTel, as the storage explorer only supports APM data + indices: { + transaction = 'traces-apm*,apm-*', + span = 'traces-apm*,apm-*', + metric = 'metrics-apm*,apm-*', + error = 'logs-apm*,apm-*', + }, } = apmEventClient; const names = uniq( [transaction, span, metric, error].flatMap((indexPatternString) => - indexPatternString.split(',').map((indexPattern) => indexPattern.trim()) + indexPatternString + .split(',') + .map((indexPattern) => indexPattern.trim()) + // At this point we do not do any work for storage explorer + OTel data. So remove any otel related index + .filter((indexPattern) => !indexPattern.includes('otel')) ) ); diff --git a/x-pack/plugins/observability_solution/apm_data_access/server/index.ts b/x-pack/plugins/observability_solution/apm_data_access/server/index.ts index 8ff76df2334ab..6b6385ded4ce4 100644 --- a/x-pack/plugins/observability_solution/apm_data_access/server/index.ts +++ b/x-pack/plugins/observability_solution/apm_data_access/server/index.ts @@ -10,10 +10,10 @@ import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/serv const configSchema = schema.object({ indices: schema.object({ - transaction: schema.string({ defaultValue: 'traces-apm*,apm-*' }), // TODO: remove apm-* pattern in 9.0 - span: schema.string({ defaultValue: 'traces-apm*,apm-*' }), - error: schema.string({ defaultValue: 'logs-apm*,apm-*' }), - metric: schema.string({ defaultValue: 'metrics-apm*,apm-*' }), + transaction: schema.string({ defaultValue: 'traces-apm*,apm-*,traces-*.otel-*' }), // TODO: remove apm-* pattern in 9.0 + span: schema.string({ defaultValue: 'traces-apm*,apm-*,traces-*.otel-*' }), + error: schema.string({ defaultValue: 'logs-apm*,apm-*,logs-*.otel-*' }), + metric: schema.string({ defaultValue: 'metrics-apm*,apm-*,metrics-*.otel-*' }), onboarding: schema.string({ defaultValue: 'apm-*' }), // Unused: to be deleted sourcemap: schema.string({ defaultValue: 'apm-*' }), // Unused: to be deleted }), diff --git a/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts b/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts index 56b310f8f2fe6..01afc1709782b 100644 --- a/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts +++ b/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts @@ -21,7 +21,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const synthtrace = getService('apmSynthtraceEsClient'); const logger = getService('log'); - const dataViewPattern = 'traces-apm*,apm-*,logs-apm*,apm-*,metrics-apm*,apm-*'; + const dataViewPattern = + 'traces-apm*,apm-*,traces-*.otel-*,logs-apm*,apm-*,logs-*.otel-*,metrics-apm*,apm-*,metrics-*.otel-*'; function createDataViewWithWriteUser({ spaceId }: { spaceId: string }) { return apmApiClient.writeUser({ @@ -116,7 +117,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(dataView.id).to.be('apm_static_data_view_id_default'); expect(dataView.name).to.be('APM'); - expect(dataView.title).to.be('traces-apm*,apm-*,logs-apm*,apm-*,metrics-apm*,apm-*'); + expect(dataView.title).to.be( + 'traces-apm*,apm-*,traces-*.otel-*,logs-apm*,apm-*,logs-*.otel-*,metrics-apm*,apm-*,metrics-*.otel-*' + ); }); }); diff --git a/x-pack/test/apm_api_integration/tests/diagnostics/privileges.spec.ts b/x-pack/test/apm_api_integration/tests/diagnostics/privileges.spec.ts index e6185cd43270d..2d9652b612010 100644 --- a/x-pack/test/apm_api_integration/tests/diagnostics/privileges.spec.ts +++ b/x-pack/test/apm_api_integration/tests/diagnostics/privileges.spec.ts @@ -36,6 +36,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { 'logs-apm*': { read: true }, 'metrics-apm*': { read: true }, 'traces-apm*': { read: true }, + 'logs-*.otel-*': { read: true }, + 'metrics-*.otel-*': { read: true }, + 'traces-*.otel-*': { read: true }, }); }); @@ -71,6 +74,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { 'logs-apm*': { read: true }, 'metrics-apm*': { read: true }, 'traces-apm*': { read: true }, + 'logs-*.otel-*': { read: true }, + 'metrics-*.otel-*': { read: true }, + 'traces-*.otel-*': { read: true }, }); }); diff --git a/x-pack/test/apm_api_integration/tests/inspect/inspect.spec.ts b/x-pack/test/apm_api_integration/tests/inspect/inspect.spec.ts index 155fbf2d959e4..f15c9900488a0 100644 --- a/x-pack/test/apm_api_integration/tests/inspect/inspect.spec.ts +++ b/x-pack/test/apm_api_integration/tests/inspect/inspect.spec.ts @@ -93,7 +93,7 @@ export default function inspectFlagTests({ getService }: FtrProviderContext) { expect(status).to.be(200); expect(body._inspect?.map((res) => res.stats?.indexPattern.value)).to.eql([ - ['metrics-apm*', 'apm-*'], + ['metrics-apm*', 'apm-*', 'metrics-*.otel-*'], ]); }); }); diff --git a/x-pack/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts b/x-pack/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts index 8c4b93a29210c..fa303d33a2945 100644 --- a/x-pack/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts +++ b/x-pack/test/apm_api_integration/tests/settings/apm_indices/apm_indices.spec.ts @@ -45,10 +45,10 @@ export default function apmIndicesTests({ getService }: FtrProviderContext) { }); expect(response.status).to.be(200); expect(response.body).to.eql({ - transaction: 'traces-apm*,apm-*', - span: 'traces-apm*,apm-*', - error: 'logs-apm*,apm-*', - metric: 'metrics-apm*,apm-*', + transaction: 'traces-apm*,apm-*,traces-*.otel-*', + span: 'traces-apm*,apm-*,traces-*.otel-*', + error: 'logs-apm*,apm-*,logs-*.otel-*', + metric: 'metrics-apm*,apm-*,metrics-*.otel-*', onboarding: 'apm-*', sourcemap: 'apm-*', }); From c5b38e487a3fd974a244ba584ef247e3ffbe6019 Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 22 Aug 2024 10:08:30 +0100 Subject: [PATCH 25/45] [ResponseOps][Connectors] Add deprecation warning to the Teams Connector (#190958) Fixes #190944 ## Summary **The exact text is still a work in progress.** Screenshot 2024-08-21 at 11 02 37 --------- Co-authored-by: Lisa Cawley --- .../teams/teams_connectors.tsx | 36 ++++++++++++------- .../connector_types/teams/translations.ts | 8 +++++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/stack_connectors/public/connector_types/teams/teams_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/teams/teams_connectors.tsx index 7d989b9b04c6e..f8c2b75940aa0 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/teams/teams_connectors.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiLink } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { FieldConfig, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; @@ -42,18 +42,28 @@ const TeamsActionFields: React.FunctionComponent = ( const { docLinks } = useKibana().services; return ( - + <> + + + + ); }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/teams/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/teams/translations.ts index 539e0867dc97c..728c613824601 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/teams/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/teams/translations.ts @@ -27,3 +27,11 @@ export const MESSAGE_REQUIRED = i18n.translate( defaultMessage: 'Message is required.', } ); + +export const WEBHOOK_DEPRECATION_WARNING = i18n.translate( + 'xpack.stackConnectors.components.teams.warning.webhookDeprecation', + { + defaultMessage: + 'Microsoft Teams deprecated some methods for configuring webhooks. Follow the documentation link to create a supported webhook URL. If the URL is not updated by December 31, 2024, notifications will stop.', + } +); From 3177b037d7f8b52214b025a5f1128bd991efc591 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 22 Aug 2024 11:14:59 +0100 Subject: [PATCH 26/45] [ML] File upload: Adds support for PDF files (#186956) Also txt, rtf, doc, docx, xls, xlsx, ppt, pptx, odt, ods, and odp. Adds the ability to automatically add a semantic text field to the mappings and a `copy_to` processor to duplicate the field. This is needed for the mappings generated for the attachment processor which adds a nested `attachment.content` field which cannot be used as a semantic text field. After a successful import, a link to Search's Playground app is shown. Navigating there lets the user instantly query the newly uploaded file. https://github.com/user-attachments/assets/09b20a5f-0e02-47fa-885e-0ed21374cc60 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Liam Thompson <32779855+leemthompo@users.noreply.github.com> --- .../data_visualizer/common/constants.ts | 2 +- .../common/utils/tika_utils.ts | 90 +++++ .../combined_fields/combined_field_label.tsx | 11 +- .../combined_fields/combined_fields_form.tsx | 66 ++-- .../components/combined_fields/geo_point.tsx | 21 +- .../combined_fields/semantic_text.tsx | 210 ++++++++++++ .../components/combined_fields/types.ts | 2 +- .../components/combined_fields/utils.ts | 11 + .../filebeat_config_flyout/filebeat_config.ts | 4 +- .../filebeat_config_flyout.tsx | 13 +- .../results_links/results_links.tsx | 45 ++- .../common/components/utils/utils.ts | 2 +- .../about_panel/welcome_content.tsx | 107 +++++- .../analysis_summary/analysis_summary.tsx | 9 +- .../file_contents/file_contents.tsx | 17 +- .../components/file_contents/preview_pdf.ts | 30 ++ .../file_data_visualizer_view.js | 57 ++-- .../file_error_callouts.tsx | 14 +- .../file_size_check.ts | 40 +++ .../tika_analyzer.ts | 108 ++++++ .../{ => advanced}/advanced.tsx | 101 +----- .../import_settings/advanced/index.ts | 8 + .../import_settings/advanced/inputs.tsx | 96 ++++++ .../advanced/use_existing_indices.ts | 58 ++++ .../import_settings/import_settings.tsx | 15 +- .../import_settings/semantic_text_info.tsx | 60 ++++ .../components/import_settings/simple.tsx | 10 +- .../import_summary/import_summary.tsx | 10 +- .../components/import_view/import.ts | 242 +++++++++++++ .../components/import_view/import_view.js | 317 ++++-------------- .../components/results_view/results_view.tsx | 49 +-- .../plugins/data_visualizer/server/routes.ts | 28 ++ x-pack/plugins/data_visualizer/tsconfig.json | 1 + .../plugins/file_upload/common/constants.ts | 5 +- x-pack/plugins/file_upload/common/types.ts | 24 +- .../plugins/file_upload/public/api/index.ts | 37 +- .../public/importer/get_max_bytes.ts | 9 + .../file_upload/public/importer/importer.ts | 6 +- .../public/importer/importer_factory.ts | 5 +- .../public/importer/tika_importer.ts | 48 +++ .../public/lazy_load_bundle/index.ts | 2 +- x-pack/plugins/file_upload/public/plugin.ts | 11 +- .../server/preview_tika_contents.ts | 50 +++ x-pack/plugins/file_upload/server/routes.ts | 48 ++- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../api_integration/apis/file_upload/index.ts | 1 + .../apis/file_upload/pdf_base64.ts | 8 + .../apis/file_upload/preview_tika_contents.ts | 49 +++ 50 files changed, 1657 insertions(+), 506 deletions(-) create mode 100644 x-pack/plugins/data_visualizer/common/utils/tika_utils.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/semantic_text.tsx create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/preview_pdf.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_size_check.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/tika_analyzer.ts rename x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/{ => advanced}/advanced.tsx (67%) create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/index.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/inputs.tsx create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/use_existing_indices.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/semantic_text_info.tsx create mode 100644 x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import.ts create mode 100644 x-pack/plugins/file_upload/public/importer/tika_importer.ts create mode 100644 x-pack/plugins/file_upload/server/preview_tika_contents.ts create mode 100644 x-pack/test/api_integration/apis/file_upload/pdf_base64.ts create mode 100644 x-pack/test/api_integration/apis/file_upload/preview_tika_contents.ts diff --git a/x-pack/plugins/data_visualizer/common/constants.ts b/x-pack/plugins/data_visualizer/common/constants.ts index 60a87c37d9f1b..ff277b9bb4785 100644 --- a/x-pack/plugins/data_visualizer/common/constants.ts +++ b/x-pack/plugins/data_visualizer/common/constants.ts @@ -28,7 +28,7 @@ export const FILE_FORMATS = { DELIMITED: 'delimited', NDJSON: 'ndjson', SEMI_STRUCTURED_TEXT: 'semi_structured_text', - // XML: 'xml', + TIKA: 'tika', }; export const SUPPORTED_FIELD_TYPES = { diff --git a/x-pack/plugins/data_visualizer/common/utils/tika_utils.ts b/x-pack/plugins/data_visualizer/common/utils/tika_utils.ts new file mode 100644 index 0000000000000..934378464d70a --- /dev/null +++ b/x-pack/plugins/data_visualizer/common/utils/tika_utils.ts @@ -0,0 +1,90 @@ +/* + * 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 function isTikaType(type: string) { + return getTikaDisplayType(type).isTikaType; +} + +export const getTikaDisplayType = (type: string): { isTikaType: boolean; label: string } => { + switch (type) { + case 'application/doc': + case 'application/ms-doc': + case 'application/msword': + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.word', { + defaultMessage: 'Microsoft Office Word document', + }), + }; + + case 'application/excel': + case 'application/vnd.ms-excel': + case 'application/x-excel': + case 'application/x-msexcel': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.excel', { + defaultMessage: 'Microsoft Office Excel document', + }), + }; + + case 'application/mspowerpoint': + case 'application/powerpoint': + case 'application/vnd.ms-powerpoint': + case 'application/x-mspowerpoint': + case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.powerPoint', { + defaultMessage: 'Microsoft Office Power Point document', + }), + }; + + case 'application/vnd.oasis.opendocument.presentation': + case 'application/vnd.oasis.opendocument.spreadsheet': + case 'application/vnd.oasis.opendocument.text': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.openDoc', { + defaultMessage: 'Open Document Format', + }), + }; + + case 'text/rtf': + case 'application/rtf': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.richText', { + defaultMessage: 'Rich Text Format', + }), + }; + + case 'application/pdf': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.pdf', { + defaultMessage: 'PDF', + }), + }; + + case 'text/plain': + case 'text/plain; charset=UTF-8': + return { + isTikaType: true, + label: i18n.translate('xpack.dataVisualizer.file.tikaTypes.plainText', { + defaultMessage: 'Plain text', + }), + }; + + default: + return { isTikaType: false, label: type }; + } +}; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_field_label.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_field_label.tsx index b1ca6e31450be..0a00518f251e5 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_field_label.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_field_label.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiText } from '@elastic/eui'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; import type { CombinedField } from './types'; export function CombinedFieldLabel({ combinedField }: { combinedField: CombinedField }) { @@ -15,7 +16,11 @@ export function CombinedFieldLabel({ combinedField }: { combinedField: CombinedF } function getCombinedFieldLabel(combinedField: CombinedField) { - return `${combinedField.fieldNames.join(combinedField.delimiter)} => ${ - combinedField.combinedFieldName - } (${combinedField.mappingType})`; + if (combinedField.mappingType === ES_FIELD_TYPES.GEO_POINT) { + return `${combinedField.fieldNames.join(combinedField.delimiter)} => ${ + combinedField.combinedFieldName + } (${combinedField.mappingType})`; + } + + return combinedField.combinedFieldName; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_fields_form.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_fields_form.tsx index 8676be744cb53..7bf8d7f0aaaf3 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_fields_form.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/combined_fields_form.tsx @@ -19,17 +19,13 @@ import { EuiFlexItem, } from '@elastic/eui'; -import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; +import type { FindFileStructureResponse, IngestPipeline } from '@kbn/file-upload-plugin/common'; +import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { CombinedField } from './types'; import { GeoPointForm } from './geo_point'; +import { SemanticTextForm } from './semantic_text'; import { CombinedFieldLabel } from './combined_field_label'; -import { - addCombinedFieldsToMappings, - addCombinedFieldsToPipeline, - getNameCollisionMsg, - removeCombinedFieldsFromMappings, - removeCombinedFieldsFromPipeline, -} from './utils'; +import { removeCombinedFieldsFromMappings, removeCombinedFieldsFromPipeline } from './utils'; interface Props { mappingsString: string; @@ -46,6 +42,12 @@ interface State { isPopoverOpen: boolean; } +export type AddCombinedField = ( + combinedField: CombinedField, + addToMappings: (mappings: MappingTypeMapping) => MappingTypeMapping, + addToPipeline: (pipeline: IngestPipeline) => IngestPipeline +) => void; + export class CombinedFieldsForm extends Component { state: State = { isPopoverOpen: false, @@ -63,20 +65,20 @@ export class CombinedFieldsForm extends Component { }); }; - addCombinedField = (combinedField: CombinedField) => { - if (this.hasNameCollision(combinedField.combinedFieldName)) { - throw new Error(getNameCollisionMsg(combinedField.combinedFieldName)); - } - + addCombinedField = ( + combinedField: CombinedField, + addToMappings: (mappings: MappingTypeMapping) => {}, + addToPipeline: (pipeline: IngestPipeline) => {} + ) => { const mappings = this.parseMappings(); const pipeline = this.parsePipeline(); - this.props.onMappingsStringChange( - JSON.stringify(addCombinedFieldsToMappings(mappings, [combinedField]), null, 2) - ); - this.props.onPipelineStringChange( - JSON.stringify(addCombinedFieldsToPipeline(pipeline, [combinedField]), null, 2) - ); + const newMappings = addToMappings(mappings); + const newPipeline = addToPipeline(pipeline); + + this.props.onMappingsStringChange(JSON.stringify(newMappings, null, 2)); + this.props.onPipelineStringChange(JSON.stringify(newPipeline, null, 2)); + this.props.onCombinedFieldsChange([...this.props.combinedFields, combinedField]); this.closePopover(); @@ -155,6 +157,13 @@ export class CombinedFieldsForm extends Component { defaultMessage: 'Add geo point field', } ); + + const semanticTextLabel = i18n.translate( + 'xpack.dataVisualizer.file.semanticTextForm.combinedFieldLabel', + { + defaultMessage: 'Add semantic text field', + } + ); const panels = [ { id: 0, @@ -163,6 +172,10 @@ export class CombinedFieldsForm extends Component { name: geoPointLabel, panel: 1, }, + { + name: semanticTextLabel, + panel: 2, + }, ], }, { @@ -176,11 +189,22 @@ export class CombinedFieldsForm extends Component { /> ), }, + { + id: 2, + title: semanticTextLabel, + content: ( + + ), + }, ]; return (
@@ -217,7 +241,7 @@ export class CombinedFieldsForm extends Component { > } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/geo_point.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/geo_point.tsx index f855d61a463d6..65ee3a6b58b5b 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/geo_point.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/geo_point.tsx @@ -23,17 +23,19 @@ import { } from '@elastic/eui'; import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; -import type { CombinedField } from './types'; import { createGeoPointCombinedField, isWithinLatRange, isWithinLonRange, getFieldNames, getNameCollisionMsg, + addCombinedFieldsToMappings, + addCombinedFieldsToPipeline, } from './utils'; +import type { AddCombinedField } from './combined_fields_form'; interface Props { - addCombinedField: (combinedField: CombinedField) => void; + addCombinedField: AddCombinedField; hasNameCollision: (name: string) => boolean; results: FindFileStructureResponse; } @@ -99,13 +101,18 @@ export class GeoPointForm extends Component { onSubmit = () => { try { + const combinedField = createGeoPointCombinedField( + this.state.latField, + this.state.lonField, + this.state.geoPointField + ); + this.props.addCombinedField( - createGeoPointCombinedField( - this.state.latField, - this.state.lonField, - this.state.geoPointField - ) + combinedField, + (mappings) => addCombinedFieldsToMappings(mappings, [combinedField]), + (pipeline) => addCombinedFieldsToPipeline(pipeline, [combinedField]) ); + this.setState({ submitError: '' }); } catch (error) { this.setState({ submitError: error.message }); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/semantic_text.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/semantic_text.tsx new file mode 100644 index 0000000000000..581e92d23dc9d --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/semantic_text.tsx @@ -0,0 +1,210 @@ +/* + * 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, { useState, useEffect, useMemo } from 'react'; +import type { FC } from 'react'; +import type { + FindFileStructureResponse, + IngestPipeline, +} from '@kbn/file-upload-plugin/common/types'; +import type { EuiSelectOption } from '@elastic/eui'; +import { + EuiButton, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiTextAlign, + EuiFieldText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { cloneDeep } from 'lodash'; +import useDebounce from 'react-use/lib/useDebounce'; +import type { + InferenceModelConfigContainer, + MappingTypeMapping, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { createSemanticTextCombinedField, getFieldNames, getNameCollisionMsg } from './utils'; +import { useDataVisualizerKibana } from '../../../kibana_context'; +import type { AddCombinedField } from './combined_fields_form'; + +interface Props { + addCombinedField: AddCombinedField; + hasNameCollision: (name: string) => boolean; + results: FindFileStructureResponse; +} +export const SemanticTextForm: FC = ({ addCombinedField, hasNameCollision, results }) => { + const { + services: { http }, + } = useDataVisualizerKibana(); + const [inferenceServices, setInferenceServices] = useState([]); + const [selectedInference, setSelectedInference] = useState(); + const [selectedFieldOption, setSelectedFieldOption] = useState(); + const [renameToFieldOption, setRenameToFieldOption] = useState(''); + const [fieldError, setFieldError] = useState(); + + const fieldOptions = useMemo( + () => + getFieldNames(results).map((columnName: string) => { + return { value: columnName, text: columnName }; + }), + [results] + ); + + useEffect(() => { + setSelectedFieldOption(fieldOptions[0].value ?? null); + }, [fieldOptions]); + + useEffect(() => { + http + .fetch('/internal/data_visualizer/inference_services', { + method: 'GET', + version: '1', + }) + .then((response) => { + const inferenceServiceOptions = response.map((service) => ({ + value: service.model_id, + text: service.model_id, + })); + setInferenceServices(inferenceServiceOptions); + setSelectedInference(inferenceServiceOptions[0]?.value ?? undefined); + }); + }, [http]); + + useEffect(() => { + if (selectedFieldOption?.includes('.')) { + setRenameToFieldOption(selectedFieldOption.split('.').pop()!); + } else { + setRenameToFieldOption(`${selectedFieldOption}_semantic`); + } + }, [selectedFieldOption]); + + const onSubmit = () => { + if ( + renameToFieldOption === '' || + renameToFieldOption === undefined || + selectedFieldOption === undefined || + selectedInference === undefined + ) { + return; + } + addCombinedField( + createSemanticTextCombinedField(renameToFieldOption, selectedFieldOption), + (mappings: MappingTypeMapping) => { + if (renameToFieldOption === undefined || selectedFieldOption === undefined) { + return mappings; + } + + const newMappings = cloneDeep(mappings); + newMappings.properties![renameToFieldOption ?? selectedFieldOption] = { + // @ts-ignore types are missing semantic_text + type: 'semantic_text', + inference_id: selectedInference, + }; + return newMappings; + }, + (pipeline: IngestPipeline) => { + const newPipeline = cloneDeep(pipeline); + if (renameToFieldOption !== null) { + newPipeline.processors.push({ + set: { + field: renameToFieldOption, + copy_from: selectedFieldOption, + }, + }); + } + return newPipeline; + } + ); + }; + + useDebounce( + () => { + if (renameToFieldOption === undefined) { + return; + } + const error = hasNameCollision(renameToFieldOption) + ? getNameCollisionMsg(renameToFieldOption) + : undefined; + setFieldError(error); + }, + 250, + [renameToFieldOption] + ); + + const isInvalid = useMemo(() => { + return ( + !selectedInference || + !selectedFieldOption || + renameToFieldOption === '' || + fieldError !== undefined + ); + }, [selectedInference, selectedFieldOption, renameToFieldOption, fieldError]); + + return ( + <> + + + + setSelectedFieldOption(e.target.value)} + /> + + + {renameToFieldOption !== null ? ( + + setRenameToFieldOption(e.target.value)} + aria-label="field name" + /> + + ) : null} + + + setSelectedInference(e.target.value)} + /> + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/types.ts b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/types.ts index 8127b208fb59c..6e6fa70e967fc 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/types.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/types.ts @@ -7,7 +7,7 @@ export interface CombinedField { mappingType: string; - delimiter: string; + delimiter?: string; combinedFieldName: string; fieldNames: string[]; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts index 75cf8cd8d91fe..4b0f57d1ca932 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts @@ -123,6 +123,17 @@ export function createGeoPointCombinedField( }; } +export function createSemanticTextCombinedField( + sematicTextField: string, + originalField: string +): CombinedField { + return { + mappingType: 'semantic_text', + combinedFieldName: sematicTextField, + fieldNames: [originalField], + }; +} + export function getNameCollisionMsg(name: string) { return i18n.translate('xpack.dataVisualizer.nameCollisionMsg', { defaultMessage: '"{name}" already exists, please provide a unique name', diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config.ts b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config.ts index dbef1b4497893..b3c02aa177de8 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config.ts @@ -11,7 +11,7 @@ import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; export function createFilebeatConfig( index: string, results: FindFileStructureResponse, - ingestPipelineId: string, + pipelineId: string, username: string | null ) { return [ @@ -27,7 +27,7 @@ export function createFilebeatConfig( ' hosts: [""]', ...getUserDetails(username), ` index: "${index}"`, - ` pipeline: "${ingestPipelineId}"`, + ` pipeline: "${pipelineId}"`, '', 'setup:', ' template.enabled: false', diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx index 7682e771ac155..c6f62f4eef456 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -34,15 +34,10 @@ export enum EDITOR_MODE { interface Props { index: string; results: FindFileStructureResponse; - ingestPipelineId: string; + pipelineId: string; closeFlyout(): void; } -export const FilebeatConfigFlyout: FC = ({ - index, - results, - ingestPipelineId, - closeFlyout, -}) => { +export const FilebeatConfigFlyout: FC = ({ index, results, pipelineId, closeFlyout }) => { const [fileBeatConfig, setFileBeatConfig] = useState(''); const [username, setUsername] = useState(null); const { @@ -56,9 +51,9 @@ export const FilebeatConfigFlyout: FC = ({ }, [security]); useEffect(() => { - const config = createFilebeatConfig(index, results, ingestPipelineId, username); + const config = createFilebeatConfig(index, results, pipelineId, username); setFileBeatConfig(config); - }, [username, index, ingestPipelineId, results]); + }, [username, index, pipelineId, results]); return ( diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx index 259b45d4e297b..a48dde6f4fa6b 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx @@ -19,6 +19,7 @@ import { isDefined } from '@kbn/ml-is-defined'; import type { ResultLinks } from '../../../../../common/app'; import type { LinkCardProps } from '../link_card/link_card'; import { useDataVisualizerKibana } from '../../../kibana_context'; +import type { CombinedField } from '../combined_fields/types'; type LinkType = 'file' | 'index'; @@ -44,7 +45,7 @@ export interface ResultLink { } interface Props { - fieldStats: FindFileStructureResponse['field_stats']; + results: FindFileStructureResponse; index: string; dataViewId: string; timeFieldName?: string; @@ -52,6 +53,7 @@ interface Props { showFilebeatFlyout(): void; getAdditionalLinks?: GetAdditionalLinks; resultLinks?: ResultLinks; + combinedFields: CombinedField[]; } interface GlobalState { @@ -62,7 +64,7 @@ interface GlobalState { const RECHECK_DELAY_MS = 3000; export const ResultsLinks: FC = ({ - fieldStats, + results, index, dataViewId, timeFieldName, @@ -70,6 +72,7 @@ export const ResultsLinks: FC = ({ showFilebeatFlyout, getAdditionalLinks, resultLinks, + combinedFields, }) => { const { services: { @@ -78,7 +81,7 @@ export const ResultsLinks: FC = ({ application: { getUrlForApp, capabilities }, }, } = useDataVisualizerKibana(); - + const fieldStats = results.field_stats; const [duration, setDuration] = useState({ from: 'now-30m', to: 'now', @@ -88,6 +91,7 @@ export const ResultsLinks: FC = ({ const [discoverLink, setDiscoverLink] = useState(''); const [indexManagementLink, setIndexManagementLink] = useState(''); const [dataViewsManagementLink, setDataViewsManagementLink] = useState(''); + const [playgroundLink, setPlaygroundLink] = useState(''); const [asyncHrefCards, setAsyncHrefCards] = useState(); useEffect(() => { @@ -96,7 +100,7 @@ export const ResultsLinks: FC = ({ const getDiscoverUrl = async (): Promise => { const isDiscoverAvailable = capabilities.discover?.show ?? false; if (!isDiscoverAvailable) return; - const discoverLocator = url?.locators.get('DISCOVER_APP_LOCATOR'); + const discoverLocator = url.locators.get('DISCOVER_APP_LOCATOR'); if (!discoverLocator) { // eslint-disable-next-line no-console @@ -116,13 +120,13 @@ export const ResultsLinks: FC = ({ if (Array.isArray(getAdditionalLinks)) { Promise.all( getAdditionalLinks.map(async (asyncCardGetter) => { - const results = await asyncCardGetter({ + const cardResults = await asyncCardGetter({ dataViewId, globalState, }); - if (Array.isArray(results)) { + if (Array.isArray(cardResults)) { return await Promise.all( - results.map(async (c) => ({ + cardResults.map(async (c) => ({ ...c, canDisplay: await c.canDisplay(), href: await c.getUrl(), @@ -140,6 +144,12 @@ export const ResultsLinks: FC = ({ } if (!unmounted) { + const playgroundLocator = url.locators.get('PLAYGROUND_LOCATOR_ID'); + + if (playgroundLocator !== undefined) { + playgroundLocator.getUrl({ 'default-index': index }).then(setPlaygroundLink); + } + setIndexManagementLink( getUrlForApp('management', { path: '/data/index_management/indices' }) ); @@ -228,7 +238,6 @@ export const ResultsLinks: FC = ({ /> )} - {indexManagementLink && ( = ({ /> )} - {dataViewsManagementLink && ( = ({ /> )} - {resultLinks?.fileBeat?.enabled === false ? null : ( = ({ )} + {playgroundLink ? ( + + } + data-test-subj="fileDataVisFilebeatConfigLink" + title={ + + } + description="" + href={playgroundLink} + /> + + ) : null} + {Array.isArray(asyncHrefCards) && asyncHrefCards.map((link) => ( diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts index 0aca4f9260b7d..776f687f7732f 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts @@ -26,7 +26,7 @@ const overrideDefaults = { linesToSample: undefined, }; -export function readFile(file: File) { +export function readFile(file: File): Promise<{ fileContents: string; data: ArrayBuffer }> { return new Promise((resolve, reject) => { if (file && file.size) { const reader = new FileReader(); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx index 61681db6b3ef5..a5437ad49dc2d 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx @@ -34,10 +34,11 @@ interface Props { export const WelcomeContent: FC = ({ hasPermissionToImport }) => { const { services: { - fileUpload: { getMaxBytesFormatted }, + fileUpload: { getMaxBytesFormatted, getMaxTikaBytesFormatted }, }, } = useDataVisualizerKibana(); const maxFileSize = getMaxBytesFormatted(); + const maxTikaFileSize = getMaxTikaBytesFormatted(); return ( @@ -57,10 +58,17 @@ export const WelcomeContent: FC = ({ hasPermissionToImport }) => {

{hasPermissionToImport ? ( - + <> + +
+ + ) : ( = ({ hasPermissionToImport }) => {

- + @@ -87,8 +96,8 @@ export const WelcomeContent: FC = ({ hasPermissionToImport }) => {

@@ -103,8 +112,8 @@ export const WelcomeContent: FC = ({ hasPermissionToImport }) => {

@@ -119,23 +128,89 @@ export const WelcomeContent: FC = ({ hasPermissionToImport }) => {

+

+
+
+
+ + + + + + + +

+

+ +

+ + + + + + + +

+ +

+
+
+
+ + + + + + + +

+ +

+
+
+
+ + + + + + + +

+ +

+
+
+
); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx index 7f67f6f4f4868..8efacc7a5de06 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { EuiTitle, EuiSpacer, EuiDescriptionList } from '@elastic/eui'; import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; +import { getTikaDisplayType } from '../../../../../common/utils/tika_utils'; import { FILE_FORMATS } from '../../../../../common/constants'; export const AnalysisSummary: FC<{ results: FindFileStructureResponse }> = ({ results }) => { @@ -64,7 +65,7 @@ function createDisplayItems(results: FindFileStructureResponse) { defaultMessage="Format" /> ), - description: results.format, + description: getFormatLabel(results), }); if (results.format === FILE_FORMATS.DELIMITED) { @@ -131,3 +132,9 @@ function createDisplayItems(results: FindFileStructureResponse) { return items; } + +function getFormatLabel(results: FindFileStructureResponse) { + return results.format === FILE_FORMATS.TIKA && results.document_type !== undefined + ? getTikaDisplayType(results.document_type).label + : results.format; +} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/file_contents.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/file_contents.tsx index ef57ab6204c6d..412423a0ba0d8 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/file_contents.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/file_contents.tsx @@ -26,7 +26,7 @@ import { useGrokHighlighter } from './use_text_parser'; import { LINE_LIMIT } from './grok_highlighter'; interface Props { - data: string; + fileContents: string; format: string; numberOfLines: number; semiStructureTextData: SemiStructureTextData | null; @@ -51,7 +51,12 @@ function semiStructureTextDataGuard( ); } -export const FileContents: FC = ({ data, format, numberOfLines, semiStructureTextData }) => { +export const FileContents: FC = ({ + fileContents, + format, + numberOfLines, + semiStructureTextData, +}) => { let mode = EDITOR_MODE.TEXT; if (format === EDITOR_MODE.JSON) { mode = EDITOR_MODE.JSON; @@ -63,8 +68,8 @@ export const FileContents: FC = ({ data, format, numberOfLines, semiStruc semiStructureTextDataGuard(semiStructureTextData) ); const formattedData = useMemo( - () => limitByNumberOfLines(data, numberOfLines), - [data, numberOfLines] + () => limitByNumberOfLines(fileContents, numberOfLines), + [fileContents, numberOfLines] ); const [highlightedLines, setHighlightedLines] = useState(null); @@ -78,7 +83,7 @@ export const FileContents: FC = ({ data, format, numberOfLines, semiStruc semiStructureTextData!; grokHighlighter( - data, + fileContents, grokPattern!, mappings, ecsCompatibility, @@ -96,7 +101,7 @@ export const FileContents: FC = ({ data, format, numberOfLines, semiStruc setIsSemiStructureTextData(false); } }); - }, [data, semiStructureTextData, grokHighlighter, isSemiStructureTextData, isMounted]); + }, [fileContents, semiStructureTextData, grokHighlighter, isSemiStructureTextData, isMounted]); return ( <> diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/preview_pdf.ts b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/preview_pdf.ts new file mode 100644 index 0000000000000..7a850a9f5b965 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_contents/preview_pdf.ts @@ -0,0 +1,30 @@ +/* + * 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 type { HttpSetup } from '@kbn/core-http-browser'; + +const URL = '/internal/file_upload/preview_pdf_contents'; + +export async function previewPDF(http: HttpSetup, data: ArrayBuffer) { + const dataString: string = [].reduce.call( + new Uint8Array(data), + (p, c) => { + return p + String.fromCharCode(c); + }, + '' + ) as string; + const pdfBase64 = btoa(dataString); + + const { preview } = await http.fetch(URL, { + method: 'POST', + version: '1', + body: JSON.stringify({ + pdfBase64, + }), + }); + return preview; +} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js index e6435c554fc63..e19b2cbceda33 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js @@ -28,8 +28,11 @@ import { createUrlOverrides, processResults, } from '../../../common/components/utils'; +import { analyzeTikaFile } from './tika_analyzer'; import { MODE } from './constants'; +import { FileSizeChecker } from './file_size_check'; +import { isTikaType } from '../../../../../common/utils/tika_utils'; export class FileDataVisualizerView extends Component { constructor(props) { @@ -40,7 +43,7 @@ export class FileDataVisualizerView extends Component { fileName: '', fileContents: '', data: [], - fileSize: 0, + base64Data: '', fileTooLarge: false, fileCouldNotBeRead: false, serverError: null, @@ -60,8 +63,6 @@ export class FileDataVisualizerView extends Component { this.originalSettings = { linesToSample: DEFAULT_LINES_TO_SAMPLE, }; - - this.maxFileUploadBytes = props.fileUpload.getMaxBytes(); } async componentDidMount() { @@ -85,7 +86,6 @@ export class FileDataVisualizerView extends Component { fileName: '', fileContents: '', data: [], - fileSize: 0, fileTooLarge: false, fileCouldNotBeRead: false, fileCouldNotBeReadPermissionError: false, @@ -102,17 +102,25 @@ export class FileDataVisualizerView extends Component { }; async loadFile(file) { - if (file.size <= this.maxFileUploadBytes) { + this.fileSizeChecker = new FileSizeChecker(this.props.fileUpload, file); + if (this.fileSizeChecker.check()) { try { const { data, fileContents } = await readFile(file); - this.setState({ - data, - fileContents, - fileName: file.name, - fileSize: file.size, - }); - - await this.analyzeFile(fileContents); + if (isTikaType(file.type)) { + this.setState({ + data, + fileName: file.name, + }); + + await this.analyzeTika(data); + } else { + this.setState({ + data, + fileContents, + fileName: file.name, + }); + await this.analyzeFile(fileContents); + } } catch (error) { this.setState({ loaded: false, @@ -126,7 +134,6 @@ export class FileDataVisualizerView extends Component { loading: false, fileTooLarge: true, fileName: file.name, - fileSize: file.size, }); } } @@ -206,6 +213,21 @@ export class FileDataVisualizerView extends Component { } } + async analyzeTika(data, isRetry = false) { + const { tikaResults, standardResults } = await analyzeTikaFile(data, this.props.fileUpload); + const serverSettings = processResults(standardResults); + this.originalSettings = serverSettings; + + this.setState({ + fileContents: tikaResults.content, + results: standardResults.results, + explanation: standardResults.explanation, + loaded: true, + loading: false, + fileCouldNotBeRead: isRetry, + }); + } + closeEditFlyout = () => { this.setState({ isEditFlyoutVisible: false }); }; @@ -258,7 +280,6 @@ export class FileDataVisualizerView extends Component { fileContents, data, fileName, - fileSize, fileTooLarge, fileCouldNotBeRead, serverError, @@ -287,9 +308,7 @@ export class FileDataVisualizerView extends Component { {loading && } - {fileTooLarge && ( - - )} + {fileTooLarge && } {fileCouldNotBeRead && loading === false && ( <> @@ -311,7 +330,7 @@ export class FileDataVisualizerView extends Component { results={results} explanation={explanation} fileName={fileName} - data={fileContents} + fileContents={fileContents} showEditFlyout={this.showEditFlyout} showExplanationFlyout={this.showExplanationFlyout} disableButtons={isEditFlyoutVisible || isExplanationFlyoutVisible} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_error_callouts.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_error_callouts.tsx index cb160a3e2763e..13c4ed5f7336f 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_error_callouts.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_error_callouts.tsx @@ -11,18 +11,16 @@ import React from 'react'; import { EuiCallOut, EuiSpacer, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import type { FindFileStructureErrorResponse } from '@kbn/file-upload-plugin/common'; -import { FILE_SIZE_DISPLAY_FORMAT } from '../../../../../common/constants'; +import type { FileSizeChecker } from './file_size_check'; interface FileTooLargeProps { - fileSize: number; - maxFileSize: number; + fileSizeChecker: FileSizeChecker; } -export const FileTooLarge: FC = ({ fileSize, maxFileSize }) => { - const fileSizeFormatted = numeral(fileSize).format(FILE_SIZE_DISPLAY_FORMAT); - const maxFileSizeFormatted = numeral(maxFileSize).format(FILE_SIZE_DISPLAY_FORMAT); +export const FileTooLarge: FC = ({ fileSizeChecker }) => { + const fileSizeFormatted = fileSizeChecker.fileSizeFormatted(); + const maxFileSizeFormatted = fileSizeChecker.maxFileSizeFormatted(); // Format the byte values, using the second format if the difference between // the file size and the max is so small that the formatted values are identical @@ -43,7 +41,7 @@ export const FileTooLarge: FC = ({ fileSize, maxFileSize }) =

); } else { - const diffFormatted = numeral(fileSize - maxFileSize).format(FILE_SIZE_DISPLAY_FORMAT); + const diffFormatted = fileSizeChecker.fileSizeDiffFormatted(); errorText = (

{ + const resp = await fileUpload.previewTikaFile(data); + const numLinesAnalyzed = (resp.content.match(/\n/g) || '').length + 1; + + return { + tikaResults: resp, + standardResults: { + results: { + format: FILE_FORMATS.TIKA, + document_type: resp.content_type, + charset: 'utf-8', + has_header_row: false, + has_byte_order_marker: false, + sample_start: '', + quote: '', + delimiter: '', + need_client_timezone: false, + num_lines_analyzed: numLinesAnalyzed, + num_messages_analyzed: 0, + field_stats: { + // @ts-expect-error semantic_text not supported + 'attachment.content': {}, + // @ts-expect-error semantic_text not supported + 'attachment.content_length': {}, + // @ts-expect-error semantic_text not supported + 'attachment.content_type': {}, + // @ts-expect-error semantic_text not supported + 'attachment.format': {}, + // @ts-expect-error semantic_text not supported + 'attachment.language': {}, + }, + mappings: { + properties: { + attachment: { + // @ts-expect-error semantic_text not supported + properties: { + content: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + content_length: { + type: 'long', + }, + content_type: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + format: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + language: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + }, + }, + ingest_pipeline: { + description: 'Ingest pipeline created by file data visualizer', + processors: [ + { + attachment: { + field: 'data', + remove_binary: true, + }, + }, + ], + }, + }, + }, + }; +} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/advanced.tsx similarity index 67% rename from x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced.tsx rename to x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/advanced.tsx index 186198622a3ef..309145a374715 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/advanced.tsx @@ -20,17 +20,18 @@ import { } from '@elastic/eui'; import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; -import type { CombinedField } from '../../../common/components/combined_fields'; -import { CombinedFieldsForm } from '../../../common/components/combined_fields'; -import { JsonEditor, EDITOR_MODE } from '../json_editor'; -import { CreateDataViewToolTip } from './create_data_view_tooltip'; -const EDITOR_HEIGHT = '300px'; +import type { CombinedField } from '../../../../common/components/combined_fields'; +import { CombinedFieldsForm } from '../../../../common/components/combined_fields'; + +import { CreateDataViewToolTip } from '../create_data_view_tooltip'; +import { IndexSettings, IngestPipeline, Mappings } from './inputs'; +import { SemanticTextInfo } from '../semantic_text_info'; interface Props { index: string; dataView: string; initialized: boolean; - onIndexChange(): void; + onIndexChange(index: string): void; createDataView: boolean; onCreateDataViewChange(): void; onDataViewChange(): void; @@ -70,7 +71,7 @@ export const AdvancedSettings: FC = ({ canCreateDataView, }) => { return ( - + <> = ({ )} value={index} disabled={initialized === true} - onChange={onIndexChange} + onChange={(e) => onIndexChange(e.target.value)} isInvalid={indexNameError !== ''} aria-label={i18n.translate( 'xpack.dataVisualizer.file.advancedImportSettings.indexNameAriaLabel', @@ -139,6 +140,8 @@ export const AdvancedSettings: FC = ({ /> + + = ({ isDisabled={initialized === true} /> + + = ({ /> - - ); -}; - -interface JsonEditorProps { - initialized: boolean; - data: string; - onChange(value: string): void; -} - -const IndexSettings: FC = ({ initialized, data, onChange }) => { - return ( - - - } - fullWidth - > - - - - ); -}; - -const Mappings: FC = ({ initialized, data, onChange }) => { - return ( - - - } - fullWidth - > - - - - ); -}; - -const IngestPipeline: FC = ({ initialized, data, onChange }) => { - return ( - - - } - fullWidth - > - - - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/index.ts b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/index.ts new file mode 100644 index 0000000000000..2e0fc86ab3ad2 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AdvancedSettings } from './advanced'; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/inputs.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/inputs.tsx new file mode 100644 index 0000000000000..7a0d952ef31db --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/inputs.tsx @@ -0,0 +1,96 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n-react'; +import type { FC } from 'react'; +import React from 'react'; + +import { EuiFormRow } from '@elastic/eui'; +import { JsonEditor, EDITOR_MODE } from '../../json_editor'; + +const EDITOR_HEIGHT = '300px'; + +interface JsonEditorProps { + initialized: boolean; + data: string; + onChange(value: string): void; + indexName?: string; +} + +export const IndexSettings: FC = ({ initialized, data, onChange }) => { + return ( + + } + fullWidth + > + + + ); +}; + +export const Mappings: FC = ({ initialized, data, onChange, indexName }) => { + return ( + + ) : ( + + ) + } + fullWidth + > + + + ); +}; + +export const IngestPipeline: FC = ({ initialized, data, onChange }) => { + return ( + + } + fullWidth + > + + + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/use_existing_indices.ts b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/use_existing_indices.ts new file mode 100644 index 0000000000000..19526c799d2aa --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/advanced/use_existing_indices.ts @@ -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 { useCallback, useState, useEffect } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { useDataVisualizerKibana } from '../../../../kibana_context'; + +interface EsIndex { + name: string; + hidden: boolean; + frozen: boolean; +} + +type Pipeline = estypes.IngestPipelineConfig & { + name: string; +}; + +export function useExistingIndices() { + const { + services: { http }, + } = useDataVisualizerKibana(); + + const [indices, setIndices] = useState([]); + const [pipelines, setPipelines] = useState([]); + + const loadIndices = useCallback(() => { + http.get('/api/index_management/indices').then((resp) => { + setIndices(resp.filter((i) => !(i.hidden || i.frozen))); + }); + }, [http]); + + const loadPipelines = useCallback(() => { + http.get('/api/ingest_pipelines').then((resp) => { + setPipelines(resp.sort((a, b) => a.name.localeCompare(b.name))); + }); + }, [http]); + + useEffect(() => { + loadIndices(); + loadPipelines(); + }, [loadIndices, loadPipelines]); + + const getMapping = useCallback( + async (indexName: string) => { + const resp = await http.get( + `/api/index_management/mapping/${indexName}` + ); + return resp.mappings; + }, + [http] + ); + + return { indices, pipelines, getMapping }; +} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/import_settings.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/import_settings.tsx index 381a3ff6e0488..a4c1187d014c8 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/import_settings.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/import_settings.tsx @@ -21,7 +21,7 @@ interface Props { index: string; dataView: string; initialized: boolean; - onIndexChange(): void; + onIndexChange(index: string): void; createDataView: boolean; onCreateDataViewChange(): void; onDataViewChange(): void; @@ -74,7 +74,7 @@ export const ImportSettings: FC = ({ defaultMessage: 'Simple', }), content: ( - + <> = ({ indexNameError={indexNameError} combinedFields={combinedFields} canCreateDataView={canCreateDataView} + results={results} /> - + ), }, { @@ -96,7 +97,7 @@ export const ImportSettings: FC = ({ defaultMessage: 'Advanced', }), content: ( - + <> = ({ results={results} canCreateDataView={canCreateDataView} /> - + ), }, ]; return ( - + <> {}} /> - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/semantic_text_info.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/semantic_text_info.tsx new file mode 100644 index 0000000000000..cf49c23a539db --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/semantic_text_info.tsx @@ -0,0 +1,60 @@ +/* + * 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 type { FC } from 'react'; +import React from 'react'; + +import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { FILE_FORMATS } from '../../../../../common/constants'; + +interface Props { + results: FindFileStructureResponse; +} + +export const SemanticTextInfo: FC = ({ results }) => { + return results.format === FILE_FORMATS.TIKA ? ( + <> + + + + } + color="primary" + iconType="iInCircle" + > + + semantic_text + + ), + }} + /> +
+ +
+ + + + ) : null; +}; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/simple.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/simple.tsx index 1ae343155e87c..ecbfb2ee8ed85 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/simple.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_settings/simple.tsx @@ -11,19 +11,22 @@ import type { FC } from 'react'; import React from 'react'; import { EuiFieldText, EuiFormRow, EuiCheckbox, EuiSpacer } from '@elastic/eui'; +import type { FindFileStructureResponse } from '@kbn/file-upload-plugin/common'; import type { CombinedField } from '../../../common/components/combined_fields'; import { CombinedFieldsReadOnlyForm } from '../../../common/components/combined_fields'; import { CreateDataViewToolTip } from './create_data_view_tooltip'; +import { SemanticTextInfo } from './semantic_text_info'; interface Props { index: string; initialized: boolean; - onIndexChange(): void; + onIndexChange(i: string): void; createDataView: boolean; onCreateDataViewChange(): void; indexNameError: string; combinedFields: CombinedField[]; canCreateDataView: boolean; + results: FindFileStructureResponse; } export const SimpleSettings: FC = ({ @@ -35,6 +38,7 @@ export const SimpleSettings: FC = ({ indexNameError, combinedFields, canCreateDataView, + results, }) => { return ( @@ -57,7 +61,7 @@ export const SimpleSettings: FC = ({ )} value={index} disabled={initialized === true} - onChange={onIndexChange} + onChange={(e) => onIndexChange(e.target.value)} isInvalid={indexNameError !== ''} aria-label={i18n.translate( 'xpack.dataVisualizer.file.simpleImportSettings.indexNameAriaLabel', @@ -87,7 +91,7 @@ export const SimpleSettings: FC = ({ /> - + diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_summary/import_summary.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_summary/import_summary.tsx index a1f6693e0bb9b..b71c2433f4ce0 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_summary/import_summary.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_summary/import_summary.tsx @@ -16,7 +16,7 @@ import { Failures } from './failures'; interface Props { index: string; dataView: string; - ingestPipelineId: string; + pipelineId: string; docCount: number; importFailures: DocFailure[]; createDataView: boolean; @@ -26,7 +26,7 @@ interface Props { export const ImportSummary: FC = ({ index, dataView, - ingestPipelineId, + pipelineId, docCount, importFailures, createDataView, @@ -35,7 +35,7 @@ export const ImportSummary: FC = ({ const items = createDisplayItems( index, dataView, - ingestPipelineId, + pipelineId, docCount, importFailures, createDataView, @@ -99,7 +99,7 @@ export const ImportSummary: FC = ({ function createDisplayItems( index: string, dataView: string, - ingestPipelineId: string, + pipelineId: string, docCount: number, importFailures: DocFailure[], createDataView: boolean, @@ -134,7 +134,7 @@ function createDisplayItems( defaultMessage="Ingest pipeline" /> ), - description: ingestPipelineId, + description: pipelineId, }); } diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import.ts b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import.ts new file mode 100644 index 0000000000000..f4cbe2f03e966 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import.ts @@ -0,0 +1,242 @@ +/* + * 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 type { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types'; +import type { + FindFileStructureResponse, + IngestPipeline, +} from '@kbn/file-upload-plugin/common/types'; +import type { FileUploadStartApi } from '@kbn/file-upload-plugin/public/api'; +import { i18n } from '@kbn/i18n'; +import { IMPORT_STATUS } from '../import_progress/import_progress'; + +interface Props { + data: ArrayBuffer; + results: FindFileStructureResponse; + dataViewsContract: DataViewsServicePublic; + fileUpload: FileUploadStartApi; +} + +interface Config { + index: string; + dataView: string; + createDataView: boolean; + indexSettingsString: string; + mappingsString: string; + pipelineString: string; + pipelineId: string | null; +} + +export async function importData(props: Props, config: Config, setState: (state: unknown) => void) { + const { data, results, dataViewsContract, fileUpload } = props; + const { + index, + dataView, + createDataView, + indexSettingsString, + mappingsString, + pipelineString, + pipelineId, + } = config; + const { format } = results; + + const errors = []; + + if (index === '') { + return; + } + + if ( + (await fileUpload.hasImportPermission({ + checkCreateDataView: createDataView, + checkHasManagePipeline: true, + indexName: index, + })) === false + ) { + errors.push( + i18n.translate('xpack.dataVisualizer.file.importView.importPermissionError', { + defaultMessage: 'You do not have permission to create or import data into index {index}.', + values: { + index, + }, + }) + ); + setState({ + permissionCheckStatus: IMPORT_STATUS.FAILED, + importing: false, + imported: false, + errors, + }); + return; + } + + setState({ + importing: true, + imported: false, + reading: true, + initialized: true, + permissionCheckStatus: IMPORT_STATUS.COMPLETE, + }); + + let success = true; + + let settings = {}; + let mappings = {}; + let pipeline = {}; + + try { + settings = JSON.parse(indexSettingsString); + } catch (error) { + success = false; + const parseError = i18n.translate('xpack.dataVisualizer.file.importView.parseSettingsError', { + defaultMessage: 'Error parsing settings:', + }); + errors.push(`${parseError} ${error.message}`); + } + + try { + mappings = JSON.parse(mappingsString); + } catch (error) { + success = false; + const parseError = i18n.translate('xpack.dataVisualizer.file.importView.parseMappingsError', { + defaultMessage: 'Error parsing mappings:', + }); + errors.push(`${parseError} ${error.message}`); + } + + try { + pipeline = JSON.parse(pipelineString); + } catch (error) { + success = false; + const parseError = i18n.translate('xpack.dataVisualizer.file.importView.parsePipelineError', { + defaultMessage: 'Error parsing ingest pipeline:', + }); + errors.push(`${parseError} ${error.message}`); + } + + setState({ + parseJSONStatus: getSuccess(success), + }); + + if (success === false) { + return; + } + + const importer = await fileUpload.importerFactory(format, { + excludeLinesPattern: results.exclude_lines_pattern, + multilineStartPattern: results.multiline_start_pattern, + }); + + const readResp = importer.read(data); + success = readResp.success; + setState({ + readStatus: getSuccess(success), + reading: false, + importer, + }); + + if (success === false) { + return; + } + + const initializeImportResp = await importer.initializeImport( + index, + settings, + mappings, + pipeline as IngestPipeline + ); + + const timeFieldName = importer.getTimeField(); + setState({ timeFieldName }); + + const indexCreated = initializeImportResp.index !== undefined; + setState({ + indexCreatedStatus: getSuccess(indexCreated), + }); + + const pipelineCreated = initializeImportResp.pipelineId !== undefined; + if (indexCreated) { + setState({ + ingestPipelineCreatedStatus: pipelineCreated ? IMPORT_STATUS.COMPLETE : IMPORT_STATUS.FAILED, + pipelineId: pipelineCreated ? initializeImportResp.pipelineId : '', + }); + } + success = indexCreated && pipelineCreated; + + if (success === false) { + errors.push(initializeImportResp.error); + return; + } + + const importResp = await importer.import( + initializeImportResp.id, + index, + pipelineId ?? initializeImportResp.pipelineId, + (progress: number) => { + setState({ + uploadProgress: progress, + }); + } + ); + success = importResp.success; + setState({ + uploadStatus: getSuccess(importResp.success), + importFailures: importResp.failures, + docCount: importResp.docCount, + }); + + if (success === false) { + errors.push(importResp.error); + return; + } + + if (createDataView) { + const dataViewName = dataView === '' ? index : dataView; + const dataViewResp = await createKibanaDataView(dataViewName, dataViewsContract, timeFieldName); + success = dataViewResp.success; + setState({ + dataViewCreatedStatus: dataViewResp.success ? IMPORT_STATUS.COMPLETE : IMPORT_STATUS.FAILED, + dataViewId: dataViewResp.id, + }); + if (success === false) { + errors.push(dataViewResp.error); + } + } + + setState({ + importing: false, + imported: success, + errors, + }); +} + +async function createKibanaDataView( + dataViewName: string, + dataViewsContract: DataViewsServicePublic, + timeFieldName?: string +) { + try { + const emptyPattern = await dataViewsContract.createAndSave({ + title: dataViewName, + timeFieldName, + }); + + return { + success: true, + id: emptyPattern.id, + }; + } catch (error) { + return { + success: false, + error, + }; + } +} + +function getSuccess(success: boolean) { + return success ? IMPORT_STATUS.COMPLETE : IMPORT_STATUS.FAILED; +} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js index 728b9fcbc6d75..aecf755c1c7c1 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js @@ -20,7 +20,6 @@ import { EuiButtonEmpty, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; import { ResultsLinks } from '../../../common/components/results_links'; import { FilebeatConfigFlyout } from '../../../common/components/filebeat_config_flyout'; @@ -35,6 +34,8 @@ import { getDefaultCombinedFields, } from '../../../common/components/combined_fields'; import { MODE as DATAVISUALIZER_MODE } from '../file_data_visualizer_view/constants'; +import { importData } from './import'; +import { FILE_FORMATS } from '../../../../../common/constants'; const DEFAULT_INDEX_SETTINGS = {}; const CONFIG_MODE = { SIMPLE: 0, ADVANCED: 1 }; @@ -57,7 +58,7 @@ const DEFAULT_STATE = { createDataView: true, dataView: '', dataViewId: '', - ingestPipelineId: '', + pipelineId: null, errors: [], importFailures: [], docCount: 0, @@ -74,6 +75,7 @@ const DEFAULT_STATE = { checkingValidIndex: false, combinedFields: [], importer: undefined, + createPipeline: true, }; export class ImportView extends Component { @@ -96,228 +98,31 @@ export class ImportView extends Component { }; clickImport = () => { - this.import(); - }; - - // TODO - sort this function out. it's a mess - async import() { const { data, results, dataViewsContract, fileUpload } = this.props; + const { + index, + dataView, + createDataView, + indexSettingsString, + mappingsString, + pipelineString, + pipelineId, + } = this.state; - const { format } = results; - let { timeFieldName } = this.state; - const { index, dataView, createDataView, indexSettingsString, mappingsString, pipelineString } = - this.state; - - const errors = []; - - if (index !== '') { - this.setState( - { - importing: true, - errors, - }, - async () => { - // check to see if the user has permission to create and ingest data into the specified index - if ( - (await fileUpload.hasImportPermission({ - checkCreateDataView: createDataView, - checkHasManagePipeline: true, - indexName: index, - })) === false - ) { - errors.push( - i18n.translate('xpack.dataVisualizer.file.importView.importPermissionError', { - defaultMessage: - 'You do not have permission to create or import data into index {index}.', - values: { - index, - }, - }) - ); - this.setState({ - permissionCheckStatus: IMPORT_STATUS.FAILED, - importing: false, - imported: false, - errors, - }); - return; - } - - this.setState( - { - importing: true, - imported: false, - reading: true, - initialized: true, - permissionCheckStatus: IMPORT_STATUS.COMPLETE, - }, - () => { - setTimeout(async () => { - let success = true; - const createPipeline = pipelineString !== ''; - - let settings = {}; - let mappings = {}; - let pipeline = {}; - - try { - settings = JSON.parse(indexSettingsString); - } catch (error) { - success = false; - const parseError = i18n.translate( - 'xpack.dataVisualizer.file.importView.parseSettingsError', - { - defaultMessage: 'Error parsing settings:', - } - ); - errors.push(`${parseError} ${error.message}`); - } - - try { - mappings = JSON.parse(mappingsString); - } catch (error) { - success = false; - const parseError = i18n.translate( - 'xpack.dataVisualizer.file.importView.parseMappingsError', - { - defaultMessage: 'Error parsing mappings:', - } - ); - errors.push(`${parseError} ${error.message}`); - } - - try { - if (createPipeline) { - pipeline = JSON.parse(pipelineString); - } - } catch (error) { - success = false; - const parseError = i18n.translate( - 'xpack.dataVisualizer.file.importView.parsePipelineError', - { - defaultMessage: 'Error parsing ingest pipeline:', - } - ); - errors.push(`${parseError} ${error.message}`); - } - - this.setState({ - parseJSONStatus: success ? IMPORT_STATUS.COMPLETE : IMPORT_STATUS.FAILED, - }); - - if (success) { - const importer = await fileUpload.importerFactory(format, { - excludeLinesPattern: results.exclude_lines_pattern, - multilineStartPattern: results.multiline_start_pattern, - }); - if (importer !== undefined) { - const readResp = importer.read(data, this.setReadProgress); - success = readResp.success; - this.setState({ - readStatus: success ? IMPORT_STATUS.COMPLETE : IMPORT_STATUS.FAILED, - reading: false, - importer, - }); - - if (readResp.success === false) { - console.error(readResp.error); - errors.push(readResp.error); - } - - if (success) { - const initializeImportResp = await importer.initializeImport( - index, - settings, - mappings, - pipeline - ); - - timeFieldName = importer.getTimeField(); - this.setState({ timeFieldName }); - - const indexCreated = initializeImportResp.index !== undefined; - this.setState({ - indexCreatedStatus: indexCreated - ? IMPORT_STATUS.COMPLETE - : IMPORT_STATUS.FAILED, - }); - - if (createPipeline) { - const pipelineCreated = initializeImportResp.pipelineId !== undefined; - if (indexCreated) { - this.setState({ - ingestPipelineCreatedStatus: pipelineCreated - ? IMPORT_STATUS.COMPLETE - : IMPORT_STATUS.FAILED, - ingestPipelineId: pipelineCreated - ? initializeImportResp.pipelineId - : '', - }); - } - success = indexCreated && pipelineCreated; - } else { - success = indexCreated; - } - - if (success) { - const importId = initializeImportResp.id; - const pipelineId = initializeImportResp.pipelineId; - const importResp = await importer.import( - importId, - index, - pipelineId, - this.setImportProgress - ); - success = importResp.success; - this.setState({ - uploadStatus: importResp.success - ? IMPORT_STATUS.COMPLETE - : IMPORT_STATUS.FAILED, - importFailures: importResp.failures, - docCount: importResp.docCount, - }); - - if (success) { - if (createDataView) { - const dataViewName = dataView === '' ? index : dataView; - const dataViewResp = await createKibanaDataView( - dataViewName, - dataViewsContract, - timeFieldName - ); - success = dataViewResp.success; - this.setState({ - dataViewCreatedStatus: dataViewResp.success - ? IMPORT_STATUS.COMPLETE - : IMPORT_STATUS.FAILED, - dataViewId: dataViewResp.id, - }); - if (dataViewResp.success === false) { - errors.push(dataViewResp.error); - } - } - } else { - errors.push(importResp.error); - } - } else { - errors.push(initializeImportResp.error); - } - } - } - } - - this.setState({ - importing: false, - imported: success, - errors, - }); - }, 500); - } - ); - } - ); - } - } + importData( + { data, results, dataViewsContract, fileUpload }, + { + index, + dataView, + createDataView, + indexSettingsString, + mappingsString, + pipelineString, + pipelineId, + }, + (state) => this.setState(state) + ); + }; onConfigModeChange = (configMode) => { this.setState({ @@ -325,8 +130,7 @@ export class ImportView extends Component { }); }; - onIndexChange = (e) => { - const index = e.target.value; + onIndexChange = (index) => { this.setState({ index, checkingValidIndex: true, @@ -385,16 +189,22 @@ export class ImportView extends Component { }); }; - onCombinedFieldsChange = (combinedFields) => { - this.setState({ combinedFields }); + onPipelineIdChange = (text) => { + this.setState({ + pipelineId: text, + }); }; - setImportProgress = (progress) => { + onCreatePipelineChange = (b) => { this.setState({ - uploadProgress: progress, + createPipeline: b, }); }; + onCombinedFieldsChange = (combinedFields) => { + this.setState({ combinedFields }); + }; + setReadProgress = (progress) => { this.setState({ readProgress: progress, @@ -409,6 +219,10 @@ export class ImportView extends Component { this.setState({ isFilebeatFlyoutVisible: false }); }; + closeFilebeatFlyout = () => { + this.setState({ isFilebeatFlyoutVisible: false }); + }; + async loadDataViewNames() { try { const dataViewNames = await this.dataViewsContract.getTitles(); @@ -423,7 +237,7 @@ export class ImportView extends Component { index, dataView, dataViewId, - ingestPipelineId, + pipelineId, importing, imported, reading, @@ -450,10 +264,9 @@ export class ImportView extends Component { checkingValidIndex, combinedFields, importer, + createPipeline, } = this.state; - const createPipeline = pipelineString !== ''; - const statuses = { reading, readStatus, @@ -567,13 +380,15 @@ export class ImportView extends Component { - {importer !== undefined && importer.initialized() && ( - - )} + {importer !== undefined && + importer.initialized() && + this.props.results.format !== FILE_FORMATS.TIKA && ( + + )} {imported === true && ( @@ -582,7 +397,7 @@ export class ImportView extends Component { {isFilebeatFlyoutVisible && ( )} @@ -648,25 +464,6 @@ export class ImportView extends Component { } } -async function createKibanaDataView(dataViewName, dataViewsContract, timeFieldName) { - try { - const emptyPattern = await dataViewsContract.createAndSave({ - title: dataViewName, - timeFieldName, - }); - - return { - success: true, - id: emptyPattern.id, - }; - } catch (error) { - return { - success: false, - error, - }; - } -} - function getDefaultState(state, results, capabilities) { const indexSettingsString = state.indexSettingsString === '' diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/results_view/results_view.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/results_view/results_view.tsx index 0c55b838e37b8..5e8658ab85433 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/results_view/results_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/results_view/results_view.tsx @@ -28,7 +28,8 @@ import { FieldsStatsGrid } from '../../../common/components/fields_stats_grid'; import { MODE as DATAVISUALIZER_MODE } from '../file_data_visualizer_view/constants'; interface Props { - data: string; + fileContents: string; + data: ArrayBuffer; fileName: string; results: FindFileStructureResponse; showEditFlyout(): void; @@ -40,7 +41,7 @@ interface Props { } export const ResultsView: FC = ({ - data, + fileContents, fileName, results, showEditFlyout, @@ -87,7 +88,7 @@ export const ResultsView: FC = ({

= ({ - showExplanationFlyout()} disabled={disableButtons}> - - + {results.format !== FILE_FORMATS.TIKA ? ( + showExplanationFlyout()} disabled={disableButtons}> + + + ) : null} - + {results.format !== FILE_FORMATS.TIKA ? ( + <> + - - -

- -

-
+ + +

+ +

+
- -
+ +
+ + ) : null}
); diff --git a/x-pack/plugins/data_visualizer/server/routes.ts b/x-pack/plugins/data_visualizer/server/routes.ts index c4e286f9671d1..a21f1ed93c1d3 100644 --- a/x-pack/plugins/data_visualizer/server/routes.ts +++ b/x-pack/plugins/data_visualizer/server/routes.ts @@ -66,4 +66,32 @@ export function routes(coreSetup: CoreSetup, logger: Logger) } } ); + + router.versioned + .get({ + path: '/internal/data_visualizer/inference_services', + access: 'internal', + options: { + tags: ['access:fileUpload:analyzeFile'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + }, + async (context, request, response) => { + try { + const esClient = (await context.core).elasticsearch.client; + // @ts-expect-error types are wrong + const { endpoints } = await esClient.asCurrentUser.inference.getModel({ + inference_id: '_all', + }); + + return response.ok({ body: endpoints }); + } catch (e) { + return response.customError(wrapError(e)); + } + } + ); } diff --git a/x-pack/plugins/data_visualizer/tsconfig.json b/x-pack/plugins/data_visualizer/tsconfig.json index ed8a3540f6d8a..79060fbbe53f9 100644 --- a/x-pack/plugins/data_visualizer/tsconfig.json +++ b/x-pack/plugins/data_visualizer/tsconfig.json @@ -83,6 +83,7 @@ "@kbn/shared-ux-utility", "@kbn/search-types", "@kbn/unified-field-list", + "@kbn/core-http-browser", "@kbn/content-management-utils", "@kbn/core-lifecycle-browser", "@kbn/presentation-containers", diff --git a/x-pack/plugins/file_upload/common/constants.ts b/x-pack/plugins/file_upload/common/constants.ts index 725cd3f81a650..af0c3c07db6c8 100644 --- a/x-pack/plugins/file_upload/common/constants.ts +++ b/x-pack/plugins/file_upload/common/constants.ts @@ -8,9 +8,10 @@ export const UI_SETTING_MAX_FILE_SIZE = 'fileUpload:maxFileSize'; export const MB = Math.pow(2, 20); export const MAX_FILE_SIZE = '100MB'; -export const MAX_FILE_SIZE_BYTES = 104857600; // 100MB +export const MAX_FILE_SIZE_BYTES = 524288000; // 500MB export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 1073741274; // 1GB export const FILE_SIZE_DISPLAY_FORMAT = '0,0.[0] b'; +export const MAX_TIKA_FILE_SIZE_BYTES = 62914560; // 60MB // Value to use in the Elasticsearch index mapping meta data to identify the // index as having been created by the ML File Data Visualizer. @@ -20,5 +21,5 @@ export const FILE_FORMATS = { DELIMITED: 'delimited', NDJSON: 'ndjson', SEMI_STRUCTURED_TEXT: 'semi_structured_text', - // XML: 'xml', + TIKA: 'tika', }; diff --git a/x-pack/plugins/file_upload/common/types.ts b/x-pack/plugins/file_upload/common/types.ts index f752d5e5a8507..409b5fcac80a1 100644 --- a/x-pack/plugins/file_upload/common/types.ts +++ b/x-pack/plugins/file_upload/common/types.ts @@ -28,6 +28,7 @@ export interface FindFileStructureResponse { has_header_row: boolean; has_byte_order_marker: boolean; format: string; + document_type?: string; field_stats: { [fieldName: string]: { count: number; @@ -56,6 +57,7 @@ export interface FindFileStructureResponse { }; }; }; + ingest_pipeline: IngestPipeline; quote: string; delimiter: string; need_client_timezone: boolean; @@ -123,14 +125,32 @@ export interface ImportDocMessage { message: string; } -export type ImportDoc = ImportDocMessage | string | object; +export interface ImportDocTika { + data: string; +} + +export type ImportDoc = ImportDocMessage | ImportDocTika | string | object; export interface IngestPipelineWrapper { id: string; - pipeline: IngestPipeline; + pipeline?: IngestPipeline; } export interface IngestPipeline { description: string; processors: any[]; + isManaged?: boolean; + name?: string; +} + +export interface PreviewTikaResponse { + date?: string; + content_type: string; + author?: string; + format: string; + modified: string; + language: string; + creator_tool?: string; + content: string; + content_length: number; } diff --git a/x-pack/plugins/file_upload/public/api/index.ts b/x-pack/plugins/file_upload/public/api/index.ts index 4d6483f2b2a50..36314ae849680 100644 --- a/x-pack/plugins/file_upload/public/api/index.ts +++ b/x-pack/plugins/file_upload/public/api/index.ts @@ -5,10 +5,20 @@ * 2.0. */ +import { fromByteArray } from 'base64-js'; import { lazyLoadModules } from '../lazy_load_bundle'; import type { IImporter, ImportFactoryOptions } from '../importer'; -import type { HasImportPermission, FindFileStructureResponse } from '../../common/types'; -import type { getMaxBytes, getMaxBytesFormatted } from '../importer/get_max_bytes'; +import type { + HasImportPermission, + FindFileStructureResponse, + PreviewTikaResponse, +} from '../../common/types'; +import type { + getMaxBytes, + getMaxBytesFormatted, + getMaxTikaBytes, + getMaxTikaBytesFormatted, +} from '../importer/get_max_bytes'; import { GeoUploadWizardAsyncWrapper } from './geo_upload_wizard_async_wrapper'; import { IndexNameFormAsyncWrapper } from './index_name_form_async_wrapper'; @@ -18,10 +28,13 @@ export interface FileUploadStartApi { importerFactory: typeof importerFactory; getMaxBytes: typeof getMaxBytes; getMaxBytesFormatted: typeof getMaxBytesFormatted; + getMaxTikaBytes: typeof getMaxTikaBytes; + getMaxTikaBytesFormatted: typeof getMaxTikaBytesFormatted; hasImportPermission: typeof hasImportPermission; checkIndexExists: typeof checkIndexExists; getTimeFieldRange: typeof getTimeFieldRange; analyzeFile: typeof analyzeFile; + previewTikaFile: typeof previewTikaFile; } export interface GetTimeFieldRangeResponse { @@ -36,7 +49,7 @@ export const IndexNameFormComponent = IndexNameFormAsyncWrapper; export async function importerFactory( format: string, options: ImportFactoryOptions -): Promise { +): Promise { const fileUploadModules = await lazyLoadModules(); return fileUploadModules.importerFactory(format, options); } @@ -62,6 +75,24 @@ export async function analyzeFile( }); } +export async function previewTikaFile( + data: ArrayBuffer, + params: Record = {} +): Promise { + const { getHttp } = await lazyLoadModules(); + const base64File = fromByteArray(new Uint8Array(data)); + const body = JSON.stringify({ + base64File, + }); + return await getHttp().fetch({ + path: `/internal/file_upload/preview_tika_contents`, + method: 'POST', + version: '1', + body, + query: params, + }); +} + export async function hasImportPermission(params: HasImportPermissionParams): Promise { const fileUploadModules = await lazyLoadModules(); try { diff --git a/x-pack/plugins/file_upload/public/importer/get_max_bytes.ts b/x-pack/plugins/file_upload/public/importer/get_max_bytes.ts index e05f1978dcf95..d4f7457384533 100644 --- a/x-pack/plugins/file_upload/public/importer/get_max_bytes.ts +++ b/x-pack/plugins/file_upload/public/importer/get_max_bytes.ts @@ -12,6 +12,7 @@ import { MAX_FILE_SIZE, MAX_FILE_SIZE_BYTES, UI_SETTING_MAX_FILE_SIZE, + MAX_TIKA_FILE_SIZE_BYTES, } from '../../common/constants'; import { getUiSettings } from '../kibana_services'; @@ -28,3 +29,11 @@ export function getMaxBytes() { export function getMaxBytesFormatted() { return numeral(getMaxBytes()).format(FILE_SIZE_DISPLAY_FORMAT); } + +export function getMaxTikaBytes() { + return MAX_TIKA_FILE_SIZE_BYTES; +} + +export function getMaxTikaBytesFormatted() { + return numeral(getMaxTikaBytes()).format(FILE_SIZE_DISPLAY_FORMAT); +} diff --git a/x-pack/plugins/file_upload/public/importer/importer.ts b/x-pack/plugins/file_upload/public/importer/importer.ts index 94a15b9905ff0..6337e892868fe 100644 --- a/x-pack/plugins/file_upload/public/importer/importer.ts +++ b/x-pack/plugins/file_upload/public/importer/importer.ts @@ -27,7 +27,7 @@ const DEFAULT_TIME_FIELD = '@timestamp'; export abstract class Importer implements IImporter { protected _docArray: ImportDoc[] = []; - private _chunkSize = CHUNK_SIZE; + protected _chunkSize = CHUNK_SIZE; private _index: string | undefined; private _pipeline: IngestPipeline | undefined; private _timeFieldName: string | undefined; @@ -282,6 +282,10 @@ function updatePipelineTimezone(ingestPipeline: IngestPipeline) { } function createDocumentChunks(docArray: ImportDoc[], chunkSize: number) { + if (chunkSize === 0) { + return [docArray]; + } + const chunks: ImportDoc[][] = []; // chop docArray into chunks const tempChunks = chunk(docArray, chunkSize); diff --git a/x-pack/plugins/file_upload/public/importer/importer_factory.ts b/x-pack/plugins/file_upload/public/importer/importer_factory.ts index 0ad05676244be..28c7364b2eb21 100644 --- a/x-pack/plugins/file_upload/public/importer/importer_factory.ts +++ b/x-pack/plugins/file_upload/public/importer/importer_factory.ts @@ -7,6 +7,7 @@ import { MessageImporter } from './message_importer'; import { NdjsonImporter } from './ndjson_importer'; +import { TikaImporter } from './tika_importer'; import { ImportFactoryOptions } from './types'; import { FILE_FORMATS } from '../../common/constants'; @@ -21,7 +22,9 @@ export function importerFactory(format: string, options: ImportFactoryOptions) { return new MessageImporter(options); case FILE_FORMATS.NDJSON: return new NdjsonImporter(); + case FILE_FORMATS.TIKA: + return new TikaImporter(); default: - return; + throw new Error('Importer not found for format'); } } diff --git a/x-pack/plugins/file_upload/public/importer/tika_importer.ts b/x-pack/plugins/file_upload/public/importer/tika_importer.ts new file mode 100644 index 0000000000000..78ccf86003385 --- /dev/null +++ b/x-pack/plugins/file_upload/public/importer/tika_importer.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fromByteArray } from 'base64-js'; +import { ImportDocTika } from '../../common/types'; +import { Importer } from './importer'; +import { CreateDocsResponse } from './types'; + +export class TikaImporter extends Importer { + constructor() { + super(); + } + + public read(data: ArrayBuffer) { + this._chunkSize = 0; + const pdfBase64 = fromByteArray(new Uint8Array(data)); + const { success, docs } = this._createDocs(pdfBase64); + if (success) { + this._docArray = this._docArray.concat(docs); + } else { + return { success: false }; + } + return { success: true }; + } + + protected _createDocs(base64String: string): CreateDocsResponse { + const remainder = 0; + try { + const docs = [{ data: base64String }]; + return { + success: true, + docs, + remainder, + }; + } catch (error) { + return { + success: false, + docs: [], + remainder, + error, + }; + } + } +} diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index 0066a2efbc5cb..d7886ec35b675 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -35,7 +35,7 @@ let loadModulesPromise: Promise; export interface LazyLoadedFileUploadModules { GeoUploadWizard: React.ComponentType; IndexNameForm: React.ComponentType; - importerFactory: (format: string, options: ImportFactoryOptions) => IImporter | undefined; + importerFactory: (format: string, options: ImportFactoryOptions) => IImporter; getHttp: () => HttpStart; } diff --git a/x-pack/plugins/file_upload/public/plugin.ts b/x-pack/plugins/file_upload/public/plugin.ts index cbf64371fc4c3..cfb3e2fcf723d 100644 --- a/x-pack/plugins/file_upload/public/plugin.ts +++ b/x-pack/plugins/file_upload/public/plugin.ts @@ -16,9 +16,15 @@ import { checkIndexExists, getTimeFieldRange, analyzeFile, + previewTikaFile, } from './api'; import { setStartServices } from './kibana_services'; -import { getMaxBytes, getMaxBytesFormatted } from './importer/get_max_bytes'; +import { + getMaxBytes, + getMaxBytesFormatted, + getMaxTikaBytes, + getMaxTikaBytesFormatted, +} from './importer/get_max_bytes'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface FileUploadSetupDependencies {} @@ -48,10 +54,13 @@ export class FileUploadPlugin importerFactory, getMaxBytes, getMaxBytesFormatted, + getMaxTikaBytes, + getMaxTikaBytesFormatted, hasImportPermission, checkIndexExists, getTimeFieldRange, analyzeFile, + previewTikaFile, }; } } diff --git a/x-pack/plugins/file_upload/server/preview_tika_contents.ts b/x-pack/plugins/file_upload/server/preview_tika_contents.ts new file mode 100644 index 0000000000000..f99a070d90414 --- /dev/null +++ b/x-pack/plugins/file_upload/server/preview_tika_contents.ts @@ -0,0 +1,50 @@ +/* + * 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 type { IScopedClusterClient } from '@kbn/core/server'; +import type { PreviewTikaResponse } from '../common/types'; + +/** + * Returns the contents of a file using the attachment ingest processor + * @param client IScopedClusterClient + * @param base64File bae64 encoded file + */ +export async function previewTikaContents( + client: IScopedClusterClient, + base64File: string +): Promise { + const pipeline = { + description: '', + processors: [ + { + attachment: { + field: 'data', + remove_binary: true, + }, + }, + ], + }; + + const resp = await client.asInternalUser.ingest.simulate({ + pipeline, + docs: [ + { + _index: 'index', + _id: 'id', + _source: { + data: base64File, + }, + }, + ], + }); + + if (!resp.docs[0].doc?._source.attachment) { + throw new Error('Failed to extract text from file.'); + } + + return resp.docs[0].doc?._source.attachment; +} diff --git a/x-pack/plugins/file_upload/server/routes.ts b/x-pack/plugins/file_upload/server/routes.ts index 6d80f8f05cb3a..4336049b7fe58 100644 --- a/x-pack/plugins/file_upload/server/routes.ts +++ b/x-pack/plugins/file_upload/server/routes.ts @@ -12,7 +12,7 @@ import type { IndicesIndexSettings, MappingTypeMapping, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { MAX_FILE_SIZE_BYTES } from '../common/constants'; +import { MAX_FILE_SIZE_BYTES, MAX_TIKA_FILE_SIZE_BYTES } from '../common/constants'; import type { IngestPipelineWrapper, InputData } from '../common/types'; import { wrapError } from './error_wrapper'; import { importDataProvider } from './import_data'; @@ -29,6 +29,7 @@ import { import type { StartDeps } from './types'; import { checkFileUploadPrivileges } from './check_privileges'; import { previewIndexTimeRange } from './preview_index_time_range'; +import { previewTikaContents } from './preview_tika_contents'; function importData( client: IScopedClusterClient, @@ -314,6 +315,51 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge const esClient = (await context.core).elasticsearch.client; const resp = await previewIndexTimeRange(esClient, timeField, pipeline, docs); + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + } + ); + + /** + * @apiGroup FileDataVisualizer + * + * @api {post} /internal/file_upload/preview_tika_contents Returns the contents of a file using the attachment ingest processor + * @apiName PreviewTikaContents + * @apiDescription Preview the contents of a file using the attachment ingest processor + */ + router.versioned + .post({ + path: '/internal/file_upload/preview_tika_contents', + access: 'internal', + options: { + tags: ['access:fileUpload:analyzeFile'], + body: { + accepts: ['application/json'], + maxBytes: MAX_TIKA_FILE_SIZE_BYTES, + }, + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: schema.object({ + base64File: schema.string(), + }), + }, + }, + }, + async (context, request, response) => { + try { + const { base64File } = request.body; + const esClient = (await context.core).elasticsearch.client; + const resp = await previewTikaContents(esClient, base64File); + return response.ok({ body: resp, }); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 580e8255cb29a..a0443e37fa617 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -14992,8 +14992,6 @@ "xpack.dataVisualizer.file.welcomeContent.delimitedTextFilesDescription": "Fichiers texte délimités, tels que CSV et TSV", "xpack.dataVisualizer.file.welcomeContent.logFilesWithCommonFormatDescription": "Fichiers log avec un format d'horodatage courant", "xpack.dataVisualizer.file.welcomeContent.newlineDelimitedJsonDescription": "JSON délimité par une nouvelle ligne", - "xpack.dataVisualizer.file.welcomeContent.supportedFileFormatDescription": "Les formats de fichier suivants sont pris en charge :", - "xpack.dataVisualizer.file.welcomeContent.uploadedFilesAllowedSizeDescription": "Vous pouvez charger des fichiers d'une taille allant jusqu'à {maxFileSize}.", "xpack.dataVisualizer.file.welcomeContent.visualizeAndImportDataFromLogFileDescription": "Chargez votre fichier, analysez ses données et, si vous le souhaitez, importez les données dans un index Elasticsearch.", "xpack.dataVisualizer.file.welcomeContent.visualizeDataFromLogFileDescription": "Téléchargez votre fichier et analysez ses données.", "xpack.dataVisualizer.file.welcomeContent.visualizeDataFromLogFileTitle": "Charger les données à partir d'un fichier", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7eea338195202..e662987308271 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14978,8 +14978,6 @@ "xpack.dataVisualizer.file.welcomeContent.delimitedTextFilesDescription": "CSV や TSV などの区切られたテキストファイル", "xpack.dataVisualizer.file.welcomeContent.logFilesWithCommonFormatDescription": "タイムスタンプの一般的フォーマットのログファイル", "xpack.dataVisualizer.file.welcomeContent.newlineDelimitedJsonDescription": "改行区切りの JSON", - "xpack.dataVisualizer.file.welcomeContent.supportedFileFormatDescription": "次のファイル形式がサポートされます。", - "xpack.dataVisualizer.file.welcomeContent.uploadedFilesAllowedSizeDescription": "最大{maxFileSize}のファイルをアップロードできます。", "xpack.dataVisualizer.file.welcomeContent.visualizeAndImportDataFromLogFileDescription": "ファイルをアップロードして、データを分析し、任意でデータをElasticsearchインデックスにインポートできます。", "xpack.dataVisualizer.file.welcomeContent.visualizeDataFromLogFileDescription": "ファイルをアップロードし、データを分析します。", "xpack.dataVisualizer.file.welcomeContent.visualizeDataFromLogFileTitle": "ファイルからデータをアップロード", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 76ac5c13e4e13..ef6badee07f44 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15003,8 +15003,6 @@ "xpack.dataVisualizer.file.welcomeContent.delimitedTextFilesDescription": "分隔的文本文件,例如 CSV 和 TSV", "xpack.dataVisualizer.file.welcomeContent.logFilesWithCommonFormatDescription": "具有时间戳通用格式的日志文件", "xpack.dataVisualizer.file.welcomeContent.newlineDelimitedJsonDescription": "换行符分隔的 JSON", - "xpack.dataVisualizer.file.welcomeContent.supportedFileFormatDescription": "支持以下文件格式:", - "xpack.dataVisualizer.file.welcomeContent.uploadedFilesAllowedSizeDescription": "您可以上传不超过 {maxFileSize} 的文件。", "xpack.dataVisualizer.file.welcomeContent.visualizeAndImportDataFromLogFileDescription": "上传文件、分析文件数据,然后根据需要将数据导入 Elasticsearch 索引。", "xpack.dataVisualizer.file.welcomeContent.visualizeDataFromLogFileDescription": "上传您的文件并分析其数据。", "xpack.dataVisualizer.file.welcomeContent.visualizeDataFromLogFileTitle": "从文件上传数据", diff --git a/x-pack/test/api_integration/apis/file_upload/index.ts b/x-pack/test/api_integration/apis/file_upload/index.ts index f2232c0cfcbce..9e1a913664a83 100644 --- a/x-pack/test/api_integration/apis/file_upload/index.ts +++ b/x-pack/test/api_integration/apis/file_upload/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./has_import_permission')); loadTestFile(require.resolve('./index_exists')); loadTestFile(require.resolve('./preview_index_time_range')); + loadTestFile(require.resolve('./preview_tika_contents')); }); } diff --git a/x-pack/test/api_integration/apis/file_upload/pdf_base64.ts b/x-pack/test/api_integration/apis/file_upload/pdf_base64.ts new file mode 100644 index 0000000000000..8d6e40b2c9162 --- /dev/null +++ b/x-pack/test/api_integration/apis/file_upload/pdf_base64.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const pdfBase64 = + 'JVBERi0xLjUNCiW1tbW1DQoxIDAgb2JqDQo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFIvTGFuZyhlbi1VUykgL1N0cnVjdFRyZWVSb290IDkgMCBSL01hcmtJbmZvPDwvTWFya2VkIHRydWU+Pj4+DQplbmRvYmoNCjIgMCBvYmoNCjw8L1R5cGUvUGFnZXMvQ291bnQgMS9LaWRzWyAzIDAgUl0gPj4NCmVuZG9iag0KMyAwIG9iag0KPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9SZXNvdXJjZXM8PC9Gb250PDwvRjEgNSAwIFI+Pi9FeHRHU3RhdGU8PC9HUzcgNyAwIFI+Pi9Qcm9jU2V0Wy9QREYvVGV4dC9JbWFnZUIvSW1hZ2VDL0ltYWdlSV0gPj4vTWVkaWFCb3hbIDAgMCA2MTIgNzkyXSAvQ29udGVudHMgNCAwIFIvR3JvdXA8PC9UeXBlL0dyb3VwL1MvVHJhbnNwYXJlbmN5L0NTL0RldmljZVJHQj4+L1RhYnMvUy9TdHJ1Y3RQYXJlbnRzIDA+Pg0KZW5kb2JqDQo0IDAgb2JqDQo8PC9GaWx0ZXIvRmxhdGVEZWNvZGUvTGVuZ3RoIDE3Mz4+DQpzdHJlYW0NCnicdY0/C4MwFMT3QL7DjclgTGIaGxAH/9KCUKibdOigVmgn/f40DoU6hPd4HLy73yG+IcvirrxUkHmOoipR9JTEjYJSQhr0EyUK0o9CqoXUBql04uQ/H+9r7ynmlRKJeT8tJQPrX8sKv09wx7aRRwlbNy/BH+ivlNS+YC/5YZV2Qtt/7MBu3LKKR4Y1oZCxIj0fQ8EC40RytE7Lmxs2hgI2ESrMRt2V+AImUj6DDQplbmRzdHJlYW0NCmVuZG9iag0KNSAwIG9iag0KPDwvVHlwZS9Gb250L1N1YnR5cGUvVHJ1ZVR5cGUvTmFtZS9GMS9CYXNlRm9udC9BQkNERUUrQ2FsaWJyaS9FbmNvZGluZy9XaW5BbnNpRW5jb2RpbmcvRm9udERlc2NyaXB0b3IgNiAwIFIvRmlyc3RDaGFyIDMyL0xhc3RDaGFyIDExNi9XaWR0aHMgMTYgMCBSPj4NCmVuZG9iag0KNiAwIG9iag0KPDwvVHlwZS9Gb250RGVzY3JpcHRvci9Gb250TmFtZS9BQkNERUUrQ2Fs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Erk//bAXh+79HSell91UZD3O8yatTeW/E9N55w97TyxUKJv2aM/DyX1CDo/UtBYtm9id2CPx84zWxgCp9h/VE18FH3V4sVs3tv+cy6SUvLAEVSfu421MDY7vlGZWHzWqCniX0/Hkl5n412Pdk5JkQ2ZdeSostPDa3HXf/4dbMbu/vPou4nOanWO6d9z2fXtvtuo+Cd9xYuLqpMIs++XCHIfOa58pvTezq7KioHmt26xDm5s7/dyttJuDFY1W/Ht13L6kjOnAulVFwbE7Mo7XvTa0zZil3vB5C9uG55d3COo0XcrZv/5+4aYXzUkrtjGWGdzicwjeFM36+fsWsxTWYEXR7nS3PIGs3EjhoLwtvAwAgfW2Aw0KZW5kc3RyZWFtDQplbmRvYmoNCjE4IDAgb2JqDQo8PC9UeXBlL1hSZWYvU2l6ZSAxOC9XWyAxIDQgMl0gL1Jvb3QgMSAwIFIvSW5mbyA4IDAgUi9JRFs8REUwMjI4M0E3RkU4QTM0NDg1NERENUI3MzdEQkM4QUU+PERFMDIyODNBN0ZFOEEzNDQ4NTRERDVCNzM3REJDOEFFPl0gL0ZpbHRlci9GbGF0ZURlY29kZS9MZW5ndGggNzU+Pg0Kc3RyZWFtDQp4nGNgAIL//xmBpCADA4iqgVBbwBTjJjDFtBJMMQeCKRYHCFUKlACq5GdgglDMEIoFQjFCKKgSVqAGVj+wPrazYIp9BQMDAGJaCAQNCmVuZHN0cmVhbQ0KZW5kb2JqDQp4cmVmDQowIDE5DQowMDAwMDAwMDA5IDY1NTM1IGYNCjAwMDAwMDAwMTcgMDAwMDAgbg0KMDAwMDAwMDEyNCAwMDAwMCBuDQowMDAwMDAwMTgwIDAwMDAwIG4NCjAwMDAwMDA0MzQgMDAwMDAgbg0KMDAwMDAwMDY4MSAwMDAwMCBuDQowMDAwMDAwODQ5IDAwMDAwIG4NCjAwMDAwMDEwODggMDAwMDAgbg0KMDAwMDAwMTE0MSAwMDAwMCBuDQowMDAwMDAwMDEwIDY1NTM1IGYNCjAwMDAwMDAwMTEgNjU1MzUgZg0KMDAwMDAwMDAxMiA2NTUzNSBmDQowMDAwMDAwMDEzIDY1NTM1IGYNCjAwMDAwMDAwMTQgNjU1MzUgZg0KMDAwMDAwMDAxNSA2NTUzNSBmDQowMDAwMDAwMDAwIDY1NTM1IGYNCjAwMDAwMDE3NDEgMDAwMDAgbg0KMDAwMDAwMTk2MCAwMDAwMCBuDQowMDAwMDgyMTk4IDAwMDAwIG4NCnRyYWlsZXINCjw8L1NpemUgMTkvUm9vdCAxIDAgUi9JbmZvIDggMCBSL0lEWzxERTAyMjgzQTdGRThBMzQ0ODU0REQ1QjczN0RCQzhBRT48REUwMjI4M0E3RkU4QTM0NDg1NERENUI3MzdEQkM4QUU+XSA+Pg0Kc3RhcnR4cmVmDQo4MjQ3Mg0KJSVFT0YNCnhyZWYNCjAgMA0KdHJhaWxlcg0KPDwvU2l6ZSAxOS9Sb290IDEgMCBSL0luZm8gOCAwIFIvSURbPERFMDIyODNBN0ZFOEEzNDQ4NTRERDVCNzM3REJDOEFFPjxERTAyMjgzQTdGRThBMzQ0ODU0REQ1QjczN0RCQzhBRT5dIC9QcmV2IDgyNDcyL1hSZWZTdG0gODIxOTg+Pg0Kc3RhcnR4cmVmDQo4MzAwOA0KJSVFT0Y='; diff --git a/x-pack/test/api_integration/apis/file_upload/preview_tika_contents.ts b/x-pack/test/api_integration/apis/file_upload/preview_tika_contents.ts new file mode 100644 index 0000000000000..b961ca8f3225b --- /dev/null +++ b/x-pack/test/api_integration/apis/file_upload/preview_tika_contents.ts @@ -0,0 +1,49 @@ +/* + * 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 { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { pdfBase64 } from './pdf_base64'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + + async function runRequest(base64File: string, expectedResponseCode: number = 200) { + const { body } = await supertest + .post(`/internal/file_upload/preview_tika_contents`) + .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send({ base64File }) + .expect(expectedResponseCode); + + return body; + } + const expectedResponse = { + date: '2010-12-01T13:33:24Z', + content_type: 'application/pdf', + author: 'John', + format: 'application/pdf; version=1.5', + modified: '2010-12-01T13:33:24Z', + language: 'en', + creator_tool: 'Microsoft® Word 2010', + content: 'This is a test PDF file', + content_length: 28, + }; + + describe('POST /internal/file_upload/preview_tika_content', () => { + it('should return the text content from the file', async () => { + const resp = await runRequest(pdfBase64); + + expect(resp).to.eql(expectedResponse); + }); + + it('should fail to return text when bad data is sent', async () => { + await runRequest('bad data', 500); + }); + }); +}; From 00975ad1a4a2528b7a61f1f0e645b8cfff7aef2e Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Thu, 22 Aug 2024 12:15:13 +0200 Subject: [PATCH 27/45] [Infra] Add a link to host metrics docs (#190993) ## Summary The link is not shown for K8s pods image The link is shown for hosts image ### How to test - Navigate to `Inventory` - Click on Alerts and Rules > Infrastructure > Create Inventory rule - Click on the metric to open the metric selection popover - Validate if the link appears and redirects to the docs page - Do the same with other asset types (Containers, Docker, etc) --- .../inventory/components/expression.tsx | 10 ++++---- .../alerting/inventory/components/metric.tsx | 23 ++++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression.tsx index 5d07c2801e947..f89cd7e778763 100644 --- a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression.tsx @@ -444,7 +444,8 @@ const StyledHealthCss = css` export const ExpressionRow: FC> = (props) => { const [isExpanded, toggle] = useToggle(true); - const { children, setRuleParams, expression, errors, expressionId, remove, canDelete } = props; + const { children, setRuleParams, expression, errors, expressionId, remove, canDelete, nodeType } = + props; const { metric, comparator = COMPARATORS.GREATER_THAN, @@ -554,7 +555,7 @@ export const ExpressionRow: FC> = (props) const ofFields = useMemo(() => { let myMetrics: SnapshotMetricType[] = hostSnapshotMetricTypes; - switch (props.nodeType) { + switch (nodeType) { case 'awsEC2': myMetrics = awsEC2SnapshotMetricTypes; break; @@ -577,8 +578,8 @@ export const ExpressionRow: FC> = (props) myMetrics = containerSnapshotMetricTypes; break; } - return myMetrics.map((myMetric) => toMetricOpt(myMetric, props.nodeType)); - }, [props.nodeType]); + return myMetrics.map((myMetric) => toMetricOpt(myMetric, nodeType)); + }, [nodeType]); return ( <> @@ -608,6 +609,7 @@ export const ExpressionRow: FC> = (props) text: string; }> } + nodeType={nodeType} onChange={updateMetric} onChangeCustom={updateCustomMetric} errors={errors} diff --git a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx index b6b24b9d721e2..bed80c31066ab 100644 --- a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx +++ b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiLink, EuiPopover, EuiPopoverTitle, EuiSelect, @@ -24,6 +25,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { debounce } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public'; +import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; +import { HOST_METRICS_DOC_HREF } from '../../../common/visualizations'; import { useMetricsDataViewContext } from '../../../containers/metrics_source'; import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; import { @@ -37,6 +40,7 @@ import { interface Props { metric?: { value: string; text: string }; metrics: Array<{ value: string; text: string }>; + nodeType: InventoryItemType; errors: IErrorObject; onChange: (metric?: string) => void; onChangeCustom: (customMetric?: SnapshotCustomMetricInput) => void; @@ -91,6 +95,7 @@ export const MetricExpression = ({ onChange, onChangeCustom, popupPosition, + nodeType, }: Props) => { const [popoverOpen, setPopoverOpen] = useState(false); const [customMetricTabOpen, setCustomMetricTabOpen] = useState(metric?.value === 'custom'); @@ -312,7 +317,23 @@ export const MetricExpression = ({ ) : ( - + + {nodeType === 'host' && ( + + + {i18n.translate( + 'xpack.infra.metrics.alertFlyout.expression.metric.whatAreTheseMetricsLink', + { + defaultMessage: 'What are these metrics?', + } + )} + + + )} Date: Thu, 22 Aug 2024 12:20:24 +0200 Subject: [PATCH 28/45] Add alert grouping functionality to the observability alerts page (#189958) Closes #190995 ## Summary This PR adds grouping functionality to the alerts page alert table based on @umbopepato's implementation in this [draft PR](https://github.com/elastic/kibana/pull/183114) (basically, he implemented the feature and I adjusted a bit for our use case :D). For now, we only added the **rule** and **source** as default grouping, and I will create a ticket to add tags as well. The challenge with tags is that since it is an array, the value of the alert is joined by a comma as the group, which does not match with what we want for tags. ![image](https://github.com/user-attachments/assets/c08c3cb1-4c6c-4918-8071-3c5913de41f6) Here is how we show the rules that don't have a group by field selected for them: (We used "ungrouped" similar to what we have in SLOs) ![image](https://github.com/user-attachments/assets/280bbd34-6c3b-41c1-803b-dcc6448f6fb4) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: DeDe Morton Co-authored-by: Shahzad --- .../src/components/alerts_grouping.test.tsx | 2 +- .../src/components/alerts_grouping.tsx | 2 +- .../src/components/alerts_grouping_level.tsx | 3 +- .../src/contexts/alerts_grouping_context.tsx | 1 - packages/kbn-alerts-grouping/src/types.ts | 2 +- .../observability/common/typings.ts | 2 + .../components/alert_search_bar/constants.ts | 37 ++++++++ .../get_alerts_page_table_configuration.tsx | 27 +++++- .../alerts/get_persistent_controls.ts | 66 ++++++++++++++ .../get_alerts_page_table_configuration.tsx | 58 +++++++++++++ .../register_alerts_table_configuration.tsx | 23 ++++- .../public/components/alerts_table/types.ts | 36 ++++++++ .../observability/public/components/tags.tsx | 47 ++++++---- .../observability/public/constants.ts | 1 + .../public/pages/alerts/alerts.tsx | 80 ++++++++++++----- .../public/pages/alerts/grouping/constants.ts | 32 +++++++ .../get_aggregations_by_grouping_field.ts | 53 +++++++++++ .../pages/alerts/grouping/get_group_stats.tsx | 57 ++++++++++++ .../alerts/grouping/render_group_panel.tsx | 87 +++++++++++++++++++ .../alerts/helpers/merge_bool_queries.ts | 25 ++++++ .../observability/public/plugin.ts | 10 ++- .../observability/tsconfig.json | 3 + .../server/alert_data_client/alerts_client.ts | 2 +- .../get_alerts_group_aggregations.test.ts | 2 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 27 files changed, 605 insertions(+), 59 deletions(-) create mode 100644 x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_persistent_controls.ts create mode 100644 x-pack/plugins/observability_solution/observability/public/components/alerts_table/observability/get_alerts_page_table_configuration.tsx create mode 100644 x-pack/plugins/observability_solution/observability/public/components/alerts_table/types.ts create mode 100644 x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/constants.ts create mode 100644 x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_aggregations_by_grouping_field.ts create mode 100644 x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_group_stats.tsx create mode 100644 x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/render_group_panel.tsx create mode 100644 x-pack/plugins/observability_solution/observability/public/pages/alerts/helpers/merge_bool_queries.ts diff --git a/packages/kbn-alerts-grouping/src/components/alerts_grouping.test.tsx b/packages/kbn-alerts-grouping/src/components/alerts_grouping.test.tsx index 87517def778cd..47e2d5c1b4082 100644 --- a/packages/kbn-alerts-grouping/src/components/alerts_grouping.test.tsx +++ b/packages/kbn-alerts-grouping/src/components/alerts_grouping.test.tsx @@ -158,7 +158,7 @@ describe('AlertsGrouping', () => { }, { range: { - '@timestamp': { + 'kibana.alert.time_range': { gte: mockDate.from, lte: mockDate.to, }, diff --git a/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx b/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx index 5db1ef5a5d0ff..17a4d35f73e8a 100644 --- a/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx +++ b/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx @@ -199,7 +199,7 @@ const AlertsGroupingInternal = ( }; return ( - {...props} getGrouping={getGrouping} groupingLevel={level} diff --git a/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx b/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx index a82818215cbf4..c0ebf0e6fa234 100644 --- a/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx +++ b/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx @@ -14,6 +14,7 @@ import { type GroupingAggregation } from '@kbn/grouping'; import { isNoneGroup } from '@kbn/grouping'; import type { DynamicGroupingProps } from '@kbn/grouping/src'; import { parseGroupingQuery } from '@kbn/grouping/src'; +import { ALERT_TIME_RANGE } from '@kbn/rule-data-utils'; import { useGetAlertsGroupAggregationsQuery, UseGetAlertsGroupAggregationsQueryProps, @@ -94,7 +95,7 @@ export const AlertsGroupingLevel = typedMemo( ...filters, { range: { - '@timestamp': { + [ALERT_TIME_RANGE]: { gte: from, lte: to, }, diff --git a/packages/kbn-alerts-grouping/src/contexts/alerts_grouping_context.tsx b/packages/kbn-alerts-grouping/src/contexts/alerts_grouping_context.tsx index cc5e06e652cd4..2d1315e3ece6d 100644 --- a/packages/kbn-alerts-grouping/src/contexts/alerts_grouping_context.tsx +++ b/packages/kbn-alerts-grouping/src/contexts/alerts_grouping_context.tsx @@ -54,7 +54,6 @@ export const useAlertsGroupingState = (groupingId: string) => { setGroupingState((prevState) => ({ ...prevState, [groupingId]: { - // @ts-expect-error options might not be defined options: [], // @ts-expect-error activeGroups might not be defined activeGroups: initialActiveGroups, diff --git a/packages/kbn-alerts-grouping/src/types.ts b/packages/kbn-alerts-grouping/src/types.ts index 835941e8db95d..24239364bb6c2 100644 --- a/packages/kbn-alerts-grouping/src/types.ts +++ b/packages/kbn-alerts-grouping/src/types.ts @@ -22,7 +22,7 @@ import { ReactElement } from 'react'; export interface GroupModel { activeGroups: string[]; - options: Array<{ key: string; label: string }>; + options?: Array<{ key: string; label: string }>; } export interface AlertsGroupingState { diff --git a/x-pack/plugins/observability_solution/observability/common/typings.ts b/x-pack/plugins/observability_solution/observability/common/typings.ts index bfdcd6d5209dc..03981f5941dc2 100644 --- a/x-pack/plugins/observability_solution/observability/common/typings.ts +++ b/x-pack/plugins/observability_solution/observability/common/typings.ts @@ -11,6 +11,7 @@ import { ALERT_STATUS_RECOVERED, ALERT_STATUS_UNTRACKED, } from '@kbn/rule-data-utils'; +import { Filter } from '@kbn/es-query'; import { ALERT_STATUS_ALL } from './constants'; export type Maybe = T | null | undefined; @@ -39,6 +40,7 @@ export type AlertStatus = export interface AlertStatusFilter { status: AlertStatus; query: string; + filter: Filter[]; label: string; } diff --git a/x-pack/plugins/observability_solution/observability/public/components/alert_search_bar/constants.ts b/x-pack/plugins/observability_solution/observability/public/components/alert_search_bar/constants.ts index 85ea6464d5ac0..dc6af6316c41c 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/alert_search_bar/constants.ts +++ b/x-pack/plugins/observability_solution/observability/public/components/alert_search_bar/constants.ts @@ -22,6 +22,7 @@ export const DEFAULT_QUERY_STRING = ''; export const ALL_ALERTS: AlertStatusFilter = { status: ALERT_STATUS_ALL, query: '', + filter: [], label: i18n.translate('xpack.observability.alerts.alertStatusFilter.showAll', { defaultMessage: 'Show all', }), @@ -30,6 +31,16 @@ export const ALL_ALERTS: AlertStatusFilter = { export const ACTIVE_ALERTS: AlertStatusFilter = { status: ALERT_STATUS_ACTIVE, query: `${ALERT_STATUS}: "${ALERT_STATUS_ACTIVE}"`, + filter: [ + { + query: { + match_phrase: { + [ALERT_STATUS]: ALERT_STATUS_ACTIVE, + }, + }, + meta: {}, + }, + ], label: i18n.translate('xpack.observability.alerts.alertStatusFilter.active', { defaultMessage: 'Active', }), @@ -38,6 +49,16 @@ export const ACTIVE_ALERTS: AlertStatusFilter = { export const RECOVERED_ALERTS: AlertStatusFilter = { status: ALERT_STATUS_RECOVERED, query: `${ALERT_STATUS}: "${ALERT_STATUS_RECOVERED}"`, + filter: [ + { + query: { + match_phrase: { + [ALERT_STATUS]: ALERT_STATUS_RECOVERED, + }, + }, + meta: {}, + }, + ], label: i18n.translate('xpack.observability.alerts.alertStatusFilter.recovered', { defaultMessage: 'Recovered', }), @@ -46,6 +67,16 @@ export const RECOVERED_ALERTS: AlertStatusFilter = { export const UNTRACKED_ALERTS: AlertStatusFilter = { status: ALERT_STATUS_UNTRACKED, query: `${ALERT_STATUS}: "${ALERT_STATUS_UNTRACKED}"`, + filter: [ + { + query: { + match_phrase: { + [ALERT_STATUS]: ALERT_STATUS_UNTRACKED, + }, + }, + meta: {}, + }, + ], label: i18n.translate('xpack.observability.alerts.alertStatusFilter.untracked', { defaultMessage: 'Untracked', }), @@ -56,3 +87,9 @@ export const ALERT_STATUS_QUERY = { [RECOVERED_ALERTS.status]: RECOVERED_ALERTS.query, [UNTRACKED_ALERTS.status]: UNTRACKED_ALERTS.query, }; + +export const ALERT_STATUS_FILTER = { + [ACTIVE_ALERTS.status]: ACTIVE_ALERTS.filter, + [RECOVERED_ALERTS.status]: RECOVERED_ALERTS.filter, + [UNTRACKED_ALERTS.status]: UNTRACKED_ALERTS.filter, +}; diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx index cabf1d6d6f34e..30c912b510743 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx @@ -12,17 +12,29 @@ import { AlertsTableConfigurationRegistry, RenderCustomActionsRowArgs, } from '@kbn/triggers-actions-ui-plugin/public/types'; -import { casesFeatureId, observabilityFeatureId } from '../../../../common'; +import { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { NotificationsStart } from '@kbn/core-notifications-browser'; +import { + casesFeatureId, + observabilityAlertFeatureIds, + observabilityFeatureId, +} from '../../../../common'; import { AlertActions } from '../../../pages/alerts/components/alert_actions'; import { useGetAlertFlyoutComponents } from '../../alerts_flyout/use_get_alert_flyout_components'; import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; +import { ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID } from '../../../constants'; import type { ConfigSchema } from '../../../plugin'; import { getRenderCellValue } from '../common/render_cell_value'; import { getColumns } from '../common/get_columns'; +import { getPersistentControlsHook } from './get_persistent_controls'; export const getAlertsPageTableConfiguration = ( observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry, - config: ConfigSchema + config: ConfigSchema, + dataViews: DataViewsServicePublic, + http: HttpSetup, + notifications: NotificationsStart ): AlertsTableConfigurationRegistry => { const renderCustomActionsRow = (props: RenderCustomActionsRowArgs) => { return ( @@ -34,7 +46,7 @@ export const getAlertsPageTableConfiguration = ( ); }; return { - id: observabilityFeatureId, + id: ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, cases: { featureId: casesFeatureId, owner: [observabilityFeatureId] }, columns: getColumns({ showRuleName: true }), getRenderCellValue, @@ -53,6 +65,15 @@ export const getAlertsPageTableConfiguration = ( return { header, body, footer }; }, ruleTypeIds: observabilityRuleTypeRegistry.list(), + usePersistentControls: getPersistentControlsHook({ + groupingId: ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, + featureIds: observabilityAlertFeatureIds, + services: { + dataViews, + http, + notifications, + }, + }), showInspectButton: true, }; }; diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_persistent_controls.ts b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_persistent_controls.ts new file mode 100644 index 0000000000000..2141e0fb68d66 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_persistent_controls.ts @@ -0,0 +1,66 @@ +/* + * 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 { useMemo, useCallback } from 'react'; +import { type AlertsGroupingProps, useAlertsGroupingState } from '@kbn/alerts-grouping'; +import { useAlertsDataView } from '@kbn/alerts-ui-shared/src/common/hooks/use_alerts_data_view'; +import { useGetGroupSelectorStateless } from '@kbn/grouping/src/hooks/use_get_group_selector'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import { AlertsByGroupingAgg } from '../types'; + +interface GetPersistentControlsParams { + groupingId: string; + featureIds: AlertConsumers[]; + maxGroupingLevels?: number; + services: Pick< + AlertsGroupingProps['services'], + 'dataViews' | 'http' | 'notifications' + >; +} + +export const getPersistentControlsHook = + ({ + groupingId, + featureIds, + maxGroupingLevels = 3, + services: { dataViews, http, notifications }, + }: GetPersistentControlsParams) => + () => { + const { grouping, updateGrouping } = useAlertsGroupingState(groupingId); + + const onGroupChange = useCallback( + (selectedGroups: string[]) => { + updateGrouping({ + activeGroups: + grouping.activeGroups?.filter((g) => g !== 'none').concat(selectedGroups) ?? [], + }); + }, + [grouping, updateGrouping] + ); + + const { dataView } = useAlertsDataView({ + featureIds, + dataViewsService: dataViews, + http, + toasts: notifications.toasts, + }); + + const groupSelector = useGetGroupSelectorStateless({ + groupingId, + onGroupChange, + fields: dataView?.fields ?? [], + defaultGroupingOptions: + grouping.options?.filter((option) => !grouping.activeGroups.includes(option.key)) ?? [], + maxGroupingLevels, + }); + + return useMemo(() => { + return { + right: groupSelector, + }; + }, [groupSelector]); + }; diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/observability/get_alerts_page_table_configuration.tsx b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/observability/get_alerts_page_table_configuration.tsx new file mode 100644 index 0000000000000..9d761aa87f4cd --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/observability/get_alerts_page_table_configuration.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 from 'react'; +import { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ALERT_START, AlertConsumers } from '@kbn/rule-data-utils'; +import { + AlertsTableConfigurationRegistry, + RenderCustomActionsRowArgs, +} from '@kbn/triggers-actions-ui-plugin/public/types'; +import { casesFeatureId, observabilityFeatureId } from '../../../../common'; +import { AlertActions } from '../../../pages/alerts/components/alert_actions'; +import { useGetAlertFlyoutComponents } from '../../alerts_flyout/use_get_alert_flyout_components'; +import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; +import type { ConfigSchema } from '../../../plugin'; +import { getRenderCellValue } from '../common/render_cell_value'; +import { getColumns } from '../common/get_columns'; + +export const getObservabilityTableConfiguration = ( + observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry, + config: ConfigSchema +): AlertsTableConfigurationRegistry => { + const renderCustomActionsRow = (props: RenderCustomActionsRowArgs) => { + return ( + + ); + }; + return { + id: AlertConsumers.OBSERVABILITY, + cases: { featureId: casesFeatureId, owner: [observabilityFeatureId] }, + columns: getColumns({ showRuleName: true }), + getRenderCellValue, + sort: [ + { + [ALERT_START]: { + order: 'desc' as SortOrder, + }, + }, + ], + useActionsColumn: () => ({ + renderCustomActionsRow, + }), + useInternalFlyout: () => { + const { header, body, footer } = useGetAlertFlyoutComponents(observabilityRuleTypeRegistry); + return { header, body, footer }; + }, + ruleTypeIds: observabilityRuleTypeRegistry.list(), + showInspectButton: true, + }; +}; diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx index 1fa574d1dd402..de687c4dd7944 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx @@ -6,22 +6,39 @@ */ import { AlertTableConfigRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/alert_table_config_registry'; +import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { NotificationsStart } from '@kbn/core-notifications-browser'; import type { ConfigSchema } from '../../plugin'; import { ObservabilityRuleTypeRegistry } from '../..'; import { getAlertsPageTableConfiguration } from './alerts/get_alerts_page_table_configuration'; import { getRuleDetailsTableConfiguration } from './rule_details/get_rule_details_table_configuration'; import { getSloAlertsTableConfiguration } from './slo/get_slo_alerts_table_configuration'; +import { getObservabilityTableConfiguration } from './observability/get_alerts_page_table_configuration'; export const registerAlertsTableConfiguration = ( alertTableConfigRegistry: AlertTableConfigRegistry, observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry, - config: ConfigSchema + config: ConfigSchema, + dataViews: DataViewsServicePublic, + http: HttpSetup, + notifications: NotificationsStart ) => { - // Alert page - const alertsPageAlertsTableConfig = getAlertsPageTableConfiguration( + // Observability table + const observabilityAlertsTableConfig = getObservabilityTableConfiguration( observabilityRuleTypeRegistry, config ); + alertTableConfigRegistry.register(observabilityAlertsTableConfig); + + // Alerts page + const alertsPageAlertsTableConfig = getAlertsPageTableConfiguration( + observabilityRuleTypeRegistry, + config, + dataViews, + http, + notifications + ); alertTableConfigRegistry.register(alertsPageAlertsTableConfig); // Rule details page diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/types.ts b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/types.ts new file mode 100644 index 0000000000000..477117999a8ca --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface BucketItem { + key: string; + doc_count: number; +} + +export interface AlertsByGroupingAgg extends Record { + groupByFields: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; + }; + ruleTags: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; + }; + rulesCountAggregation?: { + value: number; + }; + sourceCountAggregation?: { + value: number; + }; + groupsCount: { + value: number; + }; + unitsCount: { + value: number; + }; +} diff --git a/x-pack/plugins/observability_solution/observability/public/components/tags.tsx b/x-pack/plugins/observability_solution/observability/public/components/tags.tsx index e7059463ef7bd..015e911c535be 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/tags.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/tags.tsx @@ -10,38 +10,53 @@ import React, { useState } from 'react'; import { EuiBadge, EuiPopover } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -export function Tags({ tags }: { tags: string[] }) { +export function Tags({ + tags, + color, + size = 3, + oneLine = false, +}: { + tags: string[]; + color?: string; + size?: number; + oneLine?: boolean; +}) { const [isMoreTagsOpen, setIsMoreTagsOpen] = useState(false); - const onMoreTagsClick = () => setIsMoreTagsOpen((isPopoverOpen) => !isPopoverOpen); + const onMoreTagsClick = (e: any) => { + e.stopPropagation(); + setIsMoreTagsOpen((isPopoverOpen) => !isPopoverOpen); + }; const closePopover = () => setIsMoreTagsOpen(false); - const moreTags = tags.length > 3 && ( + const moreTags = tags.length > size && ( ); return ( <> - {tags.slice(0, 3).map((tag) => ( - {tag} + {tags.slice(0, size).map((tag) => ( + + {tag} + ))} -
+ {oneLine ? ' ' :
} - {tags.slice(3).map((tag) => ( - {tag} + {tags.slice(size).map((tag) => ( + + {tag} + ))} diff --git a/x-pack/plugins/observability_solution/observability/public/constants.ts b/x-pack/plugins/observability_solution/observability/public/constants.ts index 2da72ff858283..768094ec8a66b 100644 --- a/x-pack/plugins/observability_solution/observability/public/constants.ts +++ b/x-pack/plugins/observability_solution/observability/public/constants.ts @@ -8,4 +8,5 @@ export const DEFAULT_INTERVAL = '60s'; export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; +export const ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID = `alerts-page-alerts-table`; export const RULE_DETAILS_ALERTS_TABLE_CONFIG_ID = `rule-details-alerts-table`; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.tsx index 0d3933b6204f4..c1d14165f5f6e 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.tsx @@ -11,20 +11,22 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BoolQuery } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; -import { AlertConsumers } from '@kbn/rule-data-utils'; import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; import { MaintenanceWindowCallout } from '@kbn/alerts-ui-shared'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { AlertsGrouping } from '@kbn/alerts-grouping'; +import { renderGroupPanel } from './grouping/render_group_panel'; import { rulesLocatorID } from '../../../common'; -import { RulesParams } from '../../locators/rules'; -import { useKibana } from '../../utils/kibana_react'; +import { ALERT_STATUS_FILTER } from '../../components/alert_search_bar/constants'; +import { AlertsByGroupingAgg } from '../../components/alerts_table/types'; +import { ObservabilityAlertSearchBar } from '../../components/alert_search_bar/alert_search_bar'; +import { useGetFilteredRuleTypes } from '../../hooks/use_get_filtered_rule_types'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useTimeBuckets } from '../../hooks/use_time_buckets'; -import { useGetFilteredRuleTypes } from '../../hooks/use_get_filtered_rule_types'; import { useToasts } from '../../hooks/use_toast'; -import { renderRuleStats, RuleStatsState } from './components/rule_stats'; -import { ObservabilityAlertSearchBar } from '../../components/alert_search_bar/alert_search_bar'; +import { RulesParams } from '../../locators/rules'; +import { useKibana } from '../../utils/kibana_react'; import { alertSearchBarStateContainer, Provider, @@ -34,8 +36,15 @@ import { calculateTimeRangeBucketSize } from '../overview/helpers/calculate_buck import { getAlertSummaryTimeRange } from '../../utils/alert_summary_widget'; import { observabilityAlertFeatureIds } from '../../../common/constants'; import { ALERTS_URL_STORAGE_KEY } from '../../../common/constants'; +import { ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID } from '../../constants'; import { HeaderMenu } from '../overview/components/header_menu/header_menu'; import { useGetAvailableRulesWithDescriptions } from '../../hooks/use_get_available_rules_with_descriptions'; +import { buildEsQuery } from '../../utils/build_es_query'; +import { renderRuleStats, RuleStatsState } from './components/rule_stats'; +import { getGroupStats } from './grouping/get_group_stats'; +import { getAggregationsByGroupingField } from './grouping/get_aggregations_by_grouping_field'; +import { DEFAULT_GROUPING_OPTIONS } from './grouping/constants'; +import { mergeBoolQueries } from './helpers/merge_bool_queries'; const ALERTS_SEARCH_BAR_ID = 'alerts-search-bar-o11y'; const ALERTS_PER_PAGE = 50; @@ -48,13 +57,10 @@ function InternalAlertsPage() { const kibanaServices = useKibana().services; const { charts, - data: { - query: { - timefilter: { timefilter: timeFilterService }, - }, - }, + data, http, - notifications: { toasts }, + notifications, + dataViews, observabilityAIAssistant, share: { url: { locators }, @@ -67,6 +73,12 @@ function InternalAlertsPage() { }, uiSettings, } = kibanaServices; + const { toasts } = notifications; + const { + query: { + timefilter: { timefilter: timeFilterService }, + }, + } = data; const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext(); const alertSearchBarStateProps = useAlertSearchBarStateContainer(ALERTS_URL_STORAGE_KEY, { replace: false, @@ -241,16 +253,42 @@ function InternalAlertsPage() {
{esQuery && ( - featureIds={observabilityAlertFeatureIds} - query={esQuery} - showAlertStatusWithFlapping - initialPageSize={ALERTS_PER_PAGE} - cellContext={{ observabilityRuleTypeRegistry }} - /> + defaultFilters={ALERT_STATUS_FILTER[alertSearchBarStateProps.status] ?? []} + from={alertSearchBarStateProps.rangeFrom} + to={alertSearchBarStateProps.rangeTo} + globalFilters={alertSearchBarStateProps.filters} + globalQuery={{ query: alertSearchBarStateProps.kuery, language: 'kuery' }} + groupingId={ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID} + defaultGroupingOptions={DEFAULT_GROUPING_OPTIONS} + getAggregationsByGroupingField={getAggregationsByGroupingField} + renderGroupPanel={renderGroupPanel} + getGroupStats={getGroupStats} + services={{ + notifications, + dataViews, + http, + }} + > + {(groupingFilters) => { + const groupQuery = buildEsQuery({ + filters: groupingFilters, + }); + return ( + + ); + }} + )}
diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/constants.ts b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/constants.ts new file mode 100644 index 0000000000000..6bfe2f0febdd5 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/constants.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ALERT_RULE_NAME, ALERT_INSTANCE_ID } from '@kbn/rule-data-utils'; + +export const ungrouped = i18n.translate('xpack.observability.alert.grouping.ungrouped.label', { + defaultMessage: 'Ungrouped', +}); + +export const ruleName = i18n.translate('xpack.observability.alert.grouping.ruleName.label', { + defaultMessage: 'Rule name', +}); + +export const source = i18n.translate('xpack.observability.alert.grouping.source.label', { + defaultMessage: 'Source', +}); + +export const DEFAULT_GROUPING_OPTIONS = [ + { + label: ruleName, + key: ALERT_RULE_NAME, + }, + { + label: source, + key: ALERT_INSTANCE_ID, + }, +]; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_aggregations_by_grouping_field.ts b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_aggregations_by_grouping_field.ts new file mode 100644 index 0000000000000..e4c8b27225ea5 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_aggregations_by_grouping_field.ts @@ -0,0 +1,53 @@ +/* + * 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 { NamedAggregation } from '@kbn/grouping'; +import { ALERT_INSTANCE_ID, ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; + +export const getAggregationsByGroupingField = (field: string): NamedAggregation[] => { + switch (field) { + case ALERT_RULE_NAME: + return [ + { + sourceCountAggregation: { + cardinality: { + field: ALERT_INSTANCE_ID, + }, + }, + }, + { + ruleTags: { + terms: { + field: 'tags', + }, + }, + }, + ]; + break; + case ALERT_INSTANCE_ID: + return [ + { + rulesCountAggregation: { + cardinality: { + field: ALERT_RULE_UUID, + }, + }, + }, + ]; + break; + default: + return [ + { + rulesCountAggregation: { + cardinality: { + field: ALERT_RULE_UUID, + }, + }, + }, + ]; + } +}; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_group_stats.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_group_stats.tsx new file mode 100644 index 0000000000000..3fe0a6d006825 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/get_group_stats.tsx @@ -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 { GetGroupStats } from '@kbn/grouping/src'; +import { ALERT_INSTANCE_ID, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { AlertsByGroupingAgg } from '../../../components/alerts_table/types'; + +export const getGroupStats: GetGroupStats = (selectedGroup, bucket) => { + const defaultBadges = [ + { + title: 'Alerts:', + badge: { + value: bucket.doc_count, + width: 50, + }, + }, + ]; + + switch (selectedGroup) { + case ALERT_RULE_NAME: + return [ + { + title: 'Sources:', + badge: { + value: bucket.sourceCountAggregation?.value ?? 0, + width: 50, + }, + }, + ...defaultBadges, + ]; + case ALERT_INSTANCE_ID: + return [ + { + title: 'Rules:', + badge: { + value: bucket.rulesCountAggregation?.value ?? 0, + width: 50, + }, + }, + ...defaultBadges, + ]; + } + return [ + { + title: 'Rules:', + badge: { + value: bucket.rulesCountAggregation?.value ?? 0, + width: 50, + }, + }, + ...defaultBadges, + ]; +}; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/render_group_panel.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/render_group_panel.tsx new file mode 100644 index 0000000000000..17e674eb0a44e --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/grouping/render_group_panel.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { isArray } from 'lodash/fp'; +import { EuiFlexGroup, EuiIconTip, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { firstNonNullValue, GroupPanelRenderer } from '@kbn/grouping/src'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { AlertsByGroupingAgg } from '../../../components/alerts_table/types'; +import { Tags } from '../../../components/tags'; +import { ungrouped } from './constants'; + +export const renderGroupPanel: GroupPanelRenderer = ( + selectedGroup, + bucket +) => { + switch (selectedGroup) { + case 'kibana.alert.rule.name': + return isArray(bucket.key) ? ( + tag.key)} + /> + ) : undefined; + case 'kibana.alert.instance.id': + return ; + } +}; + +const RuleNameGroupContent = React.memo<{ + ruleName: string; + tags?: string[] | undefined; +}>(({ ruleName, tags }) => { + return ( +
+ + + +
{ruleName}
+
+
+
+ + {!!tags && tags.length > 0 && ( + + + + )} +
+ ); +}); +RuleNameGroupContent.displayName = 'RuleNameGroup'; + +const InstanceIdGroupContent = React.memo<{ + instanceId?: string; +}>(({ instanceId }) => { + const isUngrouped = instanceId === '*'; + return ( +
+ + + +
+ {isUngrouped ? ungrouped : instanceId ?? '--'} +   + {isUngrouped && ( + + } + /> + )} +
+
+
+
+
+ ); +}); +InstanceIdGroupContent.displayName = 'InstanceIdGroupContent'; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/helpers/merge_bool_queries.ts b/x-pack/plugins/observability_solution/observability/public/pages/alerts/helpers/merge_bool_queries.ts new file mode 100644 index 0000000000000..bd748f4e5b928 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/helpers/merge_bool_queries.ts @@ -0,0 +1,25 @@ +/* + * 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 { BoolQuery } from '@kbn/es-query'; + +export const mergeBoolQueries = ( + firstQuery: { bool: BoolQuery }, + secondQuery: { bool: BoolQuery } +): { bool: BoolQuery } => { + const first = firstQuery.bool; + const second = secondQuery.bool; + + return { + bool: { + must: [...first.must, ...second.must], + must_not: [...first.must_not, ...second.must_not], + filter: [...first.filter, ...second.filter], + should: [...first.should, ...second.should], + }, + }; +}; diff --git a/x-pack/plugins/observability_solution/observability/public/plugin.ts b/x-pack/plugins/observability_solution/observability/public/plugin.ts index b2d0e526f3c64..43967f1339c5c 100644 --- a/x-pack/plugins/observability_solution/observability/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability/public/plugin.ts @@ -445,14 +445,18 @@ export class Plugin } public start(coreStart: CoreStart, pluginsStart: ObservabilityPublicPluginsStart) { - const { application } = coreStart; + const { application, http, notifications } = coreStart; + const { dataViews, triggersActionsUi } = pluginsStart; const config = this.initContext.config.get(); - const { alertsTableConfigurationRegistry } = pluginsStart.triggersActionsUi; + const { alertsTableConfigurationRegistry } = triggersActionsUi; this.lazyRegisterAlertsTableConfiguration().then(({ registerAlertsTableConfiguration }) => { return registerAlertsTableConfiguration( alertsTableConfigurationRegistry, this.observabilityRuleTypeRegistry, - config + config, + dataViews, + http, + notifications ); }); diff --git a/x-pack/plugins/observability_solution/observability/tsconfig.json b/x-pack/plugins/observability_solution/observability/tsconfig.json index 0a65077d42a1e..72609a3ada8bd 100644 --- a/x-pack/plugins/observability_solution/observability/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability/tsconfig.json @@ -112,6 +112,9 @@ "@kbn/core-ui-settings-server-mocks", "@kbn/investigate-plugin", "@kbn/investigation-shared", + "@kbn/grouping", + "@kbn/alerts-grouping", + "@kbn/core-http-browser" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts index 76a39737190ce..8d61b2b4e5609 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -1133,7 +1133,7 @@ export class AlertsClient { script: { source: // When size()==0, emits a uniqueValue as the value to represent this group else join by uniqueValue. - "if (doc[params['selectedGroup']].size()==0) { emit(params['uniqueValue']) }" + + "if (!doc.containsKey(params['selectedGroup']) || doc[params['selectedGroup']].size()==0) { emit(params['uniqueValue']) }" + // Else, join the values with uniqueValue. We cannot simply emit the value like doc[params['selectedGroup']].value, // the runtime field will only return the first value in an array. // The docs advise that if the field has multiple values, "Scripts can call the emit method multiple times to emit multiple values." diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts index af10edf372383..c351de1283c2b 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts @@ -127,7 +127,7 @@ describe('getGroupAggregations()', () => { type: 'keyword', script: { source: - "if (doc[params['selectedGroup']].size()==0) { emit(params['uniqueValue']) }" + + "if (!doc.containsKey(params['selectedGroup']) || doc[params['selectedGroup']].size()==0) { emit(params['uniqueValue']) }" + " else { emit(doc[params['selectedGroup']].join(params['uniqueValue']))}", params: { selectedGroup: groupByField, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a0443e37fa617..1ed13e47986fb 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -32363,8 +32363,6 @@ "xpack.observability.alertDetailContextualInsights.InsightButtonLabel": "Aidez moi à comprendre cette alerte", "xpack.observability.alertDetails.actionsButtonLabel": "Actions", "xpack.observability.alertDetails.addToCase": "Ajouter au cas", - "xpack.observability.alertDetails.alertSummaryField.moreTags": "+{number} de plus", - "xpack.observability.alertDetails.alertSummaryField.moreTags.ariaLabel": "badge plus de balises", "xpack.observability.alertDetails.alertSummaryField.rule": "Règle", "xpack.observability.alertDetails.alertSummaryField.source": "Source", "xpack.observability.alertDetails.alertSummaryField.tags": "Balises", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e662987308271..b4224908c64a5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -32347,8 +32347,6 @@ "xpack.observability.alertDetailContextualInsights.InsightButtonLabel": "このアラートを理解できるように支援してください", "xpack.observability.alertDetails.actionsButtonLabel": "アクション", "xpack.observability.alertDetails.addToCase": "ケースに追加", - "xpack.observability.alertDetails.alertSummaryField.moreTags": "その他{number}", - "xpack.observability.alertDetails.alertSummaryField.moreTags.ariaLabel": "その他のタグバッジ", "xpack.observability.alertDetails.alertSummaryField.rule": "ルール", "xpack.observability.alertDetails.alertSummaryField.source": "送信元", "xpack.observability.alertDetails.alertSummaryField.tags": "タグ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ef6badee07f44..78ff3faf194f1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -32387,8 +32387,6 @@ "xpack.observability.alertDetailContextualInsights.InsightButtonLabel": "帮助我了解此告警", "xpack.observability.alertDetails.actionsButtonLabel": "操作", "xpack.observability.alertDetails.addToCase": "添加到案例", - "xpack.observability.alertDetails.alertSummaryField.moreTags": "+ 另外 {number} 个", - "xpack.observability.alertDetails.alertSummaryField.moreTags.ariaLabel": "更多标签徽章", "xpack.observability.alertDetails.alertSummaryField.rule": "规则", "xpack.observability.alertDetails.alertSummaryField.source": "源", "xpack.observability.alertDetails.alertSummaryField.tags": "标签", From cdcdfddd3feafe0165066c6d1f4865a84d0cdcf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 22 Aug 2024 11:24:10 +0100 Subject: [PATCH 29/45] [APM][ECO] enabling FF by default (#191056) I missed changing these two files on my previous [PR](https://github.com/elastic/kibana/pull/190422). --- .../public/components/routing/templates/apm_main_template.tsx | 2 +- .../context/entity_manager_context/entity_manager_context.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_main_template.tsx index 257406ce1a8fc..dd18607c48011 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_main_template.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_main_template.tsx @@ -77,7 +77,7 @@ export function ApmMainTemplate({ const { config, core } = useApmPluginContext(); const isEntityCentricExperienceSettingEnabled = core.uiSettings.get( entityCentricExperience, - false + true ); const { isEntityCentricExperienceViewEnabled, serviceInventoryViewLocalStorageSetting } = useEntityManagerEnablementContext(); diff --git a/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx b/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx index e8c297cb13a92..c2c3d6c1a57be 100644 --- a/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx +++ b/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx @@ -61,7 +61,7 @@ export function EntityManagerEnablementContextProvider({ const isEntityCentricExperienceSettingEnabled = core.uiSettings.get( entityCentricExperience, - false + true ); const isEntityCentricExperienceViewEnabled = From a524e27bed873137a296991a04830a49c4ff188e Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Thu, 22 Aug 2024 07:27:33 -0400 Subject: [PATCH 30/45] [Search] Search assistant plugin setup (#190633) ## Summary Introducing new plugin for search assistant. in the future this will be extension of Observability AI solution solution [plugin](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/observability_ai_assistant) ### Checklist Delete any items that are not applicable to this PR. - [ ] 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) --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Sander Philipse --- .github/CODEOWNERS | 1 + docs/developer/plugin-list.asciidoc | 4 ++ package.json | 1 + packages/kbn-optimizer/limits.yml | 1 + tsconfig.base.json | 2 + x-pack/.i18nrc.json | 1 + x-pack/plugins/search_assistant/README.md | 3 ++ .../plugins/search_assistant/common/index.ts | 8 ++++ x-pack/plugins/search_assistant/kibana.jsonc | 24 ++++++++++++ .../search_assistant/public/application.tsx | 37 +++++++++++++++++++ .../public/components/app.tsx | 15 ++++++++ .../public/components/search_assistant.tsx | 24 ++++++++++++ .../plugins/search_assistant/public/index.ts | 13 +++++++ .../plugins/search_assistant/public/plugin.ts | 29 +++++++++++++++ .../search_assistant/public/router.tsx | 20 ++++++++++ .../plugins/search_assistant/public/types.ts | 22 +++++++++++ .../plugins/search_assistant/server/config.ts | 19 ++++++++++ .../plugins/search_assistant/server/index.ts | 15 ++++++++ .../plugins/search_assistant/server/plugin.ts | 26 +++++++++++++ .../plugins/search_assistant/server/types.ts | 11 ++++++ x-pack/plugins/search_assistant/tsconfig.json | 28 ++++++++++++++ yarn.lock | 4 ++ 22 files changed, 308 insertions(+) create mode 100755 x-pack/plugins/search_assistant/README.md create mode 100644 x-pack/plugins/search_assistant/common/index.ts create mode 100644 x-pack/plugins/search_assistant/kibana.jsonc create mode 100644 x-pack/plugins/search_assistant/public/application.tsx create mode 100644 x-pack/plugins/search_assistant/public/components/app.tsx create mode 100644 x-pack/plugins/search_assistant/public/components/search_assistant.tsx create mode 100644 x-pack/plugins/search_assistant/public/index.ts create mode 100644 x-pack/plugins/search_assistant/public/plugin.ts create mode 100644 x-pack/plugins/search_assistant/public/router.tsx create mode 100644 x-pack/plugins/search_assistant/public/types.ts create mode 100644 x-pack/plugins/search_assistant/server/config.ts create mode 100644 x-pack/plugins/search_assistant/server/index.ts create mode 100644 x-pack/plugins/search_assistant/server/plugin.ts create mode 100644 x-pack/plugins/search_assistant/server/types.ts create mode 100644 x-pack/plugins/search_assistant/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ba352582bd651..bc5dc9e82c8c2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -733,6 +733,7 @@ src/plugins/screenshot_mode @elastic/appex-sharedux x-pack/examples/screenshotting_example @elastic/appex-sharedux x-pack/plugins/screenshotting @elastic/kibana-reporting-services packages/kbn-search-api-panels @elastic/search-kibana +x-pack/plugins/search_assistant @elastic/search-kibana packages/kbn-search-connectors @elastic/search-kibana x-pack/plugins/search_connectors @elastic/search-kibana packages/kbn-search-errors @elastic/kibana-data-discovery diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 49b5755243076..fc89188c4d219 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -795,6 +795,10 @@ Elastic. It uses Chromium and Puppeteer underneath to run the browser in headless mode. +|{kib-repo}blob/{branch}/x-pack/plugins/search_assistant/README.md[searchAssistant] +|This holds the Search AI Assistant which targets Search users and Serverless Elasticsearch. + + |{kib-repo}blob/{branch}/x-pack/plugins/search_connectors/README.mdx[searchConnectors] |This plugin contains common assets and endpoints for the use of connectors in Kibana. Primarily used by the enterprise_search and serverless_search plugins. diff --git a/package.json b/package.json index 0b93cb96d4e47..0edb050fe9aea 100644 --- a/package.json +++ b/package.json @@ -751,6 +751,7 @@ "@kbn/screenshotting-example-plugin": "link:x-pack/examples/screenshotting_example", "@kbn/screenshotting-plugin": "link:x-pack/plugins/screenshotting", "@kbn/search-api-panels": "link:packages/kbn-search-api-panels", + "@kbn/search-assistant": "link:x-pack/plugins/search_assistant", "@kbn/search-connectors": "link:packages/kbn-search-connectors", "@kbn/search-connectors-plugin": "link:x-pack/plugins/search_connectors", "@kbn/search-errors": "link:packages/kbn-search-errors", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a33156e376972..d6aa58cb5d307 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -136,6 +136,7 @@ pageLoadAssetSize: savedSearch: 16225 screenshotMode: 17856 screenshotting: 22870 + searchAssistant: 19831 searchConnectors: 30000 searchHomepage: 19831 searchInferenceEndpoints: 20470 diff --git a/tsconfig.base.json b/tsconfig.base.json index fb98e0160cb76..f433970fae490 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1460,6 +1460,8 @@ "@kbn/screenshotting-plugin/*": ["x-pack/plugins/screenshotting/*"], "@kbn/search-api-panels": ["packages/kbn-search-api-panels"], "@kbn/search-api-panels/*": ["packages/kbn-search-api-panels/*"], + "@kbn/search-assistant": ["x-pack/plugins/search_assistant"], + "@kbn/search-assistant/*": ["x-pack/plugins/search_assistant/*"], "@kbn/search-connectors": ["packages/kbn-search-connectors"], "@kbn/search-connectors/*": ["packages/kbn-search-connectors/*"], "@kbn/search-connectors-plugin": ["x-pack/plugins/search_connectors"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 97a7a92c93b5a..c90ce916b712b 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -105,6 +105,7 @@ "xpack.searchNotebooks": "plugins/search_notebooks", "xpack.searchPlayground": "plugins/search_playground", "xpack.searchInferenceEndpoints": "plugins/search_inference_endpoints", + "xpack.searchAssistant": "plugins/search_assistant", "xpack.searchProfiler": "plugins/searchprofiler", "xpack.security": ["plugins/security", "packages/security"], "xpack.server": "legacy/server", diff --git a/x-pack/plugins/search_assistant/README.md b/x-pack/plugins/search_assistant/README.md new file mode 100755 index 0000000000000..faf956ad6ec48 --- /dev/null +++ b/x-pack/plugins/search_assistant/README.md @@ -0,0 +1,3 @@ +# SearchAssistant + +This holds the Search AI Assistant which targets Search users and Serverless Elasticsearch. diff --git a/x-pack/plugins/search_assistant/common/index.ts b/x-pack/plugins/search_assistant/common/index.ts new file mode 100644 index 0000000000000..27bbca34ce7d1 --- /dev/null +++ b/x-pack/plugins/search_assistant/common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const PLUGIN_ID = 'searchAssistant'; +export const PLUGIN_NAME = 'searchAssistant'; diff --git a/x-pack/plugins/search_assistant/kibana.jsonc b/x-pack/plugins/search_assistant/kibana.jsonc new file mode 100644 index 0000000000000..85579b76a1e80 --- /dev/null +++ b/x-pack/plugins/search_assistant/kibana.jsonc @@ -0,0 +1,24 @@ +{ + "type": "plugin", + "id": "@kbn/search-assistant", + "owner": "@elastic/search-kibana", + "description": "AI Assistant for Search", + "plugin": { + "id": "searchAssistant", + "server": true, + "browser": true, + "configPath": [ + "xpack", + "searchAssistant" + ], + "requiredPlugins": [ + "observabilityAIAssistant", + "observabilityAIAssistantApp" + ], + "optionalPlugins": [ + "cloud", + "usageCollection", + ], + "requiredBundles": [] + } +} diff --git a/x-pack/plugins/search_assistant/public/application.tsx b/x-pack/plugins/search_assistant/public/application.tsx new file mode 100644 index 0000000000000..071c51f4b6e13 --- /dev/null +++ b/x-pack/plugins/search_assistant/public/application.tsx @@ -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 React from 'react'; +import ReactDOM from 'react-dom'; +import type { CoreStart } from '@kbn/core/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import { Router } from '@kbn/shared-ux-router'; +import type { SearchAssistantPluginStartDependencies } from './types'; +import { SearchAssistantRouter } from './router'; + +export const renderApp = ( + core: CoreStart, + services: SearchAssistantPluginStartDependencies, + element: HTMLElement +) => { + ReactDOM.render( + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/plugins/search_assistant/public/components/app.tsx b/x-pack/plugins/search_assistant/public/components/app.tsx new file mode 100644 index 0000000000000..7d9497c0e1457 --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/app.tsx @@ -0,0 +1,15 @@ +/* + * 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 { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +export const App: React.FC = () => { + return ( + +
+ + ); +}; diff --git a/x-pack/plugins/search_assistant/public/components/search_assistant.tsx b/x-pack/plugins/search_assistant/public/components/search_assistant.tsx new file mode 100644 index 0000000000000..9c227a4e7b73f --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/search_assistant.tsx @@ -0,0 +1,24 @@ +/* + * 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 { EuiPageTemplate } from '@elastic/eui'; +import React from 'react'; +import { App } from './app'; + +export const SearchAssistantPage: React.FC = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/search_assistant/public/index.ts b/x-pack/plugins/search_assistant/public/index.ts new file mode 100644 index 0000000000000..c2b16e857b53e --- /dev/null +++ b/x-pack/plugins/search_assistant/public/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { SearchAssistantPlugin } from './plugin'; + +export function plugin() { + return new SearchAssistantPlugin(); +} +export type { SearchAssistantPluginSetup, SearchAssistantPluginStart } from './types'; diff --git a/x-pack/plugins/search_assistant/public/plugin.ts b/x-pack/plugins/search_assistant/public/plugin.ts new file mode 100644 index 0000000000000..8ba22a48df9ff --- /dev/null +++ b/x-pack/plugins/search_assistant/public/plugin.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreSetup, Plugin } from '@kbn/core/public'; +import type { + SearchAssistantPluginSetup, + SearchAssistantPluginStart, + SearchAssistantPluginStartDependencies, +} from './types'; + +export class SearchAssistantPlugin + implements Plugin +{ + public setup( + core: CoreSetup + ): SearchAssistantPluginSetup { + return {}; + } + + public start(): SearchAssistantPluginStart { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/search_assistant/public/router.tsx b/x-pack/plugins/search_assistant/public/router.tsx new file mode 100644 index 0000000000000..a25f865b4f74a --- /dev/null +++ b/x-pack/plugins/search_assistant/public/router.tsx @@ -0,0 +1,20 @@ +/* + * 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 { Route, Routes } from '@kbn/shared-ux-router'; +import React from 'react'; +import { SearchAssistantPage } from './components/search_assistant'; + +export const SearchAssistantRouter: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/search_assistant/public/types.ts b/x-pack/plugins/search_assistant/public/types.ts new file mode 100644 index 0000000000000..f05592414a9dc --- /dev/null +++ b/x-pack/plugins/search_assistant/public/types.ts @@ -0,0 +1,22 @@ +/* + * 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 { AppMountParameters } from '@kbn/core/public'; +import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchAssistantPluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchAssistantPluginStart {} + +export interface SearchAssistantPluginStartDependencies { + history: AppMountParameters['history']; + observabilityAIAssistant: ObservabilityAIAssistantPublicStart; + usageCollection?: UsageCollectionStart; +} diff --git a/x-pack/plugins/search_assistant/server/config.ts b/x-pack/plugins/search_assistant/server/config.ts new file mode 100644 index 0000000000000..a09b7ac51b7b7 --- /dev/null +++ b/x-pack/plugins/search_assistant/server/config.ts @@ -0,0 +1,19 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core/server'; + +const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type SearchAssistantConfig = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; diff --git a/x-pack/plugins/search_assistant/server/index.ts b/x-pack/plugins/search_assistant/server/index.ts new file mode 100644 index 0000000000000..027b51cfdd7d7 --- /dev/null +++ b/x-pack/plugins/search_assistant/server/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { SearchAssistantPlugin } from './plugin'; + +export { config } from './config'; + +export function plugin() { + return new SearchAssistantPlugin(); +} + +export type { SearchAssistantPluginSetup, SearchAssistantPluginStart } from './types'; diff --git a/x-pack/plugins/search_assistant/server/plugin.ts b/x-pack/plugins/search_assistant/server/plugin.ts new file mode 100644 index 0000000000000..cdd6c3ea115b2 --- /dev/null +++ b/x-pack/plugins/search_assistant/server/plugin.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 type { Plugin } from '@kbn/core/server'; + +import type { SearchAssistantPluginSetup, SearchAssistantPluginStart } from './types'; + +export class SearchAssistantPlugin + implements Plugin +{ + constructor() {} + + public setup() { + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/search_assistant/server/types.ts b/x-pack/plugins/search_assistant/server/types.ts new file mode 100644 index 0000000000000..03c2e5a46f91d --- /dev/null +++ b/x-pack/plugins/search_assistant/server/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchAssistantPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchAssistantPluginStart {} diff --git a/x-pack/plugins/search_assistant/tsconfig.json b/x-pack/plugins/search_assistant/tsconfig.json new file mode 100644 index 0000000000000..090356cf1f440 --- /dev/null +++ b/x-pack/plugins/search_assistant/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "index.ts", + "common/**/*.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/react-kibana-context-render", + "@kbn/kibana-react-plugin", + "@kbn/i18n-react", + "@kbn/shared-ux-router", + "@kbn/shared-ux-page-kibana-template", + "@kbn/usage-collection-plugin", + "@kbn/observability-ai-assistant-plugin", + "@kbn/config-schema" + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/yarn.lock b/yarn.lock index 73366acb9d613..9a876f07ecb25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6216,6 +6216,10 @@ version "0.0.0" uid "" +"@kbn/search-assistant@link:x-pack/plugins/search_assistant": + version "0.0.0" + uid "" + "@kbn/search-connectors-plugin@link:x-pack/plugins/search_connectors": version "0.0.0" uid "" From 432faea77b95de2a9c05cac4c402a7368d7b4922 Mon Sep 17 00:00:00 2001 From: Maxim Kholod Date: Thu, 22 Aug 2024 14:34:32 +0200 Subject: [PATCH 31/45] [Cloud Security] align vertically controls on bechmark pages (#191066) ## Summary - part of https://github.com/elastic/security-team/issues/10316 - fixes https://github.com/elastic/security-team/issues/9428#issuecomment-2107656242 ## Screenshots Screenshot 2024-08-22 at 12 34 47 Screenshot 2024-08-22 at 12 34 54 --- .../public/pages/rules/rules_table_header.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx index fc714263f38be..116bae053fd32 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx @@ -23,7 +23,6 @@ import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; -import { euiThemeVars } from '@kbn/ui-theme'; import { useKibana } from '../../common/hooks/use_kibana'; import { getFindingsDetectionRuleSearchTagsFromArrayOfRules } from '../../../common/utils/detection_rules'; import { @@ -294,9 +293,6 @@ const CurrentPageOfTotal = ({ size="xs" iconType="arrowDown" iconSide="right" - css={css` - padding-bottom: ${euiThemeVars.euiSizeS}; - `} data-test-subj={RULES_BULK_ACTION_BUTTON} > Bulk actions @@ -326,7 +322,7 @@ const CurrentPageOfTotal = ({ return ( - + setSelectedRules([])} size="xs" iconType="cross" - css={css` - padding-bottom: ${euiThemeVars.euiSizeS}; - `} data-test-subj={RULES_CLEAR_ALL_RULES_SELECTION} > Date: Thu, 22 Aug 2024 08:07:14 -0500 Subject: [PATCH 32/45] [ci] Upgrade axios to 1.7.4 (#191023) --- .buildkite/package-lock.json | 18 +++++++++--------- .buildkite/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.buildkite/package-lock.json b/.buildkite/package-lock.json index 437e56299cb6c..de45e39fb4092 100644 --- a/.buildkite/package-lock.json +++ b/.buildkite/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@octokit/rest": "^18.10.0", - "axios": "^1.6.3", + "axios": "^1.7.4", "globby": "^11.1.0", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", @@ -351,11 +351,11 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -1946,11 +1946,11 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "requires": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } diff --git a/.buildkite/package.json b/.buildkite/package.json index efd66f1c17a09..158a55c777e6a 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@octokit/rest": "^18.10.0", - "axios": "^1.6.3", + "axios": "^1.7.4", "globby": "^11.1.0", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", From e932b932f0f84c8fdf697292546512c7531aa1f6 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:33:31 +0200 Subject: [PATCH 33/45] [Search] Fix one instance of div nesting inside p warning (#191061) ## Summary This fixes one error of a div being nested inside of a p, and fixes some header order issues. --- .../components/shared/ingestion_card/ingestion_card.tsx | 1 + .../components/product_selector/product_selector.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx index 819b46f6b393a..0d01eea4e6787 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx @@ -46,6 +46,7 @@ export const IngestionCard: React.FC = ({ hasBorder isDisabled={isDisabled} textAlign="left" + titleElement="h3" title={ <> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.tsx index f6762e0e59bab..71139a8b36402 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_overview/components/product_selector/product_selector.tsx @@ -62,11 +62,11 @@ export const ProductSelector: React.FC = () => { -

+

{i18n.translate('xpack.enterpriseSearch.productSelector.overview.title', { defaultMessage: 'Ingest your content', })} -

+
From 08fbd9caae04ca9f077333ced6c53b4635e0cc9e Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:35:14 +0200 Subject: [PATCH 34/45] [ES|QL] Correctly parse `source` nodes (#190941) ## Summary Fixes `source` node parsing. Correctly handles cluster part and unescapes the quoted index string part. 1. First removes the cluster string part. 2. Unquotes and unescapes the index string part (if it is quoted and escaped). Those two were not done before: the index patter string was unquoted as a whole (with cluster part attached); and, the index string was not unescaped. ### 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 ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/__tests__/ast_parser.source.test.ts | 265 ++++++++++++++++++ packages/kbn-esql-ast/src/ast_helpers.ts | 48 ++++ packages/kbn-esql-ast/src/types.ts | 19 ++ 3 files changed, 332 insertions(+) create mode 100644 packages/kbn-esql-ast/src/__tests__/ast_parser.source.test.ts diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.source.test.ts b/packages/kbn-esql-ast/src/__tests__/ast_parser.source.test.ts new file mode 100644 index 0000000000000..8d01e655e6fff --- /dev/null +++ b/packages/kbn-esql-ast/src/__tests__/ast_parser.source.test.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getAstAndSyntaxErrors as parse } from '../ast_parser'; + +describe('source nodes', () => { + it('cluster vs quoted source', () => { + const text = 'FROM cluster:index, "cluster:index"'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'cluster:index', + cluster: 'cluster', + index: 'index', + }, + { + type: 'source', + name: 'cluster:index', + cluster: '', + index: 'cluster:index', + }, + ], + }, + ]); + }); + + it('date-math syntax', () => { + const text = 'FROM '; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: '', + cluster: '', + index: '', + }, + ], + }, + ]); + }); + + describe('unquoted', () => { + it('basic', () => { + const text = 'FROM a'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a', + cluster: '', + index: 'a', + }, + ], + }, + ]); + }); + + it('with slash', () => { + const text = 'FROM a/b'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a/b', + cluster: '', + index: 'a/b', + }, + ], + }, + ]); + }); + + it('dot and star', () => { + const text = 'FROM a.b-*'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a.b-*', + cluster: '', + index: 'a.b-*', + }, + ], + }, + ]); + }); + }); + + describe('double quoted', () => { + it('basic', () => { + const text = 'FROM "a"'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a', + cluster: '', + index: 'a', + }, + ], + }, + ]); + }); + + it('allows escaped chars', () => { + const text = 'FROM "a \\" \\r \\n \\t \\\\ b"'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: expect.any(String), + cluster: '', + index: 'a " \r \n \t \\ b', + }, + ], + }, + ]); + }); + }); + + describe('triple-double quoted', () => { + it('basic', () => { + const text = 'FROM """a"""'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a', + cluster: '', + index: 'a', + }, + ], + }, + ]); + }); + + it('with double quote in the middle', () => { + const text = 'FROM """a"b"""'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a"b', + cluster: '', + index: 'a"b', + }, + ], + }, + ]); + }); + + it('allows special chars', () => { + const text = 'FROM """a:\\/b"""'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a:\\/b', + cluster: '', + index: 'a:\\/b', + }, + ], + }, + ]); + }); + + it('allows emojis', () => { + const text = 'FROM """a👍b"""'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'a👍b', + cluster: '', + index: 'a👍b', + }, + ], + }, + ]); + }); + }); + + describe('cluster string', () => { + it('basic', () => { + const text = 'FROM cluster:a'; + const { ast } = parse(text); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'cluster:a', + cluster: 'cluster', + index: 'a', + }, + ], + }, + ]); + }); + }); +}); diff --git a/packages/kbn-esql-ast/src/ast_helpers.ts b/packages/kbn-esql-ast/src/ast_helpers.ts index 44f9a2663db17..e338d2eacd4a4 100644 --- a/packages/kbn-esql-ast/src/ast_helpers.ts +++ b/packages/kbn-esql-ast/src/ast_helpers.ts @@ -12,6 +12,7 @@ import { type Token, type ParserRuleContext, type TerminalNode } from 'antlr4'; import { + IndexPatternContext, QualifiedNameContext, type ArithmeticUnaryContext, type DecimalValueContext, @@ -306,6 +307,34 @@ function sanitizeSourceString(ctx: ParserRuleContext) { return contextText; } +const unquoteIndexString = (indexString: string): string => { + const isStringQuoted = indexString[0] === '"'; + + if (!isStringQuoted) { + return indexString; + } + + // If wrapped by triple double quotes, simply remove them. + if (indexString.startsWith(`"""`) && indexString.endsWith(`"""`)) { + return indexString.slice(3, -3); + } + + // If wrapped by double quote, remove them and unescape the string. + if (indexString[indexString.length - 1] === '"') { + indexString = indexString.slice(1, -1); + indexString = indexString + .replace(/\\"/g, '"') + .replace(/\\r/g, '\r') + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\\\/g, '\\'); + return indexString; + } + + // This should never happen, but if it does, return the original string. + return indexString; +}; + export function sanitizeIdentifierString(ctx: ParserRuleContext) { const result = getUnquotedText(ctx)?.getText() || @@ -352,8 +381,27 @@ export function createSource( type: 'index' | 'policy' = 'index' ): ESQLSource { const text = sanitizeSourceString(ctx); + + let cluster: string = ''; + let index: string = ''; + + if (ctx instanceof IndexPatternContext) { + const clusterString = ctx.clusterString(); + const indexString = ctx.indexString(); + + if (clusterString) { + cluster = clusterString.getText(); + } + if (indexString) { + index = indexString.getText(); + index = unquoteIndexString(index); + } + } + return { type: 'source', + cluster, + index, name: text, sourceType: type, text, diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index ae675a375a430..e9c0db1d216d3 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -175,6 +175,25 @@ export interface ESQLTimeInterval extends ESQLAstBaseItem { export interface ESQLSource extends ESQLAstBaseItem { type: 'source'; sourceType: 'index' | 'policy'; + + /** + * Represents the cluster part of the source identifier. Empty string if not + * present. + * + * ``` + * FROM [:] + * ``` + */ + cluster?: string; + + /** + * Represents the index part of the source identifier. Unescaped and unquoted. + * + * ``` + * FROM [:] + * ``` + */ + index?: string; } export interface ESQLColumn extends ESQLAstBaseItem { From 7217ad0c052bc9ec3ec94f549574fa2151a69eda Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 22 Aug 2024 15:43:08 +0200 Subject: [PATCH 35/45] Change the ownership of the `inference` plugin (#191071) ## Summary Change the ownership from `@elastic/kibana-core` to `@elastic/appex-ai-infra` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 6 ++++-- x-pack/plugins/inference/kibana.jsonc | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bc5dc9e82c8c2..aceeaf933cc49 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -508,7 +508,7 @@ x-pack/packages/index-management @elastic/kibana-management x-pack/plugins/index_management @elastic/kibana-management test/plugin_functional/plugins/index_patterns @elastic/kibana-data-discovery x-pack/packages/ml/inference_integration_flyout @elastic/ml-ui -x-pack/plugins/inference @elastic/kibana-core +x-pack/plugins/inference @elastic/appex-ai-infra x-pack/packages/kbn-infra-forge @elastic/obs-ux-management-team x-pack/plugins/observability_solution/infra @elastic/obs-ux-logs-team @elastic/obs-ux-infra_services-team x-pack/plugins/ingest_pipelines @elastic/kibana-management @@ -1307,7 +1307,6 @@ x-pack/test/**/deployment_agnostic/ @elastic/appex-qa #temporarily to monitor te /x-pack/test_serverless/**/test_suites/common/saved_objects_management/ @elastic/kibana-core /x-pack/test_serverless/api_integration/test_suites/common/core/ @elastic/kibana-core /x-pack/test_serverless/api_integration/test_suites/**/telemetry/ @elastic/kibana-core -/x-pack/plugins/inference @elastic/kibana-core @elastic/obs-ai-assistant @elastic/security-generative-ai #CC# /src/core/server/csp/ @elastic/kibana-core #CC# /src/plugins/saved_objects/ @elastic/kibana-core #CC# /x-pack/plugins/cloud/ @elastic/kibana-core @@ -1316,6 +1315,9 @@ x-pack/test/**/deployment_agnostic/ @elastic/appex-qa #temporarily to monitor te #CC# /src/plugins/newsfeed @elastic/kibana-core #CC# /x-pack/plugins/global_search_providers/ @elastic/kibana-core +# AppEx AI Infra +/x-pack/plugins/inference @elastic/appex-ai-infra @elastic/obs-ai-assistant @elastic/security-generative-ai + # AppEx Platform Services Security x-pack/test_serverless/api_integration/test_suites/common/security_response_headers.ts @elastic/kibana-security diff --git a/x-pack/plugins/inference/kibana.jsonc b/x-pack/plugins/inference/kibana.jsonc index c52b194be7dc7..6e4e389bdc5ff 100644 --- a/x-pack/plugins/inference/kibana.jsonc +++ b/x-pack/plugins/inference/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "plugin", "id": "@kbn/inference-plugin", - "owner": "@elastic/kibana-core", + "owner": "@elastic/appex-ai-infra", "plugin": { "id": "inference", "server": true, From 44fafb88d5aa9f357d22c33c2ebb377481036db0 Mon Sep 17 00:00:00 2001 From: "Eyo O. Eyo" <7893459+eokoneyo@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:44:54 +0200 Subject: [PATCH 36/45] Extract authorization logic and it's peripherals into packages (#190028) ## Summary This PR is a precursor to https://github.com/elastic/kibana/pull/189871, as part of the spaces improvement initiative there's a need to be able to share the user privilege assignment component between the roles experience and the new spaces experience to prevent duplication of business logic and cohesiveness in the privilege assignment experience. The aforementioned PR extracts the required component into it's own package so it might be consumed as needed, this PR is particularly concerned with extracting business logic said UI component depends on that exists still within the security plugin. For context; the security plugin already depends on the spaces plugin, so having the spaces plugin in turn statically depend on the security plugin creates a cyclic dependency. That being said to complement the eventual state of said component so it might be imported elsewhere outside of the security plugin there's a need to extract further logic into standalone packages, so that the spaces plugin can consume this plugin without the afore mentioned cyclic dependency problem. #### Visually; ##### Problem; ![image](https://github.com/user-attachments/assets/6be85fb0-3ba3-4d5f-b614-3c0ff2cf7c69) ##### Proposal ![image](https://github.com/user-attachments/assets/5c4f423d-4ad4-48f4-b5bd-2ea0a99b196e)[^legend] [^legend]: items marked in blue are the packages created in this PR, whilst the entire diagram is the proposed future state --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 2 + package.json | 2 + tsconfig.base.json | 4 + .../security/authorization_core/README.md | 3 + .../security/authorization_core/index.ts | 15 ++ .../authorization_core/jest.config.js | 15 ++ .../security/authorization_core/kibana.jsonc | 5 + .../security/authorization_core/package.json | 6 + .../src/__fixtures__/licensing.mock.ts | 55 ++++++ .../__snapshots__/alerting.test.ts.snap | 0 .../actions/__snapshots__/api.test.ts.snap | 0 .../actions/__snapshots__/app.test.ts.snap | 0 .../actions/__snapshots__/cases.test.ts.snap | 0 .../actions/__snapshots__/ui.test.ts.snap | 0 .../src}/actions/actions.mock.ts | 0 .../src}/actions/actions.test.ts | 0 .../src}/actions/actions.ts | 0 .../src}/actions/alerting.test.ts | 0 .../src}/actions/alerting.ts | 0 .../src}/actions/api.test.ts | 0 .../authorization_core/src}/actions/api.ts | 0 .../src}/actions/app.test.ts | 0 .../authorization_core/src}/actions/app.ts | 0 .../src}/actions/cases.test.ts | 0 .../authorization_core/src}/actions/cases.ts | 0 .../authorization_core/src}/actions/index.ts | 0 .../src}/actions/saved_object.test.ts | 0 .../src}/actions/saved_object.ts | 0 .../src}/actions/space.test.ts | 0 .../authorization_core/src}/actions/space.ts | 0 .../src}/actions/ui.test.ts | 0 .../authorization_core/src}/actions/ui.ts | 0 .../__snapshots__/cases.test.ts.snap | 0 .../alerting.test.ts | 0 .../feature_privilege_builder/alerting.ts | 0 .../feature_privilege_builder/api.ts | 0 .../feature_privilege_builder/app.ts | 0 .../feature_privilege_builder/cases.test.ts | 0 .../feature_privilege_builder/cases.ts | 0 .../feature_privilege_builder/catalogue.ts | 0 .../feature_privilege_builder.ts | 0 .../feature_privilege_builder/index.ts | 0 .../feature_privilege_builder/management.ts | 0 .../feature_privilege_builder/navlink.ts | 0 .../feature_privilege_builder/saved_object.ts | 0 .../feature_privilege_builder/ui.ts | 0 .../src}/privileges/index.ts | 1 + .../src}/privileges/privileges.test.ts | 2 +- .../src}/privileges/privileges.ts | 4 +- .../src/privileges}/raw_kibana_privileges.ts | 0 .../security/authorization_core/tsconfig.json | 16 ++ .../security/role_management_model/README.md | 3 + .../security/role_management_model/index.ts | 15 ++ .../role_management_model/jest.config.js | 16 ++ .../role_management_model/kibana.jsonc | 5 + .../role_management_model/package.json | 6 + .../src/__fixtures__/index.ts | 9 + .../src}/__fixtures__/kibana_features.ts | 0 .../src}/__fixtures__/kibana_privileges.ts | 26 ++- .../src}/kibana_privilege.ts | 0 .../src/kibana_privileges.test.ts | 187 ++++++++++++++++++ .../src}/kibana_privileges.ts | 15 +- .../src}/primary_feature_privilege.ts | 0 .../src}/privilege_collection.test.ts | 0 .../src}/privilege_collection.ts | 0 .../src}/secured_feature.ts | 0 .../src}/secured_sub_feature.ts | 0 .../src}/sub_feature_privilege.ts | 0 .../src}/sub_feature_privilege_group.ts | 0 .../role_management_model/tsconfig.json | 15 ++ x-pack/plugins/security/common/index.ts | 3 +- .../security/common/licensing/index.mock.ts | 49 +---- x-pack/plugins/security/common/model/index.ts | 5 +- .../roles/edit_role/edit_role_page.test.tsx | 2 +- .../roles/edit_role/edit_role_page.tsx | 2 +- .../roles/edit_role/privilege_utils.test.ts | 50 ----- .../roles/edit_role/privilege_utils.ts | 19 -- .../feature_table/change_all_privileges.tsx | 2 +- .../feature_table/feature_table.test.tsx | 7 +- .../kibana/feature_table/feature_table.tsx | 4 +- .../feature_table_expanded_row.test.tsx | 6 +- .../feature_table_expanded_row.tsx | 2 +- .../feature_table/sub_feature_form.test.tsx | 10 +- .../kibana/feature_table/sub_feature_form.tsx | 4 +- .../feature_table_cell.test.tsx | 4 +- .../feature_table_cell/feature_table_cell.tsx | 2 +- .../kibana/kibana_privileges_region.test.tsx | 4 +- .../kibana/kibana_privileges_region.tsx | 2 +- .../privilege_form_calculator.test.ts | 7 +- .../privilege_form_calculator.ts | 9 +- .../privilege_summary.test.tsx | 6 +- .../privilege_summary/privilege_summary.tsx | 4 +- .../privilege_summary_calculator.test.ts | 9 +- .../privilege_summary_calculator.ts | 13 +- .../privilege_summary_expanded_row.tsx | 6 +- .../privilege_summary_table.test.tsx | 8 +- .../privilege_summary_table.tsx | 10 +- .../privilege_summary/space_column_header.tsx | 4 +- .../simple_privilege_section.test.tsx | 4 +- .../simple_privilege_section.tsx | 6 +- .../privilege_space_form.test.tsx | 7 +- .../privilege_space_form.tsx | 2 +- .../privilege_space_table.test.tsx | 4 +- .../privilege_space_table.tsx | 4 +- .../space_aware_privilege_section.test.tsx | 6 +- .../space_aware_privilege_section.tsx | 4 +- .../public/management/roles/model/index.ts | 15 -- .../roles/model/kibana_privileges.test.ts | 144 -------------- .../management/roles/privileges_api_client.ts | 3 +- .../authorization_service.test.ts | 2 +- .../authorization/authorization_service.tsx | 10 +- .../disable_ui_capabilities.test.ts | 2 +- .../server/authorization/index.mock.ts | 3 +- .../security/server/authorization/index.ts | 3 +- .../register_privileges_with_cluster.ts | 2 +- .../authorization/service.test.mocks.ts | 10 +- x-pack/plugins/security/tsconfig.json | 4 +- yarn.lock | 8 + 118 files changed, 554 insertions(+), 369 deletions(-) create mode 100644 x-pack/packages/security/authorization_core/README.md create mode 100644 x-pack/packages/security/authorization_core/index.ts create mode 100644 x-pack/packages/security/authorization_core/jest.config.js create mode 100644 x-pack/packages/security/authorization_core/kibana.jsonc create mode 100644 x-pack/packages/security/authorization_core/package.json create mode 100644 x-pack/packages/security/authorization_core/src/__fixtures__/licensing.mock.ts rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/__snapshots__/alerting.test.ts.snap (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/__snapshots__/api.test.ts.snap (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/__snapshots__/app.test.ts.snap (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/__snapshots__/cases.test.ts.snap (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/__snapshots__/ui.test.ts.snap (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/actions.mock.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/actions.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/actions.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/alerting.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/alerting.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/api.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/api.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/app.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/app.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/cases.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/cases.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/index.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/saved_object.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/saved_object.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/space.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/space.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/ui.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/actions/ui.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/alerting.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/alerting.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/api.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/app.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/cases.test.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/cases.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/catalogue.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/feature_privilege_builder.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/index.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/management.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/navlink.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/saved_object.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/feature_privilege_builder/ui.ts (100%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/index.ts (81%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/privileges.test.ts (99%) rename x-pack/{plugins/security/server/authorization => packages/security/authorization_core/src}/privileges/privileges.ts (98%) rename x-pack/{plugins/security/common/model => packages/security/authorization_core/src/privileges}/raw_kibana_privileges.ts (100%) create mode 100644 x-pack/packages/security/authorization_core/tsconfig.json create mode 100644 x-pack/packages/security/role_management_model/README.md create mode 100644 x-pack/packages/security/role_management_model/index.ts create mode 100644 x-pack/packages/security/role_management_model/jest.config.js create mode 100644 x-pack/packages/security/role_management_model/kibana.jsonc create mode 100644 x-pack/packages/security/role_management_model/package.json create mode 100644 x-pack/packages/security/role_management_model/src/__fixtures__/index.ts rename x-pack/{plugins/security/public/management/roles => packages/security/role_management_model/src}/__fixtures__/kibana_features.ts (100%) rename x-pack/{plugins/security/public/management/roles => packages/security/role_management_model/src}/__fixtures__/kibana_privileges.ts (54%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/kibana_privilege.ts (100%) create mode 100644 x-pack/packages/security/role_management_model/src/kibana_privileges.test.ts rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/kibana_privileges.ts (86%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/primary_feature_privilege.ts (100%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/privilege_collection.test.ts (100%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/privilege_collection.ts (100%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/secured_feature.ts (100%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/secured_sub_feature.ts (100%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/sub_feature_privilege.ts (100%) rename x-pack/{plugins/security/public/management/roles/model => packages/security/role_management_model/src}/sub_feature_privilege_group.ts (100%) create mode 100644 x-pack/packages/security/role_management_model/tsconfig.json delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts delete mode 100644 x-pack/plugins/security/public/management/roles/model/index.ts delete mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index aceeaf933cc49..f366f654c7770 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -748,12 +748,14 @@ packages/kbn-search-types @elastic/kibana-data-discovery x-pack/plugins/searchprofiler @elastic/kibana-management x-pack/test/security_api_integration/packages/helpers @elastic/kibana-security x-pack/packages/security/api_key_management @elastic/kibana-security +x-pack/packages/security/authorization_core @elastic/kibana-security x-pack/packages/security/form_components @elastic/kibana-security packages/kbn-security-hardening @elastic/kibana-security x-pack/plugins/security @elastic/kibana-security x-pack/packages/security/plugin_types_common @elastic/kibana-security x-pack/packages/security/plugin_types_public @elastic/kibana-security x-pack/packages/security/plugin_types_server @elastic/kibana-security +x-pack/packages/security/role_management_model @elastic/kibana-security x-pack/packages/security-solution/distribution_bar @elastic/kibana-cloud-security-posture x-pack/plugins/security_solution_ess @elastic/security-solution x-pack/packages/security-solution/features @elastic/security-threat-hunting-explore diff --git a/package.json b/package.json index 0edb050fe9aea..949f7bbaad953 100644 --- a/package.json +++ b/package.json @@ -765,12 +765,14 @@ "@kbn/search-types": "link:packages/kbn-search-types", "@kbn/searchprofiler-plugin": "link:x-pack/plugins/searchprofiler", "@kbn/security-api-key-management": "link:x-pack/packages/security/api_key_management", + "@kbn/security-authorization-core": "link:x-pack/packages/security/authorization_core", "@kbn/security-form-components": "link:x-pack/packages/security/form_components", "@kbn/security-hardening": "link:packages/kbn-security-hardening", "@kbn/security-plugin": "link:x-pack/plugins/security", "@kbn/security-plugin-types-common": "link:x-pack/packages/security/plugin_types_common", "@kbn/security-plugin-types-public": "link:x-pack/packages/security/plugin_types_public", "@kbn/security-plugin-types-server": "link:x-pack/packages/security/plugin_types_server", + "@kbn/security-role-management-model": "link:x-pack/packages/security/role_management_model", "@kbn/security-solution-distribution-bar": "link:x-pack/packages/security-solution/distribution_bar", "@kbn/security-solution-ess": "link:x-pack/plugins/security_solution_ess", "@kbn/security-solution-features": "link:x-pack/packages/security-solution/features", diff --git a/tsconfig.base.json b/tsconfig.base.json index f433970fae490..9f37b2ad90f59 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1490,6 +1490,8 @@ "@kbn/security-api-integration-helpers/*": ["x-pack/test/security_api_integration/packages/helpers/*"], "@kbn/security-api-key-management": ["x-pack/packages/security/api_key_management"], "@kbn/security-api-key-management/*": ["x-pack/packages/security/api_key_management/*"], + "@kbn/security-authorization-core": ["x-pack/packages/security/authorization_core"], + "@kbn/security-authorization-core/*": ["x-pack/packages/security/authorization_core/*"], "@kbn/security-form-components": ["x-pack/packages/security/form_components"], "@kbn/security-form-components/*": ["x-pack/packages/security/form_components/*"], "@kbn/security-hardening": ["packages/kbn-security-hardening"], @@ -1502,6 +1504,8 @@ "@kbn/security-plugin-types-public/*": ["x-pack/packages/security/plugin_types_public/*"], "@kbn/security-plugin-types-server": ["x-pack/packages/security/plugin_types_server"], "@kbn/security-plugin-types-server/*": ["x-pack/packages/security/plugin_types_server/*"], + "@kbn/security-role-management-model": ["x-pack/packages/security/role_management_model"], + "@kbn/security-role-management-model/*": ["x-pack/packages/security/role_management_model/*"], "@kbn/security-solution-distribution-bar": ["x-pack/packages/security-solution/distribution_bar"], "@kbn/security-solution-distribution-bar/*": ["x-pack/packages/security-solution/distribution_bar/*"], "@kbn/security-solution-ess": ["x-pack/plugins/security_solution_ess"], diff --git a/x-pack/packages/security/authorization_core/README.md b/x-pack/packages/security/authorization_core/README.md new file mode 100644 index 0000000000000..ce2c2dd277198 --- /dev/null +++ b/x-pack/packages/security/authorization_core/README.md @@ -0,0 +1,3 @@ +# @kbn/security-authorization-core + +Contains core authorization logic diff --git a/x-pack/packages/security/authorization_core/index.ts b/x-pack/packages/security/authorization_core/index.ts new file mode 100644 index 0000000000000..ccb68eb3bbcec --- /dev/null +++ b/x-pack/packages/security/authorization_core/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Actions } from './src/actions'; +export { privilegesFactory } from './src/privileges'; +export type { + CasesSupportedOperations, + PrivilegesService, + RawKibanaPrivileges, + RawKibanaFeaturePrivileges, +} from './src/privileges'; diff --git a/x-pack/packages/security/authorization_core/jest.config.js b/x-pack/packages/security/authorization_core/jest.config.js new file mode 100644 index 0000000000000..db3272ac46d92 --- /dev/null +++ b/x-pack/packages/security/authorization_core/jest.config.js @@ -0,0 +1,15 @@ +/* + * 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. + */ + +module.exports = { + coverageDirectory: '/x-pack/packages/security/authorization_core', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/packages/security/authorization_core/**/*.{ts,tsx}'], + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/packages/security/authorization_core'], +}; diff --git a/x-pack/packages/security/authorization_core/kibana.jsonc b/x-pack/packages/security/authorization_core/kibana.jsonc new file mode 100644 index 0000000000000..f2e33db5c8a81 --- /dev/null +++ b/x-pack/packages/security/authorization_core/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/security-authorization-core", + "owner": "@elastic/kibana-security" +} diff --git a/x-pack/packages/security/authorization_core/package.json b/x-pack/packages/security/authorization_core/package.json new file mode 100644 index 0000000000000..4b270288d4763 --- /dev/null +++ b/x-pack/packages/security/authorization_core/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/security-authorization-core", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/packages/security/authorization_core/src/__fixtures__/licensing.mock.ts b/x-pack/packages/security/authorization_core/src/__fixtures__/licensing.mock.ts new file mode 100644 index 0000000000000..6ee9910b768bd --- /dev/null +++ b/x-pack/packages/security/authorization_core/src/__fixtures__/licensing.mock.ts @@ -0,0 +1,55 @@ +/* + * 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 { Observable, of } from 'rxjs'; + +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LICENSE_TYPE } from '@kbn/licensing-plugin/common/types'; +import type { SecurityLicense, SecurityLicenseFeatures } from '@kbn/security-plugin-types-common'; + +export const licenseMock = { + create: ( + features: Partial | Observable> = {}, + licenseType: LicenseType = 'basic', // default to basic if this is not specified, + isAvailable: Observable = of(true) + ): jest.Mocked => ({ + isLicenseAvailable: jest.fn().mockImplementation(() => { + let result = true; + + isAvailable.subscribe((next) => { + result = next; + }); + + return result; + }), + getLicenseType: jest.fn().mockReturnValue(licenseType), + getUnavailableReason: jest.fn(), + isEnabled: jest.fn().mockReturnValue(true), + getFeatures: + features instanceof Observable + ? jest.fn().mockImplementation(() => { + let subbedFeatures: Partial = {}; + + features.subscribe((next) => { + subbedFeatures = next; + }); + + return subbedFeatures; + }) + : jest.fn().mockReturnValue(features), + hasAtLeast: jest + .fn() + .mockImplementation( + (licenseTypeToCheck: LicenseType) => + LICENSE_TYPE[licenseTypeToCheck] <= LICENSE_TYPE[licenseType] + ), + features$: + features instanceof Observable + ? (features as Observable) + : of((features ?? {}) as SecurityLicenseFeatures), + }), +}; diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/alerting.test.ts.snap b/x-pack/packages/security/authorization_core/src/actions/__snapshots__/alerting.test.ts.snap similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/__snapshots__/alerting.test.ts.snap rename to x-pack/packages/security/authorization_core/src/actions/__snapshots__/alerting.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/api.test.ts.snap b/x-pack/packages/security/authorization_core/src/actions/__snapshots__/api.test.ts.snap similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/__snapshots__/api.test.ts.snap rename to x-pack/packages/security/authorization_core/src/actions/__snapshots__/api.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/app.test.ts.snap b/x-pack/packages/security/authorization_core/src/actions/__snapshots__/app.test.ts.snap similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/__snapshots__/app.test.ts.snap rename to x-pack/packages/security/authorization_core/src/actions/__snapshots__/app.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/cases.test.ts.snap b/x-pack/packages/security/authorization_core/src/actions/__snapshots__/cases.test.ts.snap similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/__snapshots__/cases.test.ts.snap rename to x-pack/packages/security/authorization_core/src/actions/__snapshots__/cases.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/ui.test.ts.snap b/x-pack/packages/security/authorization_core/src/actions/__snapshots__/ui.test.ts.snap similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/__snapshots__/ui.test.ts.snap rename to x-pack/packages/security/authorization_core/src/actions/__snapshots__/ui.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/actions.mock.ts b/x-pack/packages/security/authorization_core/src/actions/actions.mock.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/actions.mock.ts rename to x-pack/packages/security/authorization_core/src/actions/actions.mock.ts diff --git a/x-pack/plugins/security/server/authorization/actions/actions.test.ts b/x-pack/packages/security/authorization_core/src/actions/actions.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/actions.test.ts rename to x-pack/packages/security/authorization_core/src/actions/actions.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/packages/security/authorization_core/src/actions/actions.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/actions.ts rename to x-pack/packages/security/authorization_core/src/actions/actions.ts diff --git a/x-pack/plugins/security/server/authorization/actions/alerting.test.ts b/x-pack/packages/security/authorization_core/src/actions/alerting.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/alerting.test.ts rename to x-pack/packages/security/authorization_core/src/actions/alerting.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/alerting.ts b/x-pack/packages/security/authorization_core/src/actions/alerting.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/alerting.ts rename to x-pack/packages/security/authorization_core/src/actions/alerting.ts diff --git a/x-pack/plugins/security/server/authorization/actions/api.test.ts b/x-pack/packages/security/authorization_core/src/actions/api.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/api.test.ts rename to x-pack/packages/security/authorization_core/src/actions/api.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/api.ts b/x-pack/packages/security/authorization_core/src/actions/api.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/api.ts rename to x-pack/packages/security/authorization_core/src/actions/api.ts diff --git a/x-pack/plugins/security/server/authorization/actions/app.test.ts b/x-pack/packages/security/authorization_core/src/actions/app.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/app.test.ts rename to x-pack/packages/security/authorization_core/src/actions/app.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/app.ts b/x-pack/packages/security/authorization_core/src/actions/app.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/app.ts rename to x-pack/packages/security/authorization_core/src/actions/app.ts diff --git a/x-pack/plugins/security/server/authorization/actions/cases.test.ts b/x-pack/packages/security/authorization_core/src/actions/cases.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/cases.test.ts rename to x-pack/packages/security/authorization_core/src/actions/cases.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/cases.ts b/x-pack/packages/security/authorization_core/src/actions/cases.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/cases.ts rename to x-pack/packages/security/authorization_core/src/actions/cases.ts diff --git a/x-pack/plugins/security/server/authorization/actions/index.ts b/x-pack/packages/security/authorization_core/src/actions/index.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/index.ts rename to x-pack/packages/security/authorization_core/src/actions/index.ts diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/packages/security/authorization_core/src/actions/saved_object.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/saved_object.test.ts rename to x-pack/packages/security/authorization_core/src/actions/saved_object.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/packages/security/authorization_core/src/actions/saved_object.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/saved_object.ts rename to x-pack/packages/security/authorization_core/src/actions/saved_object.ts diff --git a/x-pack/plugins/security/server/authorization/actions/space.test.ts b/x-pack/packages/security/authorization_core/src/actions/space.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/space.test.ts rename to x-pack/packages/security/authorization_core/src/actions/space.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/space.ts b/x-pack/packages/security/authorization_core/src/actions/space.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/space.ts rename to x-pack/packages/security/authorization_core/src/actions/space.ts diff --git a/x-pack/plugins/security/server/authorization/actions/ui.test.ts b/x-pack/packages/security/authorization_core/src/actions/ui.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/ui.test.ts rename to x-pack/packages/security/authorization_core/src/actions/ui.test.ts diff --git a/x-pack/plugins/security/server/authorization/actions/ui.ts b/x-pack/packages/security/authorization_core/src/actions/ui.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/actions/ui.ts rename to x-pack/packages/security/authorization_core/src/actions/ui.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/alerting.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/alerting.test.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/alerting.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/alerting.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/api.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/api.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/app.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/app.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.test.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.test.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/catalogue.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/catalogue.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/feature_privilege_builder.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/feature_privilege_builder.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/index.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/index.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/management.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/management.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/navlink.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/navlink.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/saved_object.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/saved_object.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/ui.ts similarity index 100% rename from x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts rename to x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/ui.ts diff --git a/x-pack/plugins/security/server/authorization/privileges/index.ts b/x-pack/packages/security/authorization_core/src/privileges/index.ts similarity index 81% rename from x-pack/plugins/security/server/authorization/privileges/index.ts rename to x-pack/packages/security/authorization_core/src/privileges/index.ts index 1056aa6dcd9af..7113b1b348bec 100644 --- a/x-pack/plugins/security/server/authorization/privileges/index.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/index.ts @@ -8,3 +8,4 @@ export type { PrivilegesService } from './privileges'; export type { CasesSupportedOperations } from './feature_privilege_builder'; export { privilegesFactory } from './privileges'; +export type { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts similarity index 99% rename from x-pack/plugins/security/server/authorization/privileges/privileges.test.ts rename to x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts index 93efd86f52f54..118d63503db22 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts @@ -9,7 +9,7 @@ import { KibanaFeature } from '@kbn/features-plugin/server'; import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; import { privilegesFactory } from './privileges'; -import { licenseMock } from '../../../common/licensing/index.mock'; +import { licenseMock } from '../__fixtures__/licensing.mock'; import { Actions } from '../actions'; const actions = new Actions(); diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/packages/security/authorization_core/src/privileges/privileges.ts similarity index 98% rename from x-pack/plugins/security/server/authorization/privileges/privileges.ts rename to x-pack/packages/security/authorization_core/src/privileges/privileges.ts index 4295ae7c89bb4..9fb8dd9f083e2 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/privileges.ts @@ -13,9 +13,9 @@ import type { } from '@kbn/features-plugin/common'; import type { FeaturesPluginSetup, KibanaFeature } from '@kbn/features-plugin/server'; +import type { SecurityLicense } from '@kbn/security-plugin-types-common'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; -import type { SecurityLicense } from '../../../common'; -import type { RawKibanaPrivileges } from '../../../common/model'; +import type { RawKibanaPrivileges } from './raw_kibana_privileges'; import type { Actions } from '../actions'; export interface PrivilegesService { diff --git a/x-pack/plugins/security/common/model/raw_kibana_privileges.ts b/x-pack/packages/security/authorization_core/src/privileges/raw_kibana_privileges.ts similarity index 100% rename from x-pack/plugins/security/common/model/raw_kibana_privileges.ts rename to x-pack/packages/security/authorization_core/src/privileges/raw_kibana_privileges.ts diff --git a/x-pack/packages/security/authorization_core/tsconfig.json b/x-pack/packages/security/authorization_core/tsconfig.json new file mode 100644 index 0000000000000..03870180c12c5 --- /dev/null +++ b/x-pack/packages/security/authorization_core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": ["jest", "node", "react"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/features-plugin", + "@kbn/security-plugin-types-common", + "@kbn/security-plugin-types-server", + "@kbn/licensing-plugin", + ] +} diff --git a/x-pack/packages/security/role_management_model/README.md b/x-pack/packages/security/role_management_model/README.md new file mode 100644 index 0000000000000..f87e15a76e453 --- /dev/null +++ b/x-pack/packages/security/role_management_model/README.md @@ -0,0 +1,3 @@ +# @kbn/security-role-management + +Contains business logic for RBAC administration within Kibana. diff --git a/x-pack/packages/security/role_management_model/index.ts b/x-pack/packages/security/role_management_model/index.ts new file mode 100644 index 0000000000000..fa69415d3f8cc --- /dev/null +++ b/x-pack/packages/security/role_management_model/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SecuredFeature } from './src/secured_feature'; +export { SecuredSubFeature } from './src/secured_sub_feature'; +export { SubFeaturePrivilegeGroup } from './src/sub_feature_privilege_group'; +export { SubFeaturePrivilege } from './src/sub_feature_privilege'; +export { PrimaryFeaturePrivilege } from './src/primary_feature_privilege'; +export { KibanaPrivileges, isGlobalPrivilegeDefinition } from './src/kibana_privileges'; +export { KibanaPrivilege } from './src/kibana_privilege'; +export { PrivilegeCollection } from './src/privilege_collection'; diff --git a/x-pack/packages/security/role_management_model/jest.config.js b/x-pack/packages/security/role_management_model/jest.config.js new file mode 100644 index 0000000000000..4223e717dec5e --- /dev/null +++ b/x-pack/packages/security/role_management_model/jest.config.js @@ -0,0 +1,16 @@ +/* + * 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. + */ + +module.exports = { + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/packages/security/role_management_model', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/packages/security/role_management_model/**/*.{ts,tsx}'], + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/packages/security/role_management_model'], +}; diff --git a/x-pack/packages/security/role_management_model/kibana.jsonc b/x-pack/packages/security/role_management_model/kibana.jsonc new file mode 100644 index 0000000000000..9ba7936494167 --- /dev/null +++ b/x-pack/packages/security/role_management_model/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/security-role-management-model", + "owner": "@elastic/kibana-security" +} diff --git a/x-pack/packages/security/role_management_model/package.json b/x-pack/packages/security/role_management_model/package.json new file mode 100644 index 0000000000000..d231b70912484 --- /dev/null +++ b/x-pack/packages/security/role_management_model/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/security-role-management-model", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/packages/security/role_management_model/src/__fixtures__/index.ts b/x-pack/packages/security/role_management_model/src/__fixtures__/index.ts new file mode 100644 index 0000000000000..32f8d17be94b2 --- /dev/null +++ b/x-pack/packages/security/role_management_model/src/__fixtures__/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { createFeature, kibanaFeatures } from './kibana_features'; +export { createKibanaPrivileges, createRawKibanaPrivileges } from './kibana_privileges'; diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/packages/security/role_management_model/src/__fixtures__/kibana_features.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts rename to x-pack/packages/security/role_management_model/src/__fixtures__/kibana_features.ts diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts b/x-pack/packages/security/role_management_model/src/__fixtures__/kibana_privileges.ts similarity index 54% rename from x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts rename to x-pack/packages/security/role_management_model/src/__fixtures__/kibana_privileges.ts index 559d479182c89..2dc5078038033 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts +++ b/x-pack/packages/security/role_management_model/src/__fixtures__/kibana_privileges.ts @@ -6,19 +6,33 @@ */ import type { KibanaFeature } from '@kbn/features-plugin/public'; -import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; +import { type FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import { + featurePrivilegeIterator, + subFeaturePrivilegeIterator, +} from '@kbn/features-plugin/server/feature_privilege_iterator'; import type { LicenseType } from '@kbn/licensing-plugin/server'; +import type { SecurityLicenseFeatures } from '@kbn/security-plugin-types-common'; +import { Actions, privilegesFactory } from '@kbn/security-authorization-core'; +import { KibanaPrivileges } from '../kibana_privileges'; -import type { SecurityLicenseFeatures } from '../../../../common'; -import { Actions } from '../../../../server/authorization'; -import { privilegesFactory } from '../../../../server/authorization/privileges'; -import { KibanaPrivileges } from '../model'; +const featuresPluginService = (): jest.Mocked => { + return { + getKibanaFeatures: jest.fn(), + getElasticsearchFeatures: jest.fn(), + registerKibanaFeature: jest.fn(), + registerElasticsearchFeature: jest.fn(), + enableReportingUiCapabilities: jest.fn(), + featurePrivilegeIterator: jest.fn().mockImplementation(featurePrivilegeIterator), + subFeaturePrivilegeIterator: jest.fn().mockImplementation(subFeaturePrivilegeIterator), + }; +}; export const createRawKibanaPrivileges = ( features: KibanaFeature[], { allowSubFeaturePrivileges = true } = {} ) => { - const featuresService = featuresPluginMock.createSetup(); + const featuresService = featuresPluginService(); featuresService.getKibanaFeatures.mockReturnValue(features); const licensingService = { diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privilege.ts b/x-pack/packages/security/role_management_model/src/kibana_privilege.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/kibana_privilege.ts rename to x-pack/packages/security/role_management_model/src/kibana_privilege.ts diff --git a/x-pack/packages/security/role_management_model/src/kibana_privileges.test.ts b/x-pack/packages/security/role_management_model/src/kibana_privileges.test.ts new file mode 100644 index 0000000000000..6102c853db51b --- /dev/null +++ b/x-pack/packages/security/role_management_model/src/kibana_privileges.test.ts @@ -0,0 +1,187 @@ +/* + * 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 { KibanaPrivilege } from './kibana_privilege'; +import { KibanaPrivileges, isGlobalPrivilegeDefinition } from './kibana_privileges'; +import type { RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import { createRawKibanaPrivileges, kibanaFeatures } from './__fixtures__'; + +describe('kibana_privilege', () => { + describe('isGlobalPrivilegeDefinition', () => { + it('returns true if no spaces are defined', () => { + expect( + // @ts-ignore + isGlobalPrivilegeDefinition({ + base: [], + feature: {}, + }) + ).toEqual(true); + }); + + it('returns true if spaces is an empty array', () => { + expect( + isGlobalPrivilegeDefinition({ + spaces: [], + base: [], + feature: {}, + }) + ).toEqual(true); + }); + + it('returns true if spaces contains "*"', () => { + expect( + isGlobalPrivilegeDefinition({ + spaces: ['*'], + base: [], + feature: {}, + }) + ).toEqual(true); + }); + + it('returns false if spaces does not contain "*"', () => { + expect( + isGlobalPrivilegeDefinition({ + spaces: ['foo', 'bar'], + base: [], + feature: {}, + }) + ).toEqual(false); + }); + }); + + describe('KibanaPrivileges', () => { + describe('#getBasePrivileges', () => { + it('returns the space base privileges for a non-global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['foo'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.space; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + + it('returns the global base privileges for a global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['*'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.global; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + }); + + describe('#createCollectionFromRoleKibanaPrivileges', () => { + it('creates a collection from a role with no privileges assigned', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = []; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection ignoring unknown privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read', 'some-unknown-base-privilege'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all', 'some-unknown-feature-privilege'], + some_unknown_feature: ['all'], + }, + spaces: ['foo'], + }, + ]; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection using all assigned privileges, and only the assigned privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]; + const collection = + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.read]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.all]) + ) + ).toEqual(false); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_all]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_toggle_1]) + ) + ).toEqual(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/packages/security/role_management_model/src/kibana_privileges.ts similarity index 86% rename from x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts rename to x-pack/packages/security/role_management_model/src/kibana_privileges.ts index 78b312c123a3f..e78ee9b105bbf 100644 --- a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts +++ b/x-pack/packages/security/role_management_model/src/kibana_privileges.ts @@ -7,11 +7,11 @@ import type { KibanaFeature } from '@kbn/features-plugin/common'; +import type { RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; import { KibanaPrivilege } from './kibana_privilege'; import { PrivilegeCollection } from './privilege_collection'; import { SecuredFeature } from './secured_feature'; -import type { RawKibanaPrivileges, RoleKibanaPrivilege } from '../../../../common'; -import { isGlobalPrivilegeDefinition } from '../edit_role/privilege_utils'; function toBasePrivilege(entry: [string, string[]]): [string, KibanaPrivilege] { const [privilegeId, actions] = entry; @@ -24,6 +24,17 @@ function recordsToBasePrivilegeMap( return new Map(Object.entries(record).map((entry) => toBasePrivilege(entry))); } +/** + * Determines if the passed privilege spec defines global privileges. + * @param privilegeSpec + */ +export function isGlobalPrivilegeDefinition(privilegeSpec: RoleKibanaPrivilege): boolean { + if (!privilegeSpec.spaces || privilegeSpec.spaces.length === 0) { + return true; + } + return privilegeSpec.spaces.includes('*'); +} + export class KibanaPrivileges { private global: ReadonlyMap; diff --git a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts b/x-pack/packages/security/role_management_model/src/primary_feature_privilege.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts rename to x-pack/packages/security/role_management_model/src/primary_feature_privilege.ts diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts b/x-pack/packages/security/role_management_model/src/privilege_collection.test.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts rename to x-pack/packages/security/role_management_model/src/privilege_collection.test.ts diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts b/x-pack/packages/security/role_management_model/src/privilege_collection.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/privilege_collection.ts rename to x-pack/packages/security/role_management_model/src/privilege_collection.ts diff --git a/x-pack/plugins/security/public/management/roles/model/secured_feature.ts b/x-pack/packages/security/role_management_model/src/secured_feature.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/secured_feature.ts rename to x-pack/packages/security/role_management_model/src/secured_feature.ts diff --git a/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts b/x-pack/packages/security/role_management_model/src/secured_sub_feature.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts rename to x-pack/packages/security/role_management_model/src/secured_sub_feature.ts diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts b/x-pack/packages/security/role_management_model/src/sub_feature_privilege.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts rename to x-pack/packages/security/role_management_model/src/sub_feature_privilege.ts diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts b/x-pack/packages/security/role_management_model/src/sub_feature_privilege_group.ts similarity index 100% rename from x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts rename to x-pack/packages/security/role_management_model/src/sub_feature_privilege_group.ts diff --git a/x-pack/packages/security/role_management_model/tsconfig.json b/x-pack/packages/security/role_management_model/tsconfig.json new file mode 100644 index 0000000000000..f18ed64fae713 --- /dev/null +++ b/x-pack/packages/security/role_management_model/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": ["jest", "node", "react"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/features-plugin", + "@kbn/security-plugin-types-common", + "@kbn/security-authorization-core", + "@kbn/licensing-plugin", + ] +} diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index 2d5e6fd6ec7f1..c4d76f7c9fd66 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -10,7 +10,6 @@ export type { GetUserDisplayNameParams, EditUser, BuiltinESPrivileges, - RawKibanaPrivileges, RoleMapping, RoleMappingRule, RoleMappingAllRule, @@ -25,6 +24,8 @@ export type { export { getUserDisplayName, isRoleReserved, isRoleWithWildcardBasePrivilege } from './model'; +export type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; + // Re-export types from the plugin directly to enhance the developer experience for consumers of the Security plugin. export type { AuthenticatedUser, diff --git a/x-pack/plugins/security/common/licensing/index.mock.ts b/x-pack/plugins/security/common/licensing/index.mock.ts index 6ee9910b768bd..49f0b578075b0 100644 --- a/x-pack/plugins/security/common/licensing/index.mock.ts +++ b/x-pack/plugins/security/common/licensing/index.mock.ts @@ -5,51 +5,4 @@ * 2.0. */ -import { Observable, of } from 'rxjs'; - -import type { LicenseType } from '@kbn/licensing-plugin/common/types'; -import { LICENSE_TYPE } from '@kbn/licensing-plugin/common/types'; -import type { SecurityLicense, SecurityLicenseFeatures } from '@kbn/security-plugin-types-common'; - -export const licenseMock = { - create: ( - features: Partial | Observable> = {}, - licenseType: LicenseType = 'basic', // default to basic if this is not specified, - isAvailable: Observable = of(true) - ): jest.Mocked => ({ - isLicenseAvailable: jest.fn().mockImplementation(() => { - let result = true; - - isAvailable.subscribe((next) => { - result = next; - }); - - return result; - }), - getLicenseType: jest.fn().mockReturnValue(licenseType), - getUnavailableReason: jest.fn(), - isEnabled: jest.fn().mockReturnValue(true), - getFeatures: - features instanceof Observable - ? jest.fn().mockImplementation(() => { - let subbedFeatures: Partial = {}; - - features.subscribe((next) => { - subbedFeatures = next; - }); - - return subbedFeatures; - }) - : jest.fn().mockReturnValue(features), - hasAtLeast: jest - .fn() - .mockImplementation( - (licenseTypeToCheck: LicenseType) => - LICENSE_TYPE[licenseTypeToCheck] <= LICENSE_TYPE[licenseType] - ), - features$: - features instanceof Observable - ? (features as Observable) - : of((features ?? {}) as SecurityLicenseFeatures), - }), -}; +export { licenseMock } from '@kbn/security-authorization-core/src/__fixtures__/licensing.mock'; diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 1e73ead22655e..1331d60d624b6 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -21,7 +21,10 @@ export { } from './authenticated_user'; export { shouldProviderUseLoginForm } from './authentication_provider'; export type { BuiltinESPrivileges } from './builtin_es_privileges'; -export type { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; +export type { + RawKibanaPrivileges, + RawKibanaFeaturePrivileges, +} from '@kbn/security-authorization-core'; export { copyRole, isRoleDeprecated, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 5f345020d6d8f..9a9abab064fa8 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -19,6 +19,7 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { KibanaFeature } from '@kbn/features-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { REMOTE_CLUSTERS_PATH } from '@kbn/remote-clusters-plugin/public'; +import { createRawKibanaPrivileges } from '@kbn/security-role-management-model/src/__fixtures__'; import type { Space } from '@kbn/spaces-plugin/public'; import { spacesManagerMock } from '@kbn/spaces-plugin/public/spaces_manager/mocks'; import { getUiApi } from '@kbn/spaces-plugin/public/ui_api'; @@ -31,7 +32,6 @@ import { TransformErrorSection } from './privileges/kibana/transform_error_secti import type { Role } from '../../../../common'; import { licenseMock } from '../../../../common/licensing/index.mock'; import { userAPIClientMock } from '../../users/index.mock'; -import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; import { indicesAPIClientMock, privilegesAPIClientMock, rolesAPIClientMock } from '../index.mock'; const spacesManager = spacesManagerMock.create(); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index ccdc71d119f08..b724acc58f507 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -44,6 +44,7 @@ import { reactRouterNavigate, useDarkMode } from '@kbn/kibana-react-plugin/publi import { toMountPoint } from '@kbn/react-kibana-mount'; import type { Cluster } from '@kbn/remote-clusters-plugin/public'; import { REMOTE_CLUSTERS_PATH } from '@kbn/remote-clusters-plugin/public'; +import { KibanaPrivileges } from '@kbn/security-role-management-model'; import type { Space, SpacesApiUi } from '@kbn/spaces-plugin/public'; import type { PublicMethodsOf } from '@kbn/utility-types'; @@ -72,7 +73,6 @@ import { useCapabilities } from '../../../components/use_capabilities'; import type { CheckSecurityFeaturesResponse } from '../../security_features'; import type { UserAPIClient } from '../../users'; import type { IndicesAPIClient } from '../indices_api_client'; -import { KibanaPrivileges } from '../model'; import type { PrivilegesAPIClient } from '../privileges_api_client'; import type { RolesAPIClient } from '../roles_api_client'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.test.ts deleted file mode 100644 index 7ddbb393bac9f..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.test.ts +++ /dev/null @@ -1,50 +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 { isGlobalPrivilegeDefinition } from './privilege_utils'; - -describe('isGlobalPrivilegeDefinition', () => { - it('returns true if no spaces are defined', () => { - expect( - // @ts-ignore - isGlobalPrivilegeDefinition({ - base: [], - feature: {}, - }) - ).toEqual(true); - }); - - it('returns true if spaces is an empty array', () => { - expect( - isGlobalPrivilegeDefinition({ - spaces: [], - base: [], - feature: {}, - }) - ).toEqual(true); - }); - - it('returns true if spaces contains "*"', () => { - expect( - isGlobalPrivilegeDefinition({ - spaces: ['*'], - base: [], - feature: {}, - }) - ).toEqual(true); - }); - - it('returns false if spaces does not contain "*"', () => { - expect( - isGlobalPrivilegeDefinition({ - spaces: ['foo', 'bar'], - base: [], - feature: {}, - }) - ).toEqual(false); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts deleted file mode 100644 index da912650fee48..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts +++ /dev/null @@ -1,19 +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 type { RoleKibanaPrivilege } from '../../../../common'; - -/** - * Determines if the passed privilege spec defines global privileges. - * @param privilegeSpec - */ -export function isGlobalPrivilegeDefinition(privilegeSpec: RoleKibanaPrivilege): boolean { - if (!privilegeSpec.spaces || privilegeSpec.spaces.length === 0) { - return true; - } - return privilegeSpec.spaces.includes('*'); -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx index 00494c48b9efb..4793f86a7a2a5 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx @@ -19,8 +19,8 @@ import _ from 'lodash'; import React, { Component } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { KibanaPrivilege } from '@kbn/security-role-management-model'; -import type { KibanaPrivilege } from '../../../../model'; import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx index 8b40b6d16d403..5a43e7931d474 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx @@ -9,13 +9,16 @@ import { EuiAccordion, EuiIconTip } from '@elastic/eui'; import React from 'react'; import type { KibanaFeature, SubFeatureConfig } from '@kbn/features-plugin/public'; +import { + createFeature, + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { getDisplayedFeaturePrivileges } from './__fixtures__'; import { FeatureTable } from './feature_table'; import type { Role } from '../../../../../../../common'; -import { createFeature, kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; const createRole = (kibana: Role['kibana'] = []): Role => { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx index 7734d415bf385..6b4e7af240eb5 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx @@ -29,11 +29,11 @@ import React, { Component } from 'react'; import type { AppCategory } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import type { KibanaPrivileges, SecuredFeature } from '@kbn/security-role-management-model'; import { ChangeAllPrivilegesControl } from './change_all_privileges'; import { FeatureTableExpandedRow } from './feature_table_expanded_row'; -import type { Role } from '../../../../../../../common'; -import type { KibanaPrivileges, SecuredFeature } from '../../../../model'; import { NO_PRIVILEGE_VALUE } from '../constants'; import { FeatureTableCell } from '../feature_table_cell'; import type { PrivilegeFormCalculator } from '../privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx index b3856bb59f1f3..92a33136c7678 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx @@ -8,12 +8,14 @@ import { act } from '@testing-library/react'; import React from 'react'; +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { FeatureTableExpandedRow } from './feature_table_expanded_row'; import type { Role } from '../../../../../../../common'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; const createRole = (kibana: Role['kibana'] = []): Role => { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx index 42090f8c6c044..8e00327fd334b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx @@ -11,9 +11,9 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { SecuredFeature } from '@kbn/security-role-management-model'; import { SubFeatureForm } from './sub_feature_form'; -import type { SecuredFeature } from '../../../../model'; import type { PrivilegeFormCalculator } from '../privilege_form_calculator'; interface Props { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx index 53e44aefbf1c8..8f741f1d48f9d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx @@ -10,13 +10,15 @@ import { act } from '@testing-library/react'; import React from 'react'; import { KibanaFeature } from '@kbn/features-plugin/public'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { SecuredSubFeature } from '@kbn/security-role-management-model'; +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SubFeatureForm } from './sub_feature_form'; -import type { Role } from '../../../../../../../common'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; -import { SecuredSubFeature } from '../../../../model'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; // Note: these tests are not concerned with the proper display of privileges, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx index 4f3c1eb103a75..9155d8ae52835 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx @@ -16,12 +16,12 @@ import { import React from 'react'; import { i18n } from '@kbn/i18n'; - import type { SecuredSubFeature, SubFeaturePrivilege, SubFeaturePrivilegeGroup, -} from '../../../../model'; +} from '@kbn/security-role-management-model'; + import { NO_PRIVILEGE_VALUE } from '../constants'; import type { PrivilegeFormCalculator } from '../privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx index 372b24048fe5b..0c1eac9a70d4e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx @@ -8,11 +8,11 @@ import { EuiIconTip } from '@elastic/eui'; import React from 'react'; +import { SecuredFeature } from '@kbn/security-role-management-model'; +import { createFeature } from '@kbn/security-role-management-model/src/__fixtures__'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { FeatureTableCell } from './feature_table_cell'; -import { createFeature } from '../../../../__fixtures__/kibana_features'; -import { SecuredFeature } from '../../../../model'; describe('FeatureTableCell', () => { it('renders the feature name', () => { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx index 062597ce46ad2..177b6fb95a413 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx @@ -10,7 +10,7 @@ import './feature_table_cell.scss'; import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui'; import React from 'react'; -import type { SecuredFeature } from '../../../../model'; +import type { SecuredFeature } from '@kbn/security-role-management-model'; interface Props { feature: SecuredFeature; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx index b12c4f91a3a7a..2c903b170cb2b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx @@ -9,6 +9,8 @@ import { shallow } from 'enzyme'; import React from 'react'; import { coreMock } from '@kbn/core/public/mocks'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { KibanaPrivileges } from '@kbn/security-role-management-model'; import { spacesManagerMock } from '@kbn/spaces-plugin/public/spaces_manager/mocks'; import { getUiApi } from '@kbn/spaces-plugin/public/ui_api'; @@ -16,8 +18,6 @@ import { KibanaPrivilegesRegion } from './kibana_privileges_region'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; -import type { Role } from '../../../../../../common'; -import { KibanaPrivileges } from '../../../model'; import { RoleValidator } from '../../validate_role'; const spacesManager = spacesManagerMock.create(); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx index d7439b19b0d00..5344e582a3b8c 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx @@ -8,13 +8,13 @@ import React, { Component } from 'react'; import type { Capabilities } from '@kbn/core/public'; +import type { KibanaPrivileges } from '@kbn/security-role-management-model'; import type { Space, SpacesApiUi } from '@kbn/spaces-plugin/public'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; import type { Role } from '../../../../../../common'; -import type { KibanaPrivileges } from '../../../model'; import { CollapsiblePanel } from '../../collapsible_panel'; import type { RoleValidator } from '../../validate_role'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts index 20c54fd2ea529..b47501e08f376 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts @@ -5,10 +5,13 @@ * 2.0. */ +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; + import { PrivilegeFormCalculator } from './privilege_form_calculator'; import type { Role } from '../../../../../../../common'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; const createRole = (kibana: Role['kibana'] = []): Role => { return { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts index 227c2be381546..75cdcac34031e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts @@ -5,9 +5,12 @@ * 2.0. */ -import type { Role } from '../../../../../../../common'; -import type { KibanaPrivileges, SubFeaturePrivilegeGroup } from '../../../../model'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { + isGlobalPrivilegeDefinition, + type KibanaPrivileges, + type SubFeaturePrivilegeGroup, +} from '@kbn/security-role-management-model'; /** * Calculator responsible for determining the displayed and effective privilege values for the following interfaces: diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx index 9f6aa8ed69ed9..f42a95693b87b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx @@ -9,6 +9,10 @@ import { act } from '@testing-library/react'; import React from 'react'; import { coreMock } from '@kbn/core/public/mocks'; +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import { spacesManagerMock } from '@kbn/spaces-plugin/public/spaces_manager/mocks'; import { getUiApi } from '@kbn/spaces-plugin/public/ui_api'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; @@ -16,8 +20,6 @@ import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { PrivilegeSummary } from './privilege_summary'; import { PrivilegeSummaryTable } from './privilege_summary_table'; import type { RoleKibanaPrivilege } from '../../../../../../../common'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ name: 'some-role', diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx index 5c6d03569b10a..1ae88a0be781d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx @@ -17,11 +17,11 @@ import { import React, { Fragment, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import type { KibanaPrivileges } from '@kbn/security-role-management-model'; import type { Space, SpacesApiUi } from '@kbn/spaces-plugin/public'; import { PrivilegeSummaryTable } from './privilege_summary_table'; -import type { Role } from '../../../../../../../common'; -import type { KibanaPrivileges } from '../../../../model'; interface Props { role: Role; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts index b0418eac51c62..13269dffa5e8c 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts @@ -5,10 +5,13 @@ * 2.0. */ +import type { Role } from '@kbn/security-plugin-types-common'; +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; + import { PrivilegeSummaryCalculator } from './privilege_summary_calculator'; -import type { Role } from '../../../../../../../common'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; const createRole = (kibana: Role['kibana'] = []): Role => { return { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts index 053cd19c98d58..ce8e2fa0e22c4 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts @@ -5,10 +5,14 @@ * 2.0. */ -import type { Role, RoleKibanaPrivilege } from '../../../../../../../common'; -import type { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; -import type { PrivilegeCollection } from '../../../../model/privilege_collection'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import type { Role, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import { + isGlobalPrivilegeDefinition, + type KibanaPrivileges, + type PrimaryFeaturePrivilege, + type PrivilegeCollection, + type SecuredFeature, +} from '@kbn/security-role-management-model'; export interface EffectiveFeaturePrivileges { [featureId: string]: { @@ -17,6 +21,7 @@ export interface EffectiveFeaturePrivileges { hasCustomizedSubFeaturePrivileges: boolean; }; } + export class PrivilegeSummaryCalculator { constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx index 727bcdc1b103d..83f1e26ad1284 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx @@ -9,13 +9,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; - -import type { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; import type { SecuredFeature, SubFeaturePrivilege, SubFeaturePrivilegeGroup, -} from '../../../../model'; +} from '@kbn/security-role-management-model'; + +import type { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; interface Props { feature: SecuredFeature; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx index b76ac9f1a1fc8..e1ca5300ee9f7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx @@ -9,6 +9,11 @@ import { act } from '@testing-library/react'; import React from 'react'; import { coreMock } from '@kbn/core/public/mocks'; +import type { RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import { spacesManagerMock } from '@kbn/spaces-plugin/public/spaces_manager/mocks'; import { getUiApi } from '@kbn/spaces-plugin/public/ui_api'; import { mountWithIntl } from '@kbn/test-jest-helpers'; @@ -16,9 +21,6 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { getDisplayedFeaturePrivileges } from './__fixtures__'; import type { PrivilegeSummaryTableProps } from './privilege_summary_table'; import { PrivilegeSummaryTable } from './privilege_summary_table'; -import type { RoleKibanaPrivilege } from '../../../../../../../common'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ name: 'some-role', diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx index 7dcbbe85d553c..af15cf82b9be6 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx @@ -20,16 +20,20 @@ import { import React, { Fragment, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { Role, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import { + isGlobalPrivilegeDefinition, + type KibanaPrivileges, + type PrimaryFeaturePrivilege, + type SecuredFeature, +} from '@kbn/security-role-management-model'; import type { Space, SpacesApiUi } from '@kbn/spaces-plugin/public'; import type { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; import { PrivilegeSummaryCalculator } from './privilege_summary_calculator'; import { PrivilegeSummaryExpandedRow } from './privilege_summary_expanded_row'; import { SpaceColumnHeader } from './space_column_header'; -import type { Role, RoleKibanaPrivilege } from '../../../../../../../common'; import { ALL_SPACES_ID } from '../../../../../../../common/constants'; -import type { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { FeatureTableCell } from '../feature_table_cell'; export interface PrivilegeSummaryTableProps { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx index ca4a2d6011c58..e65b7b255efe6 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx @@ -9,10 +9,10 @@ import React, { Fragment, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import { isGlobalPrivilegeDefinition } from '@kbn/security-role-management-model'; import type { Space, SpacesApiUi } from '@kbn/spaces-plugin/public'; -import type { RoleKibanaPrivilege } from '../../../../../../../common'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { SpacesPopoverList } from '../../../spaces_popover_list'; export interface SpaceColumnHeaderProps { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx index e0b0156db7568..3ca7cf5c8b92f 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx @@ -9,12 +9,12 @@ import type { EuiButtonGroupProps } from '@elastic/eui'; import { EuiButtonGroup, EuiComboBox, EuiSuperSelect } from '@elastic/eui'; import React from 'react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { KibanaPrivileges, SecuredFeature } from '@kbn/security-role-management-model'; import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; -import type { Role } from '../../../../../../../common'; -import { KibanaPrivileges, SecuredFeature } from '../../../../model'; const buildProps = (customProps: any = {}) => { const features = [ diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx index 2e8b395ea07a7..b5b57921705e5 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx @@ -16,12 +16,14 @@ import { import React, { Component, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { + isGlobalPrivilegeDefinition, + type KibanaPrivileges, +} from '@kbn/security-role-management-model'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; import type { Role, RoleKibanaPrivilege } from '../../../../../../../common'; import { copyRole } from '../../../../../../../common/model'; -import type { KibanaPrivileges } from '../../../../model'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; import { FeatureTable } from '../feature_table'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index 6c633d6513692..7d9d6d015c03e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -8,14 +8,17 @@ import { EuiButtonGroup } from '@elastic/eui'; import React from 'react'; +import { + createFeature, + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import type { Space } from '@kbn/spaces-plugin/public'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { PrivilegeSpaceForm } from './privilege_space_form'; import { SpaceSelector } from './space_selector'; import type { Role } from '../../../../../../../common'; -import { createFeature, kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { FeatureTable } from '../feature_table'; import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index 6abf5a04ae5c6..fbcc43a3b4b1a 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -29,13 +29,13 @@ import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { KibanaPrivileges } from '@kbn/security-role-management-model'; import type { Space } from '@kbn/spaces-plugin/public'; import { SpaceSelector } from './space_selector'; import type { FeaturesPrivileges, Role } from '../../../../../../../common'; import { ALL_SPACES_ID } from '../../../../../../../common/constants'; import { copyRole } from '../../../../../../../common/model'; -import type { KibanaPrivileges } from '../../../../model'; import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; import { FeatureTable } from '../feature_table'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index 56fe843cceded..316419f479426 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -10,12 +10,12 @@ import type { ReactWrapper } from 'enzyme'; import React from 'react'; import { KibanaFeature } from '@kbn/features-plugin/public'; +import type { Role, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import { createKibanaPrivileges } from '@kbn/security-role-management-model/src/__fixtures__'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { PrivilegeDisplay } from './privilege_display'; import { PrivilegeSpaceTable } from './privilege_space_table'; -import type { Role, RoleKibanaPrivilege } from '../../../../../../../common'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; interface TableRow { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx index 4c0ead6e43167..28e1d50d7f71e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx @@ -22,13 +22,13 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { FeaturesPrivileges, Role } from '@kbn/security-plugin-types-common'; +import { isGlobalPrivilegeDefinition } from '@kbn/security-role-management-model'; import type { Space } from '@kbn/spaces-plugin/public'; import { getSpaceColor } from '@kbn/spaces-plugin/public'; import { PrivilegeDisplay } from './privilege_display'; -import type { FeaturesPrivileges, Role } from '../../../../../../../common'; import { copyRole } from '../../../../../../../common/model'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; import type { PrivilegeFormCalculator } from '../privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx index 3c2df19eb20db..b25a474bc06aa 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx @@ -7,13 +7,15 @@ import React from 'react'; +import { + createKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; -import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { RoleValidator } from '../../../validate_role'; import { PrivilegeSummary } from '../privilege_summary'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index f499da5c6973c..404bd39ec9b67 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -20,13 +20,13 @@ import React, { Component, Fragment } from 'react'; import type { Capabilities } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import type { KibanaPrivileges } from '@kbn/security-role-management-model'; import type { Space, SpacesApiUi } from '@kbn/spaces-plugin/public'; import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; -import type { Role } from '../../../../../../../common'; import { isRoleReserved, isRoleWithWildcardBasePrivilege } from '../../../../../../../common'; -import type { KibanaPrivileges } from '../../../../model'; import type { RoleValidator } from '../../../validate_role'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; import { PrivilegeSummary } from '../privilege_summary'; diff --git a/x-pack/plugins/security/public/management/roles/model/index.ts b/x-pack/plugins/security/public/management/roles/model/index.ts deleted file mode 100644 index 55e90bb4b377d..0000000000000 --- a/x-pack/plugins/security/public/management/roles/model/index.ts +++ /dev/null @@ -1,15 +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. - */ - -export { SecuredFeature } from './secured_feature'; -export { SecuredSubFeature } from './secured_sub_feature'; -export { SubFeaturePrivilegeGroup } from './sub_feature_privilege_group'; -export { SubFeaturePrivilege } from './sub_feature_privilege'; -export { PrimaryFeaturePrivilege } from './primary_feature_privilege'; -export { KibanaPrivileges } from './kibana_privileges'; -export { KibanaPrivilege } from './kibana_privilege'; -export { PrivilegeCollection } from './privilege_collection'; diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts deleted file mode 100644 index 494f5a14b1e48..0000000000000 --- a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts +++ /dev/null @@ -1,144 +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 { KibanaPrivilege } from './kibana_privilege'; -import { KibanaPrivileges } from './kibana_privileges'; -import type { RoleKibanaPrivilege } from '../../../../common'; -import { kibanaFeatures } from '../__fixtures__/kibana_features'; -import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; - -describe('KibanaPrivileges', () => { - describe('#getBasePrivileges', () => { - it('returns the space base privileges for a non-global entry', () => { - const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); - const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); - - const entry: RoleKibanaPrivilege = { - base: [], - feature: {}, - spaces: ['foo'], - }; - - const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); - - const expectedPrivileges = rawPrivileges.space; - - expect(basePrivileges).toHaveLength(2); - expect(basePrivileges[0]).toMatchObject({ - id: 'all', - actions: expectedPrivileges.all, - }); - expect(basePrivileges[1]).toMatchObject({ - id: 'read', - actions: expectedPrivileges.read, - }); - }); - - it('returns the global base privileges for a global entry', () => { - const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); - const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); - - const entry: RoleKibanaPrivilege = { - base: [], - feature: {}, - spaces: ['*'], - }; - - const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); - - const expectedPrivileges = rawPrivileges.global; - - expect(basePrivileges).toHaveLength(2); - expect(basePrivileges[0]).toMatchObject({ - id: 'all', - actions: expectedPrivileges.all, - }); - expect(basePrivileges[1]).toMatchObject({ - id: 'read', - actions: expectedPrivileges.read, - }); - }); - }); - - describe('#createCollectionFromRoleKibanaPrivileges', () => { - it('creates a collection from a role with no privileges assigned', () => { - const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); - const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); - - const assignedPrivileges: RoleKibanaPrivilege[] = []; - kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); - }); - - it('creates a collection ignoring unknown privileges', () => { - const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); - const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); - - const assignedPrivileges: RoleKibanaPrivilege[] = [ - { - base: ['read', 'some-unknown-base-privilege'], - feature: {}, - spaces: ['*'], - }, - { - base: [], - feature: { - with_sub_features: ['read', 'cool_all', 'some-unknown-feature-privilege'], - some_unknown_feature: ['all'], - }, - spaces: ['foo'], - }, - ]; - kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); - }); - - it('creates a collection using all assigned privileges, and only the assigned privileges', () => { - const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); - const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); - - const assignedPrivileges: RoleKibanaPrivilege[] = [ - { - base: ['read'], - feature: {}, - spaces: ['*'], - }, - { - base: [], - feature: { - with_sub_features: ['read', 'cool_all'], - }, - spaces: ['foo'], - }, - ]; - const collection = - kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); - - expect( - collection.grantsPrivilege( - new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.read]) - ) - ).toEqual(true); - - expect( - collection.grantsPrivilege( - new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.all]) - ) - ).toEqual(false); - - expect( - collection.grantsPrivilege( - new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_all]) - ) - ).toEqual(true); - - expect( - collection.grantsPrivilege( - new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_toggle_1]) - ) - ).toEqual(false); - }); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/privileges_api_client.ts b/x-pack/plugins/security/public/management/roles/privileges_api_client.ts index a96fdd4340cc6..54c8992698978 100644 --- a/x-pack/plugins/security/public/management/roles/privileges_api_client.ts +++ b/x-pack/plugins/security/public/management/roles/privileges_api_client.ts @@ -6,8 +6,9 @@ */ import type { HttpStart } from '@kbn/core/public'; +import type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; -import type { BuiltinESPrivileges, RawKibanaPrivileges } from '../../../common/model'; +import type { BuiltinESPrivileges } from '../../../common/model'; export class PrivilegesAPIClient { constructor(private readonly http: HttpStart) {} diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index ddc5e26903c2b..275a6d2643f24 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -19,6 +19,7 @@ import { Subject } from 'rxjs'; import { coreMock, elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; +import { privilegesFactory } from '@kbn/security-authorization-core'; import { nextTick } from '@kbn/test-jest-helpers'; import { AuthorizationService } from './authorization_service'; @@ -26,7 +27,6 @@ import { checkPrivilegesFactory } from './check_privileges'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; import { authorizationModeFactory } from './mode'; -import { privilegesFactory } from './privileges'; import { licenseMock } from '../../common/licensing/index.mock'; import type { OnlineStatusRetryScheduler } from '../elasticsearch'; diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx index a926ee4d364b0..c8e036b07679c 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.tsx +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -25,6 +25,11 @@ import type { FeaturesPluginSetup as FeaturesPluginSetup, FeaturesPluginStart as FeaturesPluginStart, } from '@kbn/features-plugin/server'; +import { + Actions, + privilegesFactory, + type PrivilegesService, +} from '@kbn/security-authorization-core'; import type { AuthorizationMode, AuthorizationServiceSetup, @@ -33,7 +38,6 @@ import type { CheckUserProfilesPrivileges, } from '@kbn/security-plugin-types-server'; -import { Actions } from './actions'; import { initAPIAuthorization } from './api_authorization'; import { initAppAuthorization } from './app_authorization'; import { checkPrivilegesFactory } from './check_privileges'; @@ -41,8 +45,6 @@ import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { authorizationModeFactory } from './mode'; -import type { PrivilegesService } from './privileges'; -import { privilegesFactory } from './privileges'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; import { ResetSessionPage } from './reset_session_page'; import { validateFeaturePrivileges } from './validate_feature_privileges'; @@ -53,7 +55,7 @@ import { canRedirectRequest } from '../authentication'; import type { OnlineStatusRetryScheduler } from '../elasticsearch'; import type { SpacesService } from '../plugin'; -export { Actions } from './actions'; +export { Actions } from '@kbn/security-authorization-core'; interface AuthorizationServiceSetupParams { packageVersion: string; diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index a271371e5584d..f7ed4ac9cd94b 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -7,9 +7,9 @@ import { httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { ElasticsearchFeature, KibanaFeature } from '@kbn/features-plugin/server'; +import { Actions } from '@kbn/security-authorization-core'; import type { CheckPrivilegesResponse } from '@kbn/security-plugin-types-server'; -import { Actions } from './actions'; import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { authorizationMock } from './index.mock'; import type { AuthenticatedUser } from '../../common'; diff --git a/x-pack/plugins/security/server/authorization/index.mock.ts b/x-pack/plugins/security/server/authorization/index.mock.ts index 04c389f24fcad..c3b76a0908f13 100644 --- a/x-pack/plugins/security/server/authorization/index.mock.ts +++ b/x-pack/plugins/security/server/authorization/index.mock.ts @@ -5,10 +5,9 @@ * 2.0. */ +import { actionsMock } from '@kbn/security-authorization-core/src/actions/actions.mock'; import type { AuthorizationMode } from '@kbn/security-plugin-types-server'; -import { actionsMock } from './actions/actions.mock'; - export const authorizationMock = { create: ({ version = 'mock-version', diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 0ebd085ba0e42..3552f85c005dd 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -5,9 +5,8 @@ * 2.0. */ -export { Actions } from './actions'; +export { Actions, type CasesSupportedOperations } from '@kbn/security-authorization-core'; export type { AuthorizationServiceSetupInternal } from './authorization_service'; export { AuthorizationService } from './authorization_service'; export type { ElasticsearchRole } from './roles'; export { transformElasticsearchRoleToRole, compareRolesByName } from './roles'; -export type { CasesSupportedOperations } from './privileges'; diff --git a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts index b8fb5f83aadcf..0809626eaf718 100644 --- a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts @@ -8,8 +8,8 @@ import { difference, isEqual, isEqualWith } from 'lodash'; import type { IClusterClient, Logger } from '@kbn/core/server'; +import type { PrivilegesService } from '@kbn/security-authorization-core'; -import type { PrivilegesService } from './privileges'; import { serializePrivileges } from './privileges_serializer'; export async function registerPrivilegesWithCluster( diff --git a/x-pack/plugins/security/server/authorization/service.test.mocks.ts b/x-pack/plugins/security/server/authorization/service.test.mocks.ts index 7fb0908e60cab..d5cbc3375aae2 100644 --- a/x-pack/plugins/security/server/authorization/service.test.mocks.ts +++ b/x-pack/plugins/security/server/authorization/service.test.mocks.ts @@ -21,9 +21,13 @@ jest.mock('./check_saved_objects_privileges', () => ({ })); export const mockPrivilegesFactory = jest.fn(); -jest.mock('./privileges', () => ({ - privilegesFactory: mockPrivilegesFactory, -})); +jest.mock('@kbn/security-authorization-core', () => { + const authzCore = jest.requireActual('@kbn/security-authorization-core'); + return { + ...authzCore, + privilegesFactory: mockPrivilegesFactory, + }; +}); export const mockAuthorizationModeFactory = jest.fn(); jest.mock('./mode', () => ({ diff --git a/x-pack/plugins/security/tsconfig.json b/x-pack/plugins/security/tsconfig.json index 10c1ada6ede15..8e3f38833248d 100644 --- a/x-pack/plugins/security/tsconfig.json +++ b/x-pack/plugins/security/tsconfig.json @@ -83,7 +83,9 @@ "@kbn/core-user-profile-browser", "@kbn/security-api-key-management", "@kbn/security-form-components", - "@kbn/core-security-server-mocks" + "@kbn/core-security-server-mocks", + "@kbn/security-authorization-core", + "@kbn/security-role-management-model", ], "exclude": [ "target/**/*", diff --git a/yarn.lock b/yarn.lock index 9a876f07ecb25..549fd4e2e86bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6276,6 +6276,10 @@ version "0.0.0" uid "" +"@kbn/security-authorization-core@link:x-pack/packages/security/authorization_core": + version "0.0.0" + uid "" + "@kbn/security-form-components@link:x-pack/packages/security/form_components": version "0.0.0" uid "" @@ -6300,6 +6304,10 @@ version "0.0.0" uid "" +"@kbn/security-role-management-model@link:x-pack/packages/security/role_management_model": + version "0.0.0" + uid "" + "@kbn/security-solution-distribution-bar@link:x-pack/packages/security-solution/distribution_bar": version "0.0.0" uid "" From e78281187774d3974a4394bfb2a82aa9fcac435e Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 22 Aug 2024 16:46:37 +0300 Subject: [PATCH 37/45] fix: [Obs Inventory][KEYBOARD]: The map view tooltips must be available on keyboard focus (#187861) Closes: https://github.com/elastic/observability-accessibility/issues/43 Closes: https://github.com/elastic/observability-accessibility/issues/18 ## Description - https://github.com/elastic/observability-accessibility/issues/43: The Observability Inventory map view has a grid of map tiles that each accept a mouse event to show a tooltip. This tooltip must also be available when the tile receives keyboard focus. - https://github.com/elastic/observability-accessibility/issues/18: The Inventory > Kubernetes Pods popover stays open when I click "Create Inventory Rule" and the modal dialog opens. This creates an odd stacking order and obscures content for users at smaller viewport width. ## Steps to recreate 1. Open [Inventory Hosts map view](https://keep-serverless-fyzdg-f07c50.kb.eu-west-1.aws.qa.elastic.cloud/app/metrics/inventory?inventoryViewId=%270%27&waffleFilter=(expression:%27%27,kind:kuery)&waffleTime=(currentTime:1719523489979,isAutoReloading:!f)&waffleOptions=(accountId:%27%27,autoBounds:!t,boundsOverride:(max:1,min:0),customMetrics:!(),customOptions:!(),groupBy:!(),legend:(palette:cool,reverseColors:!f,steps:10),metric:(type:cpu),nodeType:host,region:%27%27,sort:(by:name,direction:desc),timelineOpen:!f,view:map)&assetDetailsFlyout=(assetType:!n,detailsItemId:!n)&assetDetails=!n) 2. Change Hosts to Kubernetes Clusters 3. Try to select specific node using the keyboard ## Screens https://github.com/user-attachments/assets/9ab3b20d-1144-48ed-9760-363f43bafb4b https://github.com/user-attachments/assets/e41bba9f-f3c5-4ce7-bba4-98cf26a2137a --- .../inventory_view/components/table_view.tsx | 1 + .../inventory_view/components/waffle/node.tsx | 77 +++++++++---------- .../components/waffle/node_square.tsx | 18 ++--- 3 files changed, 41 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx index e4ab8c45cbf10..182c74c124fd9 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx @@ -82,6 +82,7 @@ export const TableView = (props: Props) => { isOpen={openPopoverId === uniqueID} closePopover={closePopover} anchorPosition="rightCenter" + zIndex={0} > { - const [isToolTipOpen, { off: hideToolTip, on: showToolTip }] = useBoolean(false); const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false); const metric = first(node.metrics); @@ -57,11 +56,11 @@ export const Node = ({ const value = formatter(rawValue); const isContainerAssetViewEnabled = useUiSetting(enableInfrastructureContainerAssetView); - const showContainerAssetDetailPage = nodeType === 'container' && isContainerAssetViewEnabled; + const isFlyoutMode = nodeType === 'host' || showContainerAssetDetailPage; const toggleAssetPopover = () => { - if (nodeType === 'host' || showContainerAssetDetailPage) { + if (isFlyoutMode) { setFlyoutUrlState({ detailsItemId: node.id, assetType: nodeType }); } else { togglePopover(); @@ -69,46 +68,40 @@ export const Node = ({ }; const nodeSquare = ( - + } + > +
+ +
+
); - return ( - <> - {isPopoverOpen ? ( - - - - ) : isToolTipOpen ? ( - } - > - {nodeSquare} - - ) : ( - nodeSquare - )} - + return !isFlyoutMode ? ( + + + + ) : ( + nodeSquare ); }; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx index 7f11f4bbc192c..31d31fed64016 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx @@ -66,8 +66,8 @@ const NodeContainerSmall = ({ children, ...props }: NodeProps & { color: string {children}
); -const ValueInner = ({ children, ...props }: NodeProps) => ( - +
); const SquareOuter = ({ children, ...props }: NodeProps & { color: string }) => (
( export const NodeSquare = ({ squareSize, togglePopover, - showToolTip, - hideToolTip, color, nodeName, value, @@ -163,8 +162,6 @@ export const NodeSquare = ({ }: { squareSize: number; togglePopover: UseBooleanHandlers['toggle']; - showToolTip: () => void; - hideToolTip: () => void; color: string; nodeName: string; value: string; @@ -184,9 +181,6 @@ export const NodeSquare = ({ style={{ width: squareSize || 0, height: squareSize || 0 }} onClick={togglePopover} onKeyPress={togglePopover} - onFocus={showToolTip} - onMouseOver={showToolTip} - onMouseLeave={hideToolTip} className="buttonContainer" > @@ -217,10 +211,8 @@ export const NodeSquare = ({ style={{ width: squareSize || 0, height: squareSize || 0, ...style }} onClick={togglePopover} onKeyPress={togglePopover} - onMouseOver={showToolTip} - onFocus={showToolTip} - onMouseLeave={hideToolTip} color={color} + tabIndex={0} /> ); }; From 07717a43ab369847d87c8e15071759502a89c48b Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 22 Aug 2024 10:29:22 -0400 Subject: [PATCH 38/45] [ResponseOps][alerting] add rule info to logging in alertsClient (#190857) ## Summary While investigating some issues with the alertsClient, I realized that we weren't writing out any rule information for the logged messages. This made debugging quite difficult, as I wanted to see the rule, so had to search through the alerts indices for the specified alert to get it's rule id, rule type, etc. As an example, see https://github.com/elastic/kibana/issues/190376 This PR adds that kind of rule info to the logged messages in alertsClient, as well as the typical sort of tags we write out (rule id, rule type, module). --- .../alerts_client/alerts_client.test.ts | 24 ++-- .../server/alerts_client/alerts_client.ts | 35 ++++-- .../lib/alert_conflict_resolver.test.ts | 118 +++++++++++++++--- .../lib/alert_conflict_resolver.ts | 37 ++++-- 4 files changed, 172 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts index fcf151b1d0afd..4391fdce06c28 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts @@ -287,6 +287,9 @@ const defaultExecutionOpts = { startedAt: null, }; +const ruleInfo = `for test.rule-type:1 'rule-name'`; +const logTags = { tags: ['test.rule-type', '1', 'alerts-client'] }; + describe('Alerts Client', () => { let alertsClientParams: AlertsClientParams; let processAndLogAlertsOpts: ProcessAndLogAlertsOpts; @@ -484,7 +487,8 @@ describe('Alerts Client', () => { }); expect(logger.error).toHaveBeenCalledWith( - `Error searching for tracked alerts by UUID - search failed!` + `Error searching for tracked alerts by UUID ${ruleInfo} - search failed!`, + logTags ); spy.mockRestore(); @@ -778,7 +782,8 @@ describe('Alerts Client', () => { expect(spy).toHaveBeenNthCalledWith(2, 'recoveredCurrent'); expect(logger.error).toHaveBeenCalledWith( - "Error writing alert(2) to .alerts-test.alerts-default - alert(2) doesn't exist in active alerts" + `Error writing alert(2) to .alerts-test.alerts-default - alert(2) doesn't exist in active alerts ${ruleInfo}.`, + logTags ); spy.mockRestore(); @@ -1346,7 +1351,8 @@ describe('Alerts Client', () => { expect(clusterClient.bulk).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith( - `Error writing alerts: 1 successful, 0 conflicts, 2 errors: Validation Failed: 1: index is missing;2: type is missing;; failed to parse field [process.command_line] of type [wildcard] in document with id 'f0c9805be95fedbc3c99c663f7f02cc15826c122'.` + `Error writing alerts ${ruleInfo}: 1 successful, 0 conflicts, 2 errors: Validation Failed: 1: index is missing;2: type is missing;; failed to parse field [process.command_line] of type [wildcard] in document with id 'f0c9805be95fedbc3c99c663f7f02cc15826c122'.`, + { tags: ['test.rule-type', '1', 'resolve-alert-conflicts'] } ); }); @@ -1423,7 +1429,8 @@ describe('Alerts Client', () => { }); expect(logger.warn).toHaveBeenCalledWith( - `Could not update alert abc in partial-.internal.alerts-test.alerts-default-000001. Partial and restored alert indices are not supported.` + `Could not update alert abc in partial-.internal.alerts-test.alerts-default-000001. Partial and restored alert indices are not supported ${ruleInfo}.`, + logTags ); }); @@ -1448,7 +1455,8 @@ describe('Alerts Client', () => { expect(clusterClient.bulk).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith( - `Error writing 2 alerts to .alerts-test.alerts-default - fail` + `Error writing 2 alerts to .alerts-test.alerts-default ${ruleInfo} - fail`, + logTags ); }); @@ -1478,7 +1486,8 @@ describe('Alerts Client', () => { }); expect(logger.debug).toHaveBeenCalledWith( - `Resources registered and installed for test context but "shouldWrite" is set to false.` + `Resources registered and installed for test context but "shouldWrite" is set to false ${ruleInfo}.`, + logTags ); expect(clusterClient.bulk).not.toHaveBeenCalled(); }); @@ -2026,7 +2035,8 @@ describe('Alerts Client', () => { ).rejects.toBe('something went wrong!'); expect(logger.warn).toHaveBeenCalledWith( - 'Error updating alert maintenance window IDs: something went wrong!' + `Error updating alert maintenance window IDs for test.rule-type:1 'rule-name': something went wrong!`, + logTags ); }); }); diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index 162cdb3cd21fb..9926ea9ec9039 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -112,6 +112,8 @@ export class AlertsClient< private reportedAlerts: Record> = {}; private _isUsingDataStreams: boolean; + private ruleInfoMessage: string; + private logTags: { tags: string[] }; constructor(private readonly options: AlertsClientParams) { this.legacyAlertsClient = new LegacyAlertsClient< @@ -130,6 +132,8 @@ export class AlertsClient< this.rule = formatRule({ rule: this.options.rule, ruleType: this.options.ruleType }); this.ruleType = options.ruleType; this._isUsingDataStreams = this.options.dataStreamAdapter.isUsingDataStreams(); + this.ruleInfoMessage = `for ${this.ruleType.id}:${this.options.rule.id} '${this.options.rule.name}'`; + this.logTags = { tags: [this.ruleType.id, this.options.rule.id, 'alerts-client'] }; } public async initializeExecution(opts: InitializeExecutionOpts) { @@ -202,7 +206,10 @@ export class AlertsClient< this.fetchedAlerts.primaryTerm[alertUuid] = hit._primary_term; } } catch (err) { - this.options.logger.error(`Error searching for tracked alerts by UUID - ${err.message}`); + this.options.logger.error( + `Error searching for tracked alerts by UUID ${this.ruleInfoMessage} - ${err.message}`, + this.logTags + ); } } @@ -327,7 +334,8 @@ export class AlertsClient< ); } catch (e) { this.options.logger.debug( - `Failed to update alert matched by maintenance window scoped query for rule ${this.ruleType.id}:${this.options.rule.id}: '${this.options.rule.name}'.` + `Failed to update alert matched by maintenance window scoped query ${this.ruleInfoMessage}`, + this.logTags ); } @@ -407,7 +415,8 @@ export class AlertsClient< private async persistAlertsHelper() { if (!this.ruleType.alerts?.shouldWrite) { this.options.logger.debug( - `Resources registered and installed for ${this.ruleType.alerts?.context} context but "shouldWrite" is set to false.` + `Resources registered and installed for ${this.ruleType.alerts?.context} context but "shouldWrite" is set to false ${this.ruleInfoMessage}.`, + this.logTags ); return; } @@ -482,7 +491,8 @@ export class AlertsClient< } } else { this.options.logger.error( - `Error writing alert(${id}) to ${this.indexTemplateAndPattern.alias} - alert(${id}) doesn't exist in active alerts` + `Error writing alert(${id}) to ${this.indexTemplateAndPattern.alias} - alert(${id}) doesn't exist in active alerts ${this.ruleInfoMessage}.`, + this.logTags ); } } @@ -529,7 +539,8 @@ export class AlertsClient< return true; } else if (!isValidAlertIndexName(alertIndex)) { this.options.logger.warn( - `Could not update alert ${alertUuid} in ${alertIndex}. Partial and restored alert indices are not supported.` + `Could not update alert ${alertUuid} in ${alertIndex}. Partial and restored alert indices are not supported ${this.ruleInfoMessage}.`, + this.logTags ); return false; } @@ -573,11 +584,15 @@ export class AlertsClient< operations: bulkBody, }, bulkResponse: response, + ruleId: this.options.rule.id, + ruleName: this.options.rule.name, + ruleType: this.ruleType.id, }); } } catch (err) { this.options.logger.error( - `Error writing ${alertsToIndex.length} alerts to ${this.indexTemplateAndPattern.alias} - ${err.message}` + `Error writing ${alertsToIndex.length} alerts to ${this.indexTemplateAndPattern.alias} ${this.ruleInfoMessage} - ${err.message}`, + this.logTags ); } } @@ -669,7 +684,10 @@ export class AlertsClient< }); return response; } catch (err) { - this.options.logger.warn(`Error updating alert maintenance window IDs: ${err}`); + this.options.logger.warn( + `Error updating alert maintenance window IDs ${this.ruleInfoMessage}: ${err}`, + this.logTags + ); throw err; } } @@ -739,7 +757,8 @@ export class AlertsClient< // Update alerts with new maintenance window IDs, await not needed this.updateAlertMaintenanceWindowIds(uniqueAlertsId).catch(() => { this.options.logger.debug( - 'Failed to update new alerts with scoped query maintenance window Ids by updateByQuery.' + `Failed to update new alerts with scoped query maintenance window Ids by updateByQuery ${this.ruleInfoMessage}.`, + this.logTags ); }); } diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.test.ts index 281b358854be9..cc6b43b40da7b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.test.ts @@ -25,6 +25,12 @@ import { resolveAlertConflicts } from './alert_conflict_resolver'; const logger = loggingSystemMock.create().get(); const esClient = elasticsearchServiceMock.createElasticsearchClient(); +const ruleId = 'rule-id'; +const ruleName = 'name of rule'; +const ruleType = 'rule-type'; + +const ruleInfo = `for ${ruleType}:${ruleId} '${ruleName}'`; +const logTags = { tags: [ruleType, ruleId, 'resolve-alert-conflicts'] }; const alertDoc = { [EVENT_ACTION]: 'active', @@ -45,11 +51,20 @@ describe('alert_conflict_resolver', () => { esClient.mget.mockRejectedValueOnce(new Error('mget failed')); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).toHaveBeenNthCalledWith( 2, - 'Error resolving alert conflicts: mget failed' + `Error resolving alert conflicts ${ruleInfo}: mget failed`, + logTags ); }); @@ -61,11 +76,20 @@ describe('alert_conflict_resolver', () => { }); esClient.bulk.mockRejectedValueOnce(new Error('bulk failed')); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).toHaveBeenNthCalledWith( 2, - 'Error resolving alert conflicts: bulk failed' + `Error resolving alert conflicts ${ruleInfo}: bulk failed`, + logTags ); }); }); @@ -73,13 +97,29 @@ describe('alert_conflict_resolver', () => { describe('is successful with', () => { test('no bulk results', async () => { const { bulkRequest, bulkResponse } = getReqRes(''); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).not.toHaveBeenCalled(); }); test('no errors in bulk results', async () => { const { bulkRequest, bulkResponse } = getReqRes('c is is c is'); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).not.toHaveBeenCalled(); }); @@ -96,16 +136,30 @@ describe('alert_conflict_resolver', () => { items: [getBulkResItem(0)], }); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).toHaveBeenNthCalledWith( 1, - `Error writing alerts: 0 successful, 1 conflicts, 0 errors: ` + `Error writing alerts ${ruleInfo}: 0 successful, 1 conflicts, 0 errors: `, + logTags + ); + expect(logger.info).toHaveBeenNthCalledWith( + 1, + `Retrying bulk update of 1 conflicted alerts ${ruleInfo}`, + logTags ); - expect(logger.info).toHaveBeenNthCalledWith(1, `Retrying bulk update of 1 conflicted alerts`); expect(logger.info).toHaveBeenNthCalledWith( 2, - `Retried bulk update of 1 conflicted alerts succeeded` + `Retried bulk update of 1 conflicted alerts succeeded ${ruleInfo}`, + logTags ); }); @@ -122,16 +176,30 @@ describe('alert_conflict_resolver', () => { items: [getBulkResItem(2)], }); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).toHaveBeenNthCalledWith( 1, - `Error writing alerts: 2 successful, 1 conflicts, 1 errors: hallo` + `Error writing alerts ${ruleInfo}: 2 successful, 1 conflicts, 1 errors: hallo`, + logTags + ); + expect(logger.info).toHaveBeenNthCalledWith( + 1, + `Retrying bulk update of 1 conflicted alerts ${ruleInfo}`, + logTags ); - expect(logger.info).toHaveBeenNthCalledWith(1, `Retrying bulk update of 1 conflicted alerts`); expect(logger.info).toHaveBeenNthCalledWith( 2, - `Retried bulk update of 1 conflicted alerts succeeded` + `Retried bulk update of 1 conflicted alerts succeeded ${ruleInfo}`, + logTags ); }); @@ -148,16 +216,30 @@ describe('alert_conflict_resolver', () => { items: [getBulkResItem(2), getBulkResItem(3), getBulkResItem(5)], }); - await resolveAlertConflicts({ logger, esClient, bulkRequest, bulkResponse }); + await resolveAlertConflicts({ + logger, + esClient, + bulkRequest, + bulkResponse, + ruleId, + ruleName, + ruleType, + }); expect(logger.error).toHaveBeenNthCalledWith( 1, - `Error writing alerts: 2 successful, 3 conflicts, 1 errors: hallo` + `Error writing alerts ${ruleInfo}: 2 successful, 3 conflicts, 1 errors: hallo`, + logTags + ); + expect(logger.info).toHaveBeenNthCalledWith( + 1, + `Retrying bulk update of 3 conflicted alerts ${ruleInfo}`, + logTags ); - expect(logger.info).toHaveBeenNthCalledWith(1, `Retrying bulk update of 3 conflicted alerts`); expect(logger.info).toHaveBeenNthCalledWith( 2, - `Retried bulk update of 3 conflicted alerts succeeded` + `Retried bulk update of 3 conflicted alerts succeeded ${ruleInfo}`, + logTags ); }); }); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.ts b/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.ts index 383d8dbb103fb..55a3a885f1c71 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/alert_conflict_resolver.ts @@ -35,6 +35,9 @@ export interface ResolveAlertConflictsParams { logger: Logger; bulkRequest: BulkRequest; bulkResponse: BulkResponse; + ruleId: string; + ruleName: string; + ruleType: string; } interface NormalizedBulkRequest { @@ -46,26 +49,33 @@ interface NormalizedBulkRequest { // to replace just logging that the error occurred, so we don't want // to cause _more_ errors ... export async function resolveAlertConflicts(params: ResolveAlertConflictsParams): Promise { - const { logger } = params; + const { logger, ruleId, ruleType, ruleName } = params; + const ruleInfoMessage = `for ${ruleType}:${ruleId} '${ruleName}'`; + const logTags = { tags: [ruleType, ruleId, 'resolve-alert-conflicts'] }; + try { await resolveAlertConflicts_(params); } catch (err) { - logger.error(`Error resolving alert conflicts: ${err.message}`); + logger.error(`Error resolving alert conflicts ${ruleInfoMessage}: ${err.message}`, logTags); } } async function resolveAlertConflicts_(params: ResolveAlertConflictsParams): Promise { - const { logger, esClient, bulkRequest, bulkResponse } = params; + const { logger, esClient, bulkRequest, bulkResponse, ruleId, ruleType, ruleName } = params; if (bulkRequest.operations && bulkRequest.operations?.length === 0) return; if (bulkResponse.items && bulkResponse.items?.length === 0) return; + const ruleInfoMessage = `for ${ruleType}:${ruleId} '${ruleName}'`; + const logTags = { tags: [ruleType, ruleId, 'resolve-alert-conflicts'] }; + // get numbers for a summary log message const { success, errors, conflicts, messages } = getResponseStats(bulkResponse); if (conflicts === 0 && errors === 0) return; const allMessages = messages.join('; '); logger.error( - `Error writing alerts: ${success} successful, ${conflicts} conflicts, ${errors} errors: ${allMessages}` + `Error writing alerts ${ruleInfoMessage}: ${success} successful, ${conflicts} conflicts, ${errors} errors: ${allMessages}`, + logTags ); // get a new bulk request for just conflicted docs @@ -79,14 +89,18 @@ async function resolveAlertConflicts_(params: ResolveAlertConflictsParams): Prom await updateOCC(conflictRequest, freshDocs); await refreshFieldsInDocs(conflictRequest, freshDocs); - logger.info(`Retrying bulk update of ${conflictRequest.length} conflicted alerts`); + logger.info( + `Retrying bulk update of ${conflictRequest.length} conflicted alerts ${ruleInfoMessage}`, + logTags + ); const mbrResponse = await makeBulkRequest(params.esClient, params.bulkRequest, conflictRequest); if (mbrResponse.bulkResponse?.items.length !== conflictRequest.length) { const actual = mbrResponse.bulkResponse?.items.length; const expected = conflictRequest.length; logger.error( - `Unexpected number of bulk response items retried; expecting ${expected}, retried ${actual}` + `Unexpected number of bulk response items retried; expecting ${expected}, retried ${actual} ${ruleInfoMessage}`, + logTags ); return; } @@ -94,16 +108,21 @@ async function resolveAlertConflicts_(params: ResolveAlertConflictsParams): Prom if (mbrResponse.error) { const index = bulkRequest.index || 'unknown index'; logger.error( - `Error writing ${conflictRequest.length} alerts to ${index} - ${mbrResponse.error.message}` + `Error writing ${conflictRequest.length} alerts to ${index} ${ruleInfoMessage} - ${mbrResponse.error.message}`, + logTags ); return; } if (mbrResponse.errors === 0) { - logger.info(`Retried bulk update of ${conflictRequest.length} conflicted alerts succeeded`); + logger.info( + `Retried bulk update of ${conflictRequest.length} conflicted alerts succeeded ${ruleInfoMessage}`, + logTags + ); } else { logger.error( - `Retried bulk update of ${conflictRequest.length} conflicted alerts still had ${mbrResponse.errors} conflicts` + `Retried bulk update of ${conflictRequest.length} conflicted alerts still had ${mbrResponse.errors} conflicts ${ruleInfoMessage}`, + logTags ); } } From f63bd038936d9ccc54126dd6fb153409348fd8b5 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Thu, 22 Aug 2024 16:59:41 +0200 Subject: [PATCH 39/45] [Infra] Identify hosts not monitored by the system integration in the hosts list (#191063) Closes [#190418](https://github.com/elastic/kibana/issues/190418) ## Summary This PR adds a popover for the hosts that are not monitored by the system integration to help users troubleshoot the problem ## Testing A way to check it is to have a host that is monitored by system integration (I am using metricbeat) and sythtrace hosts - there we should have both (I am using `node scripts/synthtrace infra_hosts_with_apm_hosts --scenarioOpts.numInstances=50 --live`) - Go to hosts view and check the table - The hosts that are **not** monitored by the system integration should have an icon before the host name which opens the popover with some explanation and documentation links - The hosts that are monitored by the system integration should not have an icon before the host name Screenshot 2024-08-22 at 12 02 27 ![image](https://github.com/user-attachments/assets/b28474d4-2272-4372-b70d-6370aa8e2a9d) --- .../hosts/hooks/use_hosts_table.test.ts | 2 + .../metrics/hosts/hooks/use_hosts_table.tsx | 88 ++++++++++++++++--- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts index c8011d614dbff..965f6acd24c93 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts @@ -176,6 +176,7 @@ describe('useHostTable hook', () => { memoryFree: 34359.738368, normalizedLoad1m: 239.2040001, alertsCount: 0, + hasSystemMetrics: true, }, { name: 'host-1', @@ -194,6 +195,7 @@ describe('useHostTable hook', () => { memoryFree: 9.194304, normalizedLoad1m: 100, alertsCount: 0, + hasSystemMetrics: true, }, ]; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx index 87ecc64de14f3..10f7b6028a384 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx @@ -6,7 +6,13 @@ */ import React, { useCallback, useMemo, useState } from 'react'; -import { EuiBasicTableColumn, CriteriaWithPagination, EuiTableSelectionType } from '@elastic/eui'; +import { + EuiBasicTableColumn, + CriteriaWithPagination, + EuiTableSelectionType, + EuiText, + EuiLink, +} from '@elastic/eui'; import createContainer from 'constate'; import useAsync from 'react-use/lib/useAsync'; import { isEqual } from 'lodash'; @@ -15,6 +21,8 @@ import { CloudProvider } from '@kbn/custom-icons'; import { findInventoryModel } from '@kbn/metrics-data-access-plugin/common'; import { EuiToolTip } from '@elastic/eui'; import { EuiBadge } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Popover } from '../../../../components/asset_details/tabs/common/popover'; import { HOST_NAME_FIELD } from '../../../../../common/constants'; import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; import { createInventoryMetricFormatter } from '../../inventory_view/lib/create_inventory_metric_formatter'; @@ -48,6 +56,7 @@ export type HostNodeRow = HostMetadata & HostMetrics & { name: string; alertsCount?: number; + hasSystemMetrics: boolean; }; /** @@ -58,7 +67,7 @@ const formatMetric = (type: InfraAssetMetricType, value: number | undefined | nu }; const buildItemsList = (nodes: InfraAssetMetricsItem[]): HostNodeRow[] => { - return nodes.map(({ metrics, metadata, name, alertsCount }) => { + return nodes.map(({ metrics, metadata, name, alertsCount, hasSystemMetrics }) => { const metadataKeyValue = metadata.reduce( (acc, curr) => ({ ...acc, @@ -83,7 +92,7 @@ const buildItemsList = (nodes: InfraAssetMetricsItem[]): HostNodeRow[] => { }), {} as HostMetrics ), - + hasSystemMetrics, alertsCount: alertsCount ?? 0, }; }); @@ -127,6 +136,7 @@ export const useHostsTable = () => { const { hostNodes } = useHostsViewContext(); const displayAlerts = hostNodes.some((item) => 'alertsCount' in item); + const showApmHostTroubleshooting = hostNodes.some((item) => !item.hasSystemMetrics); const { value: formulas } = useAsync(() => inventoryModel.metrics.getFormulas()); @@ -267,6 +277,63 @@ export const useHostsTable = () => { }, ] : []), + ...(showApmHostTroubleshooting + ? [ + { + name: '', + width: '20px', + field: 'hasSystemMetrics', + sortable: false, + 'data-test-subj': 'hostsView-tableRow-hasSystemMetrics', + render: (hasSystemMetrics: HostNodeRow['hasSystemMetrics']) => { + if (hasSystemMetrics) { + return null; + } + return ( + + +

+ + + + ), + }} + /> +

+

+ + + +

+
+
+ ); + }, + }, + ] + : []), { name: TABLE_COLUMN_LABEL.title, field: 'title', @@ -385,18 +452,19 @@ export const useHostsTable = () => { }, ], [ - detailsItemId, + displayAlerts, + showApmHostTroubleshooting, formulas?.cpuUsage.value, - formulas?.diskUsage.value, - formulas?.memoryFree.value, - formulas?.memoryUsage.value, formulas?.normalizedLoad1m.value, + formulas?.memoryUsage.value, + formulas?.memoryFree.value, + formulas?.diskUsage.value, formulas?.rx.value, formulas?.tx.value, - reportHostEntryClick, - setProperties, - displayAlerts, metricColumnsWidth, + detailsItemId, + setProperties, + reportHostEntryClick, ] ); From 9653d7e1fcf1b894728ae7502dd6b0e290e25321 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 22 Aug 2024 11:08:01 -0400 Subject: [PATCH 40/45] [Response Ops][Task Manager] Adding jest integration test to test capacity based claiming (#189431) Resolves https://github.com/elastic/kibana/issues/189111 ## Summary Adds jest integration test to test cost capacity based claiming with the `mget` claim strategy. Using this integration test, we can exclude running other tasks other than our test types. We register a normal cost task and an XL cost task. We test both that we can claim tasks up to 100% capacity and that we will stop claiming tasks if the next task puts us over capacity, even if that means we're leaving capacity on the table. --------- Co-authored-by: Elastic Machine --- ...sk_manager_capacity_based_claiming.test.ts | 327 ++++++++++++++++++ .../server/task_claimers/index.test.ts | 14 +- .../server/task_claimers/index.ts | 11 + .../server/task_claimers/strategy_mget.ts | 16 +- .../task_claimers/strategy_update_by_query.ts | 18 +- 5 files changed, 367 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/integration_tests/task_manager_capacity_based_claiming.test.ts diff --git a/x-pack/plugins/task_manager/server/integration_tests/task_manager_capacity_based_claiming.test.ts b/x-pack/plugins/task_manager/server/integration_tests/task_manager_capacity_based_claiming.test.ts new file mode 100644 index 0000000000000..ab2a397f60f8c --- /dev/null +++ b/x-pack/plugins/task_manager/server/integration_tests/task_manager_capacity_based_claiming.test.ts @@ -0,0 +1,327 @@ +/* + * 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 { v4 as uuidV4 } from 'uuid'; +import type { TestElasticsearchUtils, TestKibanaUtils } from '@kbn/core-test-helpers-kbn-server'; +import { schema } from '@kbn/config-schema'; +import { times } from 'lodash'; +import { TaskCost, TaskStatus } from '../task'; +import type { TaskClaimingOpts } from '../queries/task_claiming'; +import { TaskManagerPlugin, type TaskManagerStartContract } from '../plugin'; +import { injectTask, setupTestServers, retry } from './lib'; +import { CreateMonitoringStatsOpts } from '../monitoring'; +import { filter, map } from 'rxjs'; +import { isTaskManagerWorkerUtilizationStatEvent } from '../task_events'; +import { TaskLifecycleEvent } from '../polling_lifecycle'; +import { Ok } from '../lib/result_type'; + +const POLLING_INTERVAL = 5000; +const { TaskPollingLifecycle: TaskPollingLifecycleMock } = jest.requireMock('../polling_lifecycle'); +jest.mock('../polling_lifecycle', () => { + const actual = jest.requireActual('../polling_lifecycle'); + return { + ...actual, + TaskPollingLifecycle: jest.fn().mockImplementation((opts) => { + return new actual.TaskPollingLifecycle(opts); + }), + }; +}); + +const { createMonitoringStats: createMonitoringStatsMock } = jest.requireMock('../monitoring'); +jest.mock('../monitoring', () => { + const actual = jest.requireActual('../monitoring'); + return { + ...actual, + createMonitoringStats: jest.fn().mockImplementation((opts) => { + return new actual.createMonitoringStats(opts); + }), + }; +}); + +const mockTaskTypeNormalCostRunFn = jest.fn(); +const mockCreateTaskRunnerNormalCost = jest.fn(); +const mockTaskTypeNormalCost = { + title: 'Normal cost task', + description: '', + cost: TaskCost.Normal, + stateSchemaByVersion: { + 1: { + up: (state: Record) => ({ foo: state.foo || '' }), + schema: schema.object({ + foo: schema.string(), + }), + }, + }, + createTaskRunner: mockCreateTaskRunnerNormalCost.mockImplementation(() => ({ + run: mockTaskTypeNormalCostRunFn, + })), +}; +const mockTaskTypeXLCostRunFn = jest.fn(); +const mockCreateTaskRunnerXLCost = jest.fn(); +const mockTaskTypeXLCost = { + title: 'XL cost task', + description: '', + cost: TaskCost.ExtraLarge, + stateSchemaByVersion: { + 1: { + up: (state: Record) => ({ foo: state.foo || '' }), + schema: schema.object({ + foo: schema.string(), + }), + }, + }, + createTaskRunner: mockCreateTaskRunnerXLCost.mockImplementation(() => ({ + run: mockTaskTypeXLCostRunFn, + })), +}; +jest.mock('../queries/task_claiming', () => { + const actual = jest.requireActual('../queries/task_claiming'); + return { + ...actual, + TaskClaiming: jest.fn().mockImplementation((opts: TaskClaimingOpts) => { + opts.definitions.registerTaskDefinitions({ + _normalCostType: mockTaskTypeNormalCost, + _xlCostType: mockTaskTypeXLCost, + }); + return new actual.TaskClaiming(opts); + }), + }; +}); + +const taskManagerStartSpy = jest.spyOn(TaskManagerPlugin.prototype, 'start'); + +describe('capacity based claiming', () => { + const taskIdsToRemove: string[] = []; + let esServer: TestElasticsearchUtils; + let kibanaServer: TestKibanaUtils; + let taskManagerPlugin: TaskManagerStartContract; + let createMonitoringStatsOpts: CreateMonitoringStatsOpts; + + beforeAll(async () => { + const setupResult = await setupTestServers({ + xpack: { + task_manager: { + claim_strategy: `mget`, + capacity: 10, + poll_interval: POLLING_INTERVAL, + unsafe: { + exclude_task_types: ['[A-Za-z]*'], + }, + }, + }, + }); + esServer = setupResult.esServer; + kibanaServer = setupResult.kibanaServer; + + expect(taskManagerStartSpy).toHaveBeenCalledTimes(1); + taskManagerPlugin = taskManagerStartSpy.mock.results[0].value; + + expect(TaskPollingLifecycleMock).toHaveBeenCalledTimes(1); + + expect(createMonitoringStatsMock).toHaveBeenCalledTimes(1); + createMonitoringStatsOpts = createMonitoringStatsMock.mock.calls[0][0]; + }); + + afterAll(async () => { + if (kibanaServer) { + await kibanaServer.stop(); + } + if (esServer) { + await esServer.stop(); + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + while (taskIdsToRemove.length > 0) { + const id = taskIdsToRemove.pop(); + await taskManagerPlugin.removeIfExists(id!); + } + }); + + it('should claim tasks to full capacity', async () => { + const backgroundTaskLoads: number[] = []; + createMonitoringStatsOpts.taskPollingLifecycle?.events + .pipe( + filter(isTaskManagerWorkerUtilizationStatEvent), + map((taskEvent: TaskLifecycleEvent) => { + return (taskEvent.event as unknown as Ok).value; + }) + ) + .subscribe((load: number) => { + backgroundTaskLoads.push(load); + }); + const taskRunAtDates: Date[] = []; + mockTaskTypeNormalCostRunFn.mockImplementation(() => { + taskRunAtDates.push(new Date()); + return { state: { foo: 'test' } }; + }); + + // inject 10 normal cost tasks with the same runAt value + const ids: string[] = []; + times(10, () => ids.push(uuidV4())); + + const runAt = new Date(); + for (const id of ids) { + await injectTask(kibanaServer.coreStart.elasticsearch.client.asInternalUser, { + id, + taskType: '_normalCostType', + params: {}, + state: { foo: 'test' }, + stateVersion: 1, + runAt, + enabled: true, + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + taskIdsToRemove.push(id); + } + + await retry(async () => { + expect(mockTaskTypeNormalCostRunFn).toHaveBeenCalledTimes(10); + }); + + expect(taskRunAtDates.length).toBe(10); + + // run at dates should be within a few seconds of each other + const firstRunAt = taskRunAtDates[0].getTime(); + const lastRunAt = taskRunAtDates[taskRunAtDates.length - 1].getTime(); + + expect(lastRunAt - firstRunAt).toBeLessThanOrEqual(1000); + + // background task load should be 0 or 100 since we're only running these tasks + for (const load of backgroundTaskLoads) { + expect(load === 0 || load === 100).toBe(true); + } + }); + + it('should claim tasks until the next task will exceed capacity', async () => { + const backgroundTaskLoads: number[] = []; + createMonitoringStatsOpts.taskPollingLifecycle?.events + .pipe( + filter(isTaskManagerWorkerUtilizationStatEvent), + map((taskEvent: TaskLifecycleEvent) => { + return (taskEvent.event as unknown as Ok).value; + }) + ) + .subscribe((load: number) => { + backgroundTaskLoads.push(load); + }); + const now = new Date(); + const taskRunAtDates: Array<{ runAt: Date; type: string }> = []; + mockTaskTypeNormalCostRunFn.mockImplementation(() => { + taskRunAtDates.push({ type: 'normal', runAt: new Date() }); + return { state: { foo: 'test' } }; + }); + mockTaskTypeXLCostRunFn.mockImplementation(() => { + taskRunAtDates.push({ type: 'xl', runAt: new Date() }); + return { state: { foo: 'test' } }; + }); + + // inject 6 normal cost tasks for total cost of 12 + const ids: string[] = []; + times(6, () => ids.push(uuidV4())); + const runAt1 = new Date(now.valueOf() - 5); + for (const id of ids) { + await injectTask(kibanaServer.coreStart.elasticsearch.client.asInternalUser, { + id, + taskType: '_normalCostType', + params: {}, + state: { foo: 'test' }, + stateVersion: 1, + runAt: runAt1, + enabled: true, + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + taskIdsToRemove.push(id); + } + + // inject 1 XL cost task that will put us over the max cost capacity of 20 + const xlid = uuidV4(); + const runAt2 = now; + await injectTask(kibanaServer.coreStart.elasticsearch.client.asInternalUser, { + id: xlid, + taskType: '_xlCostType', + params: {}, + state: { foo: 'test' }, + stateVersion: 1, + runAt: runAt2, + enabled: true, + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + taskIdsToRemove.push(xlid); + + // inject one more normal cost task + const runAt3 = new Date(now.valueOf() + 5); + const lastid = uuidV4(); + await injectTask(kibanaServer.coreStart.elasticsearch.client.asInternalUser, { + id: lastid, + taskType: '_normalCostType', + params: {}, + state: { foo: 'test' }, + stateVersion: 1, + runAt: runAt3, + enabled: true, + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + taskIdsToRemove.push(lastid); + + // retry until all tasks have been run + await retry(async () => { + expect(mockTaskTypeNormalCostRunFn).toHaveBeenCalledTimes(7); + expect(mockTaskTypeXLCostRunFn).toHaveBeenCalledTimes(1); + }); + + expect(taskRunAtDates.length).toBe(8); + + const firstRunAt = taskRunAtDates[0].runAt.getTime(); + + // the first 6 tasks should have been run at the same time (adding some fudge factor) + // and they should all be normal cost tasks + for (let i = 0; i < 6; i++) { + expect(taskRunAtDates[i].type).toBe('normal'); + expect(taskRunAtDates[i].runAt.getTime() - firstRunAt).toBeLessThanOrEqual(500); + } + + // the next task should be XL cost task and be run after one polling interval has passed (with some fudge factor) + expect(taskRunAtDates[6].type).toBe('xl'); + expect(taskRunAtDates[6].runAt.getTime() - firstRunAt).toBeGreaterThan(POLLING_INTERVAL - 500); + + // last task should be normal cost and be run after one polling interval has passed + expect(taskRunAtDates[7].type).toBe('normal'); + expect(taskRunAtDates[7].runAt.getTime() - firstRunAt).toBeGreaterThan(POLLING_INTERVAL - 500); + + // background task load should be 0 or 60 or 100 since we're only running these tasks + // should be 100 during the claim cycle where we claimed 6 normal tasks but left the large capacity task in the queue + // should be 60 during the next claim cycle where we claimed the large capacity task and the normal capacity: 10 + 2 / 20 = 60% + for (const load of backgroundTaskLoads) { + expect(load === 0 || load === 60 || load === 100).toBe(true); + } + }); +}); diff --git a/x-pack/plugins/task_manager/server/task_claimers/index.test.ts b/x-pack/plugins/task_manager/server/task_claimers/index.test.ts index d4501a0a021ff..72ab2f9a695e8 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/index.test.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getTaskClaimer } from '.'; +import { getTaskClaimer, isTaskTypeExcluded } from '.'; import { mockLogger } from '../test_utils'; import { claimAvailableTasksUpdateByQuery } from './strategy_update_by_query'; import { claimAvailableTasksMget } from './strategy_mget'; @@ -37,3 +37,15 @@ describe('task_claimers/index', () => { }); }); }); + +describe('isTaskTypeExcluded', () => { + test('returns false when task type is not in the excluded list', () => { + expect(isTaskTypeExcluded(['otherTaskType'], 'taskType')).toBe(false); + expect(isTaskTypeExcluded(['otherTaskType*'], 'taskType')).toBe(false); + }); + + test('returns true when task type is in the excluded list', () => { + expect(isTaskTypeExcluded(['taskType'], 'taskType')).toBe(true); + expect(isTaskTypeExcluded(['task*'], 'taskType')).toBe(true); + }); +}); diff --git a/x-pack/plugins/task_manager/server/task_claimers/index.ts b/x-pack/plugins/task_manager/server/task_claimers/index.ts index 2b7e48c85f167..ff4f9f6131120 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/index.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/index.ts @@ -8,6 +8,7 @@ import { Subject, Observable } from 'rxjs'; import { Logger } from '@kbn/core/server'; +import minimatch from 'minimatch'; import { TaskStore } from '../task_store'; import { TaskClaim, TaskTiming } from '../task_events'; import { TaskTypeDictionary } from '../task_type_dictionary'; @@ -72,3 +73,13 @@ export function getEmptyClaimOwnershipResult(): ClaimOwnershipResult { docs: [], }; } + +export function isTaskTypeExcluded(excludedTaskTypePatterns: string[], taskType: string) { + for (const excludedTypePattern of excludedTaskTypePatterns) { + if (minimatch(taskType, excludedTypePattern)) { + return true; + } + } + + return false; +} diff --git a/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts b/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts index 7962fdd2b6f8a..b2751803e8dc3 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts @@ -19,7 +19,12 @@ import apm from 'elastic-apm-node'; import { Subject, Observable } from 'rxjs'; import { TaskTypeDictionary } from '../task_type_dictionary'; -import { TaskClaimerOpts, ClaimOwnershipResult, getEmptyClaimOwnershipResult } from '.'; +import { + TaskClaimerOpts, + ClaimOwnershipResult, + getEmptyClaimOwnershipResult, + isTaskTypeExcluded, +} from '.'; import { ConcreteTaskInstance, TaskStatus, ConcreteTaskInstanceVersion, TaskCost } from '../task'; import { TASK_MANAGER_TRANSACTION_TYPE } from '../task_running'; import { @@ -50,7 +55,7 @@ interface OwnershipClaimingOpts { size: number; taskTypes: Set; removedTypes: Set; - excludedTypes: Set; + excludedTaskTypes: string[]; taskStore: TaskStore; events$: Subject; definitions: TaskTypeDictionary; @@ -103,13 +108,12 @@ async function claimAvailableTasks(opts: TaskClaimerOpts): Promise { const searchedTypes = Array.from(taskTypes) .concat(Array.from(removedTypes)) - .filter((type) => !excludedTypes.has(type)); + .filter((type) => !isTaskTypeExcluded(excludedTaskTypes, type)); const queryForScheduledTasks = mustBeAllOf( // Task must be enabled EnabledTask, diff --git a/x-pack/plugins/task_manager/server/task_claimers/strategy_update_by_query.ts b/x-pack/plugins/task_manager/server/task_claimers/strategy_update_by_query.ts index 1cb9d8942a55a..807ee8ca4397f 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/strategy_update_by_query.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/strategy_update_by_query.ts @@ -9,14 +9,18 @@ * This module contains helpers for managing the task manager storage layer. */ import apm from 'elastic-apm-node'; -import minimatch from 'minimatch'; import { Subject, Observable, from, of } from 'rxjs'; import { mergeScan } from 'rxjs'; import { groupBy, pick } from 'lodash'; import { asOk } from '../lib/result_type'; import { TaskTypeDictionary } from '../task_type_dictionary'; -import { TaskClaimerOpts, ClaimOwnershipResult, getEmptyClaimOwnershipResult } from '.'; +import { + TaskClaimerOpts, + ClaimOwnershipResult, + getEmptyClaimOwnershipResult, + isTaskTypeExcluded, +} from '.'; import { ConcreteTaskInstance } from '../task'; import { TASK_MANAGER_TRANSACTION_TYPE } from '../task_running'; import { isLimited, TASK_MANAGER_MARK_AS_CLAIMED } from '../queries/task_claiming'; @@ -132,16 +136,6 @@ function emitEvents(events$: Subject, events: TaskClaim[]) { events.forEach((event) => events$.next(event)); } -function isTaskTypeExcluded(excludedTaskTypes: string[], taskType: string) { - for (const excludedType of excludedTaskTypes) { - if (minimatch(taskType, excludedType)) { - return true; - } - } - - return false; -} - async function markAvailableTasksAsClaimed({ definitions, excludedTaskTypes, From 20ccb21c942a91f8625764889be4461c0ee22b6a Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 22 Aug 2024 17:13:16 +0200 Subject: [PATCH 41/45] [Synthetics] Settings add config to enable default rules (#190800) ## Summary Settings add config to enable default rules !! separated out of https://github.com/elastic/kibana/pull/186585 !! image --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dominique Belcher --- .../common/constants/settings_defaults.ts | 2 + .../common/runtime_types/dynamic_settings.ts | 2 + .../synthetics_overview_status.ts | 2 +- .../synthetics/common/types/default_alerts.ts | 20 +++ .../synthetics/common/types/index.ts | 1 + ...etics_alert.ts => use_synthetics_rules.ts} | 9 +- .../alerts/toggle_alert_flyout_button.tsx | 4 +- .../alerting_defaults/alert_defaults_form.tsx | 45 ++++++ .../use_monitors_sorted_by_status.test.tsx | 2 +- .../synthetics/state/alert_rules/actions.ts | 25 ++- .../apps/synthetics/state/alert_rules/api.ts | 8 +- .../synthetics/state/alert_rules/index.ts | 4 +- .../apps/synthetics/state/settings/api.ts | 10 +- .../server/alert_rules/common.test.ts | 4 + .../synthetics/server/alert_rules/common.ts | 2 + .../status_rule/status_rule_executor.ts | 2 +- .../alert_rules/tls_rule/tls_rule_executor.ts | 2 +- .../server/queries/query_monitor_status.ts | 2 +- .../server/routes/certs/get_certificates.ts | 15 +- .../default_alerts/default_alert_service.ts | 61 ++++++-- .../default_alerts/enable_default_alert.ts | 3 +- .../default_alerts/get_default_alert.ts | 7 +- .../default_alerts/update_default_alert.ts | 38 +++-- .../overview_status/overview_status.test.ts | 8 +- .../routes/overview_status/overview_status.ts | 10 +- .../routes/settings/dynamic_settings.ts | 12 +- .../server/routes/suggestions/route.ts | 25 +-- .../server/runtime_types/settings.ts | 2 + .../server/saved_objects/saved_objects.ts | 3 +- .../get_all_monitors.test.ts | 41 +---- .../synthetics_monitor/get_all_monitors.ts | 5 - .../synthetics/server/types.ts | 2 + .../uptime/tsconfig.json | 2 +- .../synthetics/enable_default_alerting.ts | 142 +++++++++++++++++- 34 files changed, 382 insertions(+), 140 deletions(-) create mode 100644 x-pack/plugins/observability_solution/synthetics/common/types/default_alerts.ts rename x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/{use_synthetics_alert.ts => use_synthetics_rules.ts} (91%) diff --git a/x-pack/plugins/observability_solution/synthetics/common/constants/settings_defaults.ts b/x-pack/plugins/observability_solution/synthetics/common/constants/settings_defaults.ts index aa32449713be8..fc382175e182f 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/constants/settings_defaults.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/constants/settings_defaults.ts @@ -16,4 +16,6 @@ export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = { cc: [], bcc: [], }, + defaultTLSRuleEnabled: true, + defaultStatusRuleEnabled: true, }; diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/dynamic_settings.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/dynamic_settings.ts index 8dc2405085bb5..e222ee5896908 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/dynamic_settings.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/dynamic_settings.ts @@ -34,6 +34,8 @@ export const DynamicSettingsCodec = t.intersection([ }), t.partial({ defaultEmail: DefaultEmailCodec, + defaultTLSRuleEnabled: t.boolean, + defaultStatusRuleEnabled: t.boolean, }), ]); diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts index 54238d01a915d..7e8a1257e6428 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts @@ -44,7 +44,7 @@ export const OverviewPendingStatusMetaDataCodec = t.intersection([ monitorQueryId: t.string, configId: t.string, status: t.string, - location: t.string, + locationId: t.string, }), t.partial({ timestamp: t.string, diff --git a/x-pack/plugins/observability_solution/synthetics/common/types/default_alerts.ts b/x-pack/plugins/observability_solution/synthetics/common/types/default_alerts.ts new file mode 100644 index 0000000000000..2c02838842ad1 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/common/types/default_alerts.ts @@ -0,0 +1,20 @@ +/* + * 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 { SanitizedRule, SanitizedRuleAction, RuleSystemAction } from '@kbn/alerting-plugin/common'; +import { SYNTHETICS_STATUS_RULE, SYNTHETICS_TLS_RULE } from '../constants/synthetics_alerts'; + +export type DefaultRuleType = typeof SYNTHETICS_STATUS_RULE | typeof SYNTHETICS_TLS_RULE; +type SYNTHETICS_DEFAULT_RULE = Omit, 'systemActions' | 'actions'> & { + actions: Array; + ruleTypeId: SanitizedRule['alertTypeId']; +}; + +export interface DEFAULT_ALERT_RESPONSE { + statusRule: SYNTHETICS_DEFAULT_RULE | null; + tlsRule: SYNTHETICS_DEFAULT_RULE | null; +} diff --git a/x-pack/plugins/observability_solution/synthetics/common/types/index.ts b/x-pack/plugins/observability_solution/synthetics/common/types/index.ts index 2a70803a43211..be369427c47e7 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/types/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/types/index.ts @@ -7,3 +7,4 @@ export * from './synthetics_monitor'; export * from './monitor_validation'; +export * from './default_alerts'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts similarity index 91% rename from x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts rename to x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts index 2f3673287deb1..6e03b69b5d60c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_rules.ts @@ -25,7 +25,7 @@ import { } from '../../../state'; import { ClientPluginsStart } from '../../../../../plugin'; -export const useSyntheticsAlert = (isOpen: boolean) => { +export const useSyntheticsRules = (isOpen: boolean) => { const dispatch = useDispatch(); const defaultRules = useSelector(selectSyntheticsAlerts); @@ -64,14 +64,15 @@ export const useSyntheticsAlert = (isOpen: boolean) => { const { triggersActionsUi } = useKibana().services; const EditAlertFlyout = useMemo(() => { - if (!defaultRules) { + const initialRule = + alertFlyoutVisible === SYNTHETICS_TLS_RULE ? defaultRules?.tlsRule : defaultRules?.statusRule; + if (!initialRule) { return null; } return triggersActionsUi.getEditRuleFlyout({ onClose: () => dispatch(setAlertFlyoutVisible(null)), hideInterval: true, - initialRule: - alertFlyoutVisible === SYNTHETICS_TLS_RULE ? defaultRules.tlsRule : defaultRules.statusRule, + initialRule, }); }, [defaultRules, dispatch, triggersActionsUi, alertFlyoutVisible]); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx index 8bc04d8eb9f9d..0a8e5abf37f1a 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx @@ -26,7 +26,7 @@ import { import { ManageRulesLink } from '../common/links/manage_rules_link'; import { ClientPluginsStart } from '../../../../plugin'; import { ToggleFlyoutTranslations } from './hooks/translations'; -import { useSyntheticsAlert } from './hooks/use_synthetics_alert'; +import { useSyntheticsRules } from './hooks/use_synthetics_rules'; import { selectAlertFlyoutVisibility, selectMonitorListState, @@ -40,7 +40,7 @@ export const ToggleAlertFlyoutButton = () => { const { application } = useKibana().services; const hasUptimeWrite = application?.capabilities.uptime?.save ?? false; - const { EditAlertFlyout, loading } = useSyntheticsAlert(isOpen); + const { EditAlertFlyout, loading } = useSyntheticsRules(isOpen); const { loaded, data: monitors } = useSelector(selectMonitorListState); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/alerting_defaults/alert_defaults_form.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/alerting_defaults/alert_defaults_form.tsx index f87f1267efcc3..463e7604815a1 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/alerting_defaults/alert_defaults_form.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/alerting_defaults/alert_defaults_form.tsx @@ -15,6 +15,7 @@ import { EuiFlexItem, EuiForm, EuiSpacer, + EuiSwitch, } from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; import { useKibana } from '@kbn/kibana-react-plugin/public'; @@ -80,6 +81,50 @@ export const AlertDefaultsForm = () => { return ( + + + + + } + description={ + + } + > + + { + setFormFields({ + ...formFields, + defaultStatusRuleEnabled: !(formFields.defaultStatusRuleEnabled ?? true), + }); + }} + /> + + { + setFormFields({ + ...formFields, + defaultTLSRuleEnabled: !(formFields.defaultTLSRuleEnabled ?? true), + }); + }} + /> + { [`test-monitor-4-${location1.id}`]: { configId: 'test-monitor-4', monitorQueryId: 'test-monitor-4', - location: location1.id, + locationId: location1.id, }, }, }, diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/actions.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/actions.ts index 413c94c0fbd7b..e004b5a34396a 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/actions.ts @@ -5,24 +5,21 @@ * 2.0. */ -import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import { DEFAULT_ALERT_RESPONSE } from '../../../../../common/types/default_alerts'; import { createAsyncAction } from '../utils/actions'; -export const getDefaultAlertingAction = createAsyncAction< - void, - { statusRule: Rule; tlsRule: Rule } ->('getDefaultAlertingAction'); +export const getDefaultAlertingAction = createAsyncAction( + 'getDefaultAlertingAction' +); -export const enableDefaultAlertingAction = createAsyncAction< - void, - { statusRule: Rule; tlsRule: Rule } ->('enableDefaultAlertingAction'); +export const enableDefaultAlertingAction = createAsyncAction( + 'enableDefaultAlertingAction' +); -export const enableDefaultAlertingSilentlyAction = createAsyncAction< - void, - { statusRule: Rule; tlsRule: Rule } ->('enableDefaultAlertingSilentlyAction'); +export const enableDefaultAlertingSilentlyAction = createAsyncAction( + 'enableDefaultAlertingSilentlyAction' +); -export const updateDefaultAlertingAction = createAsyncAction( +export const updateDefaultAlertingAction = createAsyncAction( 'updateDefaultAlertingAction' ); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/api.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/api.ts index ee42d9e1b2508..909976e1f2848 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/api.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/api.ts @@ -5,18 +5,18 @@ * 2.0. */ -import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; import { SYNTHETICS_API_URLS } from '../../../../../common/constants'; +import { DEFAULT_ALERT_RESPONSE } from '../../../../../common/types/default_alerts'; import { apiService } from '../../../../utils/api_service'; -export async function getDefaultAlertingAPI(): Promise<{ statusRule: Rule; tlsRule: Rule }> { +export async function getDefaultAlertingAPI(): Promise { return apiService.get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING); } -export async function enableDefaultAlertingAPI(): Promise<{ statusRule: Rule; tlsRule: Rule }> { +export async function enableDefaultAlertingAPI(): Promise { return apiService.post(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING); } -export async function updateDefaultAlertingAPI(): Promise { +export async function updateDefaultAlertingAPI(): Promise { return apiService.put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING); } diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/index.ts index 4b8c6688adc1e..7dc44fa1dbf0f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/alert_rules/index.ts @@ -6,7 +6,7 @@ */ import { createReducer } from '@reduxjs/toolkit'; -import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import { DEFAULT_ALERT_RESPONSE } from '../../../../../common/types/default_alerts'; import { IHttpSerializedFetchError } from '..'; import { enableDefaultAlertingAction, @@ -16,7 +16,7 @@ import { } from './actions'; export interface DefaultAlertingState { - data?: { statusRule: Rule; tlsRule: Rule }; + data?: DEFAULT_ALERT_RESPONSE; success: boolean | null; loading: boolean; error: IHttpSerializedFetchError | null; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/settings/api.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/settings/api.ts index 7949cd5120f79..81925415ed3b3 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/settings/api.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/settings/api.ts @@ -36,9 +36,17 @@ export const getDynamicSettings = async (): Promise => { export const setDynamicSettings = async ({ settings, }: SaveApiRequest): Promise => { + const newSettings: DynamicSettings = { + certAgeThreshold: settings.certAgeThreshold, + certExpirationThreshold: settings.certExpirationThreshold, + defaultConnectors: settings.defaultConnectors, + defaultEmail: settings.defaultEmail, + defaultTLSRuleEnabled: settings.defaultTLSRuleEnabled, + defaultStatusRuleEnabled: settings.defaultStatusRuleEnabled, + }; return await apiService.put( SYNTHETICS_API_URLS.DYNAMIC_SETTINGS, - settings, + newSettings, DynamicSettingsSaveCodec, { version: '2023-10-31', diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts index b0ce8c17c6d0c..47221ff7020d5 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.test.ts @@ -274,6 +274,7 @@ describe('setRecoveredAlertsContext', () => { alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id', monitorName: 'test-monitor', recoveryReason: 'the monitor has been deleted', + 'kibana.alert.reason': 'the monitor has been deleted', recoveryStatus: 'has been deleted', monitorUrl: '(unavailable)', monitorUrlLabel: 'URL', @@ -350,6 +351,7 @@ describe('setRecoveredAlertsContext', () => { alertDetailsUrl: 'https://localhost:5601/app/observability/alerts/alert-id', monitorName: 'test-monitor', recoveryReason: 'this location has been removed from the monitor', + 'kibana.alert.reason': 'this location has been removed from the monitor', recoveryStatus: 'has recovered', stateId: '123456', status: 'recovered', @@ -421,6 +423,8 @@ describe('setRecoveredAlertsContext', () => { status: 'up', recoveryReason: 'the monitor is now up again. It ran successfully at Feb 26, 2023 @ 00:00:00.000', + 'kibana.alert.reason': + 'the monitor is now up again. It ran successfully at Feb 26, 2023 @ 00:00:00.000', recoveryStatus: 'is now up', locationId: location, checkedAt: 'Feb 26, 2023 @ 00:00:00.000', diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts index 25d2265ff3ff6..549bbcff4cdfe 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/common.ts @@ -19,6 +19,7 @@ import { i18n } from '@kbn/i18n'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { legacyExperimentalFieldMap, ObservabilityUptimeAlert } from '@kbn/alerts-as-data-utils'; import { PublicAlertsClient } from '@kbn/alerting-plugin/server/alerts_client/types'; +import { ALERT_REASON } from '@kbn/rule-data-utils'; import { syntheticsRuleFieldMap } from '../../common/rules/synthetics_rule_field_map'; import { combineFiltersAndUserSearch, stringifyKueries } from '../../common/lib'; import { @@ -298,6 +299,7 @@ export const setRecoveredAlertsContext = ({ linkMessage, ...(isUp ? { status: 'up' } : {}), ...(recoveryReason ? { [RECOVERY_REASON]: recoveryReason } : {}), + ...(recoveryReason ? { [ALERT_REASON]: recoveryReason } : {}), ...(basePath && spaceId && alertUuid ? { [ALERT_DETAILS_URL]: getAlertDetailsUrl(basePath, spaceId, alertUuid) } : {}), diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts index 36ad8783f44ad..4d5ab04d6c10f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/status_rule_executor.ts @@ -79,7 +79,7 @@ export class StatusRuleExecutor { monitorLocationMap, projectMonitorsCount, monitorQueryIdToConfigIdMap, - } = processMonitors(this.monitors, this.server, this.soClient, this.syntheticsMonitorClient); + } = processMonitors(this.monitors); return { enabledMonitorQueryIds, diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts index 10c4ae24a1bf2..4c5766b34738f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts @@ -74,7 +74,7 @@ export class TLSRuleExecutor { monitorLocationMap, projectMonitorsCount, monitorQueryIdToConfigIdMap, - } = processMonitors(this.monitors, this.server, this.soClient, this.syntheticsMonitorClient); + } = processMonitors(this.monitors); return { enabledMonitorQueryIds, diff --git a/x-pack/plugins/observability_solution/synthetics/server/queries/query_monitor_status.ts b/x-pack/plugins/observability_solution/synthetics/server/queries/query_monitor_status.ts index 4e2873ce77218..2bed9fdb5f643 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/queries/query_monitor_status.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/queries/query_monitor_status.ts @@ -201,7 +201,7 @@ export async function queryMonitorStatus( configId: `${monitorQueryIdToConfigIdMap[queryId]}`, monitorQueryId: queryId, status: 'unknown', - location: loc, + locationId: loc, }; }); } diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/certs/get_certificates.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/certs/get_certificates.ts index b6b6ad5aec85d..5d6fc1ab61ff3 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/certs/get_certificates.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/certs/get_certificates.ts @@ -34,13 +34,7 @@ export const getSyntheticsCertsRoute: SyntheticsRestApiRouteFactory< to: schema.maybe(schema.string()), }), }, - handler: async ({ - request, - syntheticsEsClient, - savedObjectsClient, - server, - syntheticsMonitorClient, - }) => { + handler: async ({ request, syntheticsEsClient, savedObjectsClient }) => { const queryParams = request.query; const monitors = await getAllMonitors({ @@ -57,12 +51,7 @@ export const getSyntheticsCertsRoute: SyntheticsRestApiRouteFactory< }; } - const { enabledMonitorQueryIds } = processMonitors( - monitors, - server, - savedObjectsClient, - syntheticsMonitorClient - ); + const { enabledMonitorQueryIds } = processMonitors(monitors); const data = await getSyntheticsCerts({ ...queryParams, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts index 38606b4d2865f..eb94f095be177 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts @@ -7,6 +7,7 @@ import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { FindActionResult } from '@kbn/actions-plugin/server'; +import { DynamicSettingsAttributes } from '../../runtime_types/settings'; import { savedObjectsAdapter } from '../../saved_objects'; import { populateAlertActions } from '../../../common/rules/alert_actions'; import { @@ -19,12 +20,12 @@ import { SYNTHETICS_STATUS_RULE, SYNTHETICS_TLS_RULE, } from '../../../common/constants/synthetics_alerts'; - -type DefaultRuleType = typeof SYNTHETICS_STATUS_RULE | typeof SYNTHETICS_TLS_RULE; +import { DefaultRuleType } from '../../../common/types/default_alerts'; export class DefaultAlertService { context: UptimeRequestHandlerContext; soClient: SavedObjectsClientContract; server: SyntheticsServerSetup; + settings?: DynamicSettingsAttributes; constructor( context: UptimeRequestHandlerContext, @@ -36,7 +37,16 @@ export class DefaultAlertService { this.soClient = soClient; } + async getSettings() { + if (!this.settings) { + this.settings = await savedObjectsAdapter.getSyntheticsDynamicSettings(this.soClient); + } + return this.settings; + } + async setupDefaultAlerts() { + this.settings = await this.getSettings(); + const [statusRule, tlsRule] = await Promise.allSettled([ this.setupStatusRule(), this.setupTlsRule(), @@ -50,12 +60,15 @@ export class DefaultAlertService { } return { - statusRule: statusRule.status === 'fulfilled' ? statusRule.value : null, - tlsRule: tlsRule.status === 'fulfilled' ? tlsRule.value : null, + statusRule: statusRule.status === 'fulfilled' && statusRule.value ? statusRule.value : null, + tlsRule: tlsRule.status === 'fulfilled' && tlsRule.value ? tlsRule.value : null, }; } setupStatusRule() { + if (this.settings?.defaultStatusRuleEnabled === false) { + return; + } return this.createDefaultAlertIfNotExist( SYNTHETICS_STATUS_RULE, `Synthetics status internal rule`, @@ -64,6 +77,9 @@ export class DefaultAlertService { } setupTlsRule() { + if (this.settings?.defaultTLSRuleEnabled === false) { + return; + } return this.createDefaultAlertIfNotExist( SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, @@ -78,7 +94,7 @@ export class DefaultAlertService { options: { page: 1, perPage: 1, - filter: `alert.attributes.alertTypeId:(${ruleType})`, + filter: `alert.attributes.alertTypeId:(${ruleType}) AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"`, }, }); @@ -88,6 +104,7 @@ export class DefaultAlertService { const { actions = [], systemActions = [], ...alert } = data[0]; return { ...alert, actions: [...actions, ...systemActions], ruleTypeId: alert.alertTypeId }; } + async createDefaultAlertIfNotExist(ruleType: DefaultRuleType, name: string, interval: string) { const alert = await this.getExistingAlert(ruleType); if (alert) { @@ -121,11 +138,30 @@ export class DefaultAlertService { }; } - updateStatusRule() { - return this.updateDefaultAlert(SYNTHETICS_STATUS_RULE, `Synthetics status internal rule`, '1m'); + async updateStatusRule(enabled?: boolean) { + if (enabled) { + return this.updateDefaultAlert( + SYNTHETICS_STATUS_RULE, + `Synthetics status internal rule`, + '1m' + ); + } else { + const rulesClient = (await this.context.alerting)?.getRulesClient(); + await rulesClient.bulkDeleteRules({ + filter: `alert.attributes.alertTypeId:"${SYNTHETICS_STATUS_RULE}" AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"`, + }); + } } - updateTlsRule() { - return this.updateDefaultAlert(SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, '1m'); + + async updateTlsRule(enabled?: boolean) { + if (enabled) { + return this.updateDefaultAlert(SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, '1m'); + } else { + const rulesClient = (await this.context.alerting)?.getRulesClient(); + await rulesClient.bulkDeleteRules({ + filter: `alert.attributes.alertTypeId:"${SYNTHETICS_TLS_RULE}" AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"`, + }); + } } async updateDefaultAlert(ruleType: DefaultRuleType, name: string, interval: string) { @@ -195,14 +231,15 @@ export class DefaultAlertService { async getActionConnectors() { const actionsClient = (await this.context.actions)?.getActionsClient(); - - const settings = await savedObjectsAdapter.getSyntheticsDynamicSettings(this.soClient); + if (!this.settings) { + this.settings = await savedObjectsAdapter.getSyntheticsDynamicSettings(this.soClient); + } let actionConnectors: FindActionResult[] = []; try { actionConnectors = await actionsClient.getAll(); } catch (e) { this.server.logger.error(e); } - return { actionConnectors, settings }; + return { actionConnectors, settings: this.settings }; } } diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/enable_default_alert.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/enable_default_alert.ts index db403bd6bcd54..4a78ee67ddd1f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/enable_default_alert.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/enable_default_alert.ts @@ -8,12 +8,13 @@ import { DefaultAlertService } from './default_alert_service'; import { SyntheticsRestApiRouteFactory } from '../types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { DEFAULT_ALERT_RESPONSE } from '../../../common/types/default_alerts'; export const enableDefaultAlertingRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'POST', path: SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING, validate: {}, - handler: async ({ context, server, savedObjectsClient }): Promise => { + handler: async ({ context, server, savedObjectsClient }): Promise => { const defaultAlertService = new DefaultAlertService(context, server, savedObjectsClient); return defaultAlertService.setupDefaultAlerts(); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/get_default_alert.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/get_default_alert.ts index 01d3891d96dbb..7437be6997803 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/get_default_alert.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/get_default_alert.ts @@ -12,19 +12,20 @@ import { import { DefaultAlertService } from './default_alert_service'; import { SyntheticsRestApiRouteFactory } from '../types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { DEFAULT_ALERT_RESPONSE } from '../../../common/types/default_alerts'; export const getDefaultAlertingRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'GET', path: SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING, validate: {}, - handler: async ({ context, server, savedObjectsClient }): Promise => { + handler: async ({ context, server, savedObjectsClient }): Promise => { const defaultAlertService = new DefaultAlertService(context, server, savedObjectsClient); const statusRule = defaultAlertService.getExistingAlert(SYNTHETICS_STATUS_RULE); const tlsRule = defaultAlertService.getExistingAlert(SYNTHETICS_TLS_RULE); const [status, tls] = await Promise.all([statusRule, tlsRule]); return { - statusRule: status, - tlsRule: tls, + statusRule: status || null, + tlsRule: tls || null, }; }, }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/update_default_alert.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/update_default_alert.ts index bef006d02b87e..406eaef0aad14 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/update_default_alert.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/update_default_alert.ts @@ -8,21 +8,41 @@ import { DefaultAlertService } from './default_alert_service'; import { SyntheticsRestApiRouteFactory } from '../types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { savedObjectsAdapter } from '../../saved_objects'; +import { DEFAULT_ALERT_RESPONSE } from '../../../common/types/default_alerts'; export const updateDefaultAlertingRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'PUT', path: SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING, validate: {}, - handler: async ({ context, server, savedObjectsClient }): Promise => { + handler: async ({ + request, + context, + server, + savedObjectsClient, + }): Promise => { const defaultAlertService = new DefaultAlertService(context, server, savedObjectsClient); + const { defaultTLSRuleEnabled, defaultStatusRuleEnabled } = + await savedObjectsAdapter.getSyntheticsDynamicSettings(savedObjectsClient); - const [statusRule, tlsRule] = await Promise.all([ - defaultAlertService.updateStatusRule(), - defaultAlertService.updateTlsRule(), - ]); - return { - statusRule, - tlsRule, - }; + const updateStatusRulePromise = defaultAlertService.updateStatusRule(defaultStatusRuleEnabled); + const updateTLSRulePromise = defaultAlertService.updateTlsRule(defaultTLSRuleEnabled); + + try { + const [statusRule, tlsRule] = await Promise.all([ + updateStatusRulePromise, + updateTLSRulePromise, + ]); + return { + statusRule: statusRule || null, + tlsRule: tlsRule || null, + }; + } catch (e) { + server.logger.error(`Error updating default alerting rules: ${e}`); + return { + statusRule: null, + tlsRule: null, + }; + } }, }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.test.ts index 33cf9746fb6e7..c850267245b22 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.test.ts @@ -593,25 +593,25 @@ describe('current status route', () => { pendingConfigs: { 'id3-Asia/Pacific - Japan': { configId: 'id3', - location: 'Asia/Pacific - Japan', + locationId: 'Asia/Pacific - Japan', monitorQueryId: 'project-monitor-id', status: 'unknown', }, 'id3-Europe - Germany': { configId: 'id3', - location: 'Europe - Germany', + locationId: 'Europe - Germany', monitorQueryId: 'project-monitor-id', status: 'unknown', }, 'id4-Asia/Pacific - Japan': { configId: 'id4', - location: 'Asia/Pacific - Japan', + locationId: 'Asia/Pacific - Japan', monitorQueryId: 'id4', status: 'unknown', }, 'id4-Europe - Germany': { configId: 'id4', - location: 'Europe - Germany', + locationId: 'Europe - Germany', monitorQueryId: 'id4', status: 'unknown', }, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.ts index b6373758ac802..d114955fc9213 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_status/overview_status.ts @@ -36,7 +36,7 @@ export function periodToMs(schedule: { number: string; unit: Unit }) { * @returns The counts of up/down/disabled monitor by location, and a map of each monitor:location status. */ export async function getStatus(context: RouteContext, params: OverviewStatusQuery) { - const { syntheticsEsClient, syntheticsMonitorClient, savedObjectsClient, server } = context; + const { syntheticsEsClient, savedObjectsClient } = context; const { query, scopeStatusByLocation = true } = params; @@ -77,13 +77,7 @@ export async function getStatus(context: RouteContext, params: OverviewStatusQue disabledMonitorsCount, projectMonitorsCount, monitorQueryIdToConfigIdMap, - } = processMonitors( - allMonitors, - server, - savedObjectsClient, - syntheticsMonitorClient, - queryLocations - ); + } = processMonitors(allMonitors, queryLocations); // Account for locations filter const listOfLocationAfterFilter = diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/dynamic_settings.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/dynamic_settings.ts index 07641ef826de3..e9b9bb6da931f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/dynamic_settings.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/dynamic_settings.ts @@ -22,7 +22,7 @@ export const createGetDynamicSettingsRoute: SyntheticsRestApiRouteFactory< handler: async ({ savedObjectsClient }) => { const dynamicSettingsAttributes: DynamicSettingsAttributes = await savedObjectsAdapter.getSyntheticsDynamicSettings(savedObjectsClient); - return fromAttribute(dynamicSettingsAttributes); + return fromSettingsAttribute(dynamicSettingsAttributes); }, }); @@ -42,16 +42,20 @@ export const createPostDynamicSettingsRoute: SyntheticsRestApiRouteFactory = () ...newSettings, } as DynamicSettingsAttributes); - return fromAttribute(attr as DynamicSettingsAttributes); + return fromSettingsAttribute(attr as DynamicSettingsAttributes); }, }); -const fromAttribute = (attr: DynamicSettingsAttributes) => { +export const fromSettingsAttribute = ( + attr: DynamicSettingsAttributes +): DynamicSettingsAttributes => { return { certExpirationThreshold: attr.certExpirationThreshold, certAgeThreshold: attr.certAgeThreshold, defaultConnectors: attr.defaultConnectors, defaultEmail: attr.defaultEmail, + defaultStatusRuleEnabled: attr.defaultStatusRuleEnabled ?? true, + defaultTLSRuleEnabled: attr.defaultTLSRuleEnabled ?? true, }; }; @@ -72,6 +76,8 @@ export const DynamicSettingsSchema = schema.object({ certAgeThreshold: schema.maybe(schema.number({ min: 1, validate: validateInteger })), certExpirationThreshold: schema.maybe(schema.number({ min: 1, validate: validateInteger })), defaultConnectors: schema.maybe(schema.arrayOf(schema.string())), + defaultStatusRuleEnabled: schema.maybe(schema.boolean()), + defaultTLSRuleEnabled: schema.maybe(schema.boolean()), defaultEmail: schema.maybe( schema.object({ to: schema.arrayOf(schema.string()), diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts index 3a9c3b064bc0f..85ab73eb10a6e 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/suggestions/route.ts @@ -5,7 +5,7 @@ * 2.0. */ import { SyntheticsRestApiRouteFactory } from '../types'; -import { syntheticsMonitorType } from '../../../common/types/saved_objects'; +import { monitorAttributes, syntheticsMonitorType } from '../../../common/types/saved_objects'; import { ConfigKey, MonitorFiltersResult, @@ -30,7 +30,7 @@ interface AggsResponse { projectsAggs: { buckets: Buckets; }; - monitorTypeAggs: { + monitorTypesAggs: { buckets: Buckets; }; monitorIdsAggs: { @@ -85,7 +85,7 @@ export const getSyntheticsSuggestionsRoute: SyntheticsRestApiRouteFactory< searchFields: SEARCH_FIELDS, }); - const { monitorTypeAggs, tagsAggs, locationsAggs, projectsAggs, monitorIdsAggs } = + const { monitorTypesAggs, tagsAggs, locationsAggs, projectsAggs, monitorIdsAggs } = (data?.aggregations as AggsResponse) ?? {}; const allLocationsMap = new Map(allLocations.map((obj) => [obj.id, obj.label])); @@ -114,7 +114,7 @@ export const getSyntheticsSuggestionsRoute: SyntheticsRestApiRouteFactory< count, })) ?? [], monitorTypes: - monitorTypeAggs?.buckets?.map(({ key, doc_count: count }) => ({ + monitorTypesAggs?.buckets?.map(({ key, doc_count: count }) => ({ label: key, value: key, count, @@ -129,35 +129,42 @@ export const getSyntheticsSuggestionsRoute: SyntheticsRestApiRouteFactory< const aggs = { tagsAggs: { terms: { - field: `${syntheticsMonitorType}.attributes.${ConfigKey.TAGS}`, + field: `${monitorAttributes}.${ConfigKey.TAGS}`, size: 10000, exclude: [''], }, }, monitorTypeAggs: { terms: { - field: `${syntheticsMonitorType}.attributes.${ConfigKey.MONITOR_TYPE}.keyword`, + field: `${monitorAttributes}.${ConfigKey.MONITOR_TYPE}.keyword`, size: 10000, exclude: [''], }, }, locationsAggs: { terms: { - field: `${syntheticsMonitorType}.attributes.${ConfigKey.LOCATIONS}.id`, + field: `${monitorAttributes}.${ConfigKey.LOCATIONS}.id`, size: 10000, exclude: [''], }, }, projectsAggs: { terms: { - field: `${syntheticsMonitorType}.attributes.${ConfigKey.PROJECT_ID}`, + field: `${monitorAttributes}.${ConfigKey.PROJECT_ID}`, + size: 10000, + exclude: [''], + }, + }, + monitorTypesAggs: { + terms: { + field: `${monitorAttributes}.${ConfigKey.MONITOR_TYPE}.keyword`, size: 10000, exclude: [''], }, }, monitorIdsAggs: { terms: { - field: `${syntheticsMonitorType}.attributes.${ConfigKey.MONITOR_QUERY_ID}`, + field: `${monitorAttributes}.${ConfigKey.MONITOR_QUERY_ID}`, size: 10000, exclude: [''], }, diff --git a/x-pack/plugins/observability_solution/synthetics/server/runtime_types/settings.ts b/x-pack/plugins/observability_solution/synthetics/server/runtime_types/settings.ts index 4aff410088683..def512e2f73a8 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/runtime_types/settings.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/runtime_types/settings.ts @@ -25,6 +25,8 @@ export const DynamicSettingsAttributesCodec = t.intersection([ }), t.partial({ defaultEmail: DefaultEmailCodec, + defaultStatusRuleEnabled: t.boolean, + defaultTLSRuleEnabled: t.boolean, }), ]); diff --git a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts index 419661d832b1d..9b4a365941a7d 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts @@ -12,6 +12,7 @@ import { } from '@kbn/core/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import { fromSettingsAttribute } from '../routes/settings/dynamic_settings'; import { syntheticsSettings, syntheticsSettingsObjectId, @@ -62,7 +63,7 @@ export const savedObjectsAdapter = { syntheticsSettingsObjectType, syntheticsSettingsObjectId ); - return obj?.attributes ?? DYNAMIC_SETTINGS_DEFAULT_ATTRIBUTES; + return fromSettingsAttribute(obj?.attributes ?? DYNAMIC_SETTINGS_DEFAULT_ATTRIBUTES); } catch (getErr) { if (SavedObjectsErrorHelpers.isNotFoundError(getErr)) { // If the object doesn't exist, check to see if uptime settings exist diff --git a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts index f17494bac5487..313063662f9d4 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts @@ -6,46 +6,11 @@ */ import { processMonitors } from './get_all_monitors'; -import { mockEncryptedSO } from '../../synthetics_service/utils/mocks'; -import { loggerMock } from '@kbn/logging-mocks'; -import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; -import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; -import { SyntheticsService } from '../../synthetics_service/synthetics_service'; import * as getLocations from '../../synthetics_service/get_all_locations'; -import { SyntheticsServerSetup } from '../../types'; describe('processMonitors', () => { - const mockEsClient = { - search: jest.fn(), - }; - const logger = loggerMock.create(); - const soClient = savedObjectsClientMock.create(); - - const serverMock: SyntheticsServerSetup = { - logger, - syntheticsEsClient: mockEsClient, - authSavedObjectsClient: soClient, - config: { - service: { - username: 'dev', - password: '12345', - manifestUrl: 'http://localhost:8080/api/manifest', - }, - }, - spaces: { - spacesService: { - getSpaceId: jest.fn().mockReturnValue('test-space'), - }, - }, - encryptedSavedObjects: mockEncryptedSO(), - } as unknown as SyntheticsServerSetup; - - const syntheticsService = new SyntheticsService(serverMock); - - const monitorClient = new SyntheticsMonitorClient(syntheticsService, serverMock); - it('should return a processed data', async () => { - const result = processMonitors(testMonitors, serverMock, soClient, monitorClient); + const result = processMonitors(testMonitors); expect(result).toEqual({ allIds: [ 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', @@ -81,7 +46,7 @@ describe('processMonitors', () => { it('should return a processed data where location label is missing', async () => { testMonitors[0].attributes.locations[0].label = undefined; - const result = processMonitors(testMonitors, serverMock, soClient, monitorClient); + const result = processMonitors(testMonitors); expect(result).toEqual({ allIds: [ 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', @@ -155,7 +120,7 @@ describe('processMonitors', () => { ) ); - const result = processMonitors(testMonitors, serverMock, soClient, monitorClient); + const result = processMonitors(testMonitors); expect(result).toEqual({ allIds: [ 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', diff --git a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts index c742bf26176ad..e19bc529695ae 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts @@ -11,7 +11,6 @@ import { SavedObjectsFindResult, } from '@kbn/core-saved-objects-api-server'; import { intersection } from 'lodash'; -import { SyntheticsServerSetup } from '../../types'; import { syntheticsMonitorType } from '../../../common/types/saved_objects'; import { periodToMs } from '../../routes/overview_status/overview_status'; import { @@ -19,7 +18,6 @@ import { EncryptedSyntheticsMonitorAttributes, SourceType, } from '../../../common/runtime_types'; -import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; export const getAllMonitors = async ({ soClient, @@ -57,9 +55,6 @@ export const getAllMonitors = async ({ export const processMonitors = ( allMonitors: Array>, - server: SyntheticsServerSetup, - soClient: SavedObjectsClientContract, - syntheticsMonitorClient: SyntheticsMonitorClient, queryLocations?: string[] | string ) => { /** diff --git a/x-pack/plugins/observability_solution/synthetics/server/types.ts b/x-pack/plugins/observability_solution/synthetics/server/types.ts index 6209055fc6778..2e1fac95023b9 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/types.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/types.ts @@ -17,6 +17,7 @@ import { Logger, SavedObjectsClientContract, } from '@kbn/core/server'; +import { PluginStartContract as AlertingPluginStart } from '@kbn/alerting-plugin/server'; import { SharePluginSetup } from '@kbn/share-plugin/server'; import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; @@ -85,6 +86,7 @@ export interface SyntheticsPluginsStartDependencies { taskManager: TaskManagerStartContract; telemetry: TelemetryPluginStart; spaces?: SpacesPluginStart; + alerting: AlertingPluginStart; } export type UptimeRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/plugins/observability_solution/uptime/tsconfig.json b/x-pack/plugins/observability_solution/uptime/tsconfig.json index 041ff7a84a4fb..f797a5a7f930d 100644 --- a/x-pack/plugins/observability_solution/uptime/tsconfig.json +++ b/x-pack/plugins/observability_solution/uptime/tsconfig.json @@ -77,7 +77,7 @@ "@kbn/react-kibana-context-render", "@kbn/react-kibana-context-theme", "@kbn/react-kibana-mount", - "@kbn/deeplinks-observability" + "@kbn/deeplinks-observability", ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts b/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts index dd4db37f4adc3..0064ef490bb75 100644 --- a/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts +++ b/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { omit } from 'lodash'; import { HTTPFields } from '@kbn/synthetics-plugin/common/runtime_types'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '@kbn/synthetics-plugin/common/constants/settings_defaults'; import { FtrProviderContext } from '../../ftr_provider_context'; import { getFixtureJson } from './helper/get_fixture_json'; @@ -41,13 +42,19 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async () => { httpMonitorJson = _httpMonitorJson; await kibanaServer.savedObjects.cleanStandardList(); + await supertest + .put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS) + .set('kbn-xsrf', 'true') + .send(DYNAMIC_SETTINGS_DEFAULTS) + .expect(200); }); it('returns the created alerted when called', async () => { const apiResponse = await supertest .post(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) .set('kbn-xsrf', 'true') - .send({}); + .send() + .expect(200); const omitFields = [ 'id', @@ -76,6 +83,96 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse).eql(omitMonitorKeys(newMonitor)); + await retry.tryForTime(30 * 1000, async () => { + const res = await supertest + .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(res.body.statusRule.ruleTypeId).eql('xpack.synthetics.alerts.monitorStatus'); + expect(res.body.tlsRule.ruleTypeId).eql('xpack.synthetics.alerts.tls'); + }); + }); + + it('deletes (and recreates) the default rule when settings are updated', async () => { + const newMonitor = httpMonitorJson; + + const { body: apiResponse } = await addMonitorAPI(newMonitor); + + expect(apiResponse).eql(omitMonitorKeys(newMonitor)); + + await retry.tryForTime(30 * 1000, async () => { + const res = await supertest + .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(res.body.statusRule.ruleTypeId).eql('xpack.synthetics.alerts.monitorStatus'); + expect(res.body.tlsRule.ruleTypeId).eql('xpack.synthetics.alerts.tls'); + }); + const settings = await supertest + .put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS) + .set('kbn-xsrf', 'true') + .send({ + defaultStatusRuleEnabled: false, + defaultTLSRuleEnabled: false, + }); + + expect(settings.body.defaultStatusRuleEnabled).eql(false); + expect(settings.body.defaultTLSRuleEnabled).eql(false); + + await supertest + .put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + await retry.tryForTime(30 * 1000, async () => { + const res = await supertest + .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(res.body.statusRule).eql(null); + expect(res.body.tlsRule).eql(null); + }); + + const settings2 = await supertest + .put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS) + .set('kbn-xsrf', 'true') + .send({ + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }) + .expect(200); + + expect(settings2.body.defaultStatusRuleEnabled).eql(true); + expect(settings2.body.defaultTLSRuleEnabled).eql(true); + + await supertest + .put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + await retry.tryForTime(30 * 1000, async () => { + const res = await supertest + .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(res.body.statusRule.ruleTypeId).eql('xpack.synthetics.alerts.monitorStatus'); + expect(res.body.tlsRule.ruleTypeId).eql('xpack.synthetics.alerts.tls'); + }); + }); + + it('doesnt throw errors when rule has already been deleted', async () => { + const newMonitor = httpMonitorJson; + + const { body: apiResponse } = await addMonitorAPI(newMonitor); + + expect(apiResponse).eql(omitMonitorKeys(newMonitor)); + await retry.tryForTime(30 * 1000, async () => { const res = await supertest .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) @@ -84,6 +181,49 @@ export default function ({ getService }: FtrProviderContext) { expect(res.body.statusRule.ruleTypeId).eql('xpack.synthetics.alerts.monitorStatus'); expect(res.body.tlsRule.ruleTypeId).eql('xpack.synthetics.alerts.tls'); }); + + const settings = await supertest + .put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS) + .set('kbn-xsrf', 'true') + .send({ + defaultStatusRuleEnabled: false, + defaultTLSRuleEnabled: false, + }); + + expect(settings.body.defaultStatusRuleEnabled).eql(false); + expect(settings.body.defaultTLSRuleEnabled).eql(false); + + await supertest + .put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .send(); + + await retry.tryForTime(30 * 1000, async () => { + const res = await supertest + .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(res.body.statusRule).eql(null); + expect(res.body.tlsRule).eql(null); + }); + + // call api again with the same settings, make sure its 200 + await supertest + .put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + await retry.tryForTime(30 * 1000, async () => { + const res = await supertest + .get(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(res.body.statusRule).eql(null); + expect(res.body.tlsRule).eql(null); + }); }); }); } From df5810f146c2c7f7542fef92b69669c9d6feda41 Mon Sep 17 00:00:00 2001 From: Maxim Kholod Date: Thu, 22 Aug 2024 17:38:03 +0200 Subject: [PATCH 42/45] [Cloud Security] fix 'show 1 alerts' bug (#191062) ## Summary Using plural from `react-intl` in the Session View for `Show N alerts` text - part of https://github.com/elastic/security-team/issues/10316 - fixes https://github.com/elastic/kibana/issues/189880 --- .../process_tree_alerts_filter/index.test.tsx | 13 +++++++++++++ .../components/process_tree_alerts_filter/index.tsx | 5 +++-- x-pack/plugins/translations/translations/fr-FR.json | 1 - x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx index 45ced9a4b7c18..cb26c2ecd03a3 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx @@ -53,6 +53,19 @@ describe('ProcessTreeAlertsFiltersFilter component', () => { expect(filterCountStatus).toBeTruthy(); }); + it('should show pluralise correctly', async () => { + renderResult = mockedContext.render( + + ); + + const filterCountStatus = renderResult.queryByTestId( + 'sessionView:sessionViewAlertDetailsFilterStatus' + ); + + expect(filterCountStatus).toHaveTextContent('Showing 1 alert'); + expect(filterCountStatus).toBeTruthy(); + }); + it('should call onAlertEventCategorySelected with alert category when filter item is clicked ', () => { const mockAlertEventCategorySelectedEvent = jest.fn(); renderResult = mockedContext.render( diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.tsx index cf2af552671cd..a52fcc09cc4ea 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts_filter/index.tsx @@ -124,9 +124,10 @@ export const ProcessTreeAlertsFilter = ({ {totalAlertsCount === filteredAlertsCount && ( {totalAlertsCount}
, + count: totalAlertsCount, + bold: (str: string) => {str}, }} /> )} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 1ed13e47986fb..76b4aa11baf89 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -41491,7 +41491,6 @@ "xpack.sessionView.alertFilteredCountStatusLabel": " Affichage de {count} alertes", "xpack.sessionView.alerts": "Alertes", "xpack.sessionView.alertsLoadMoreButton": "Charger plus d'alertes", - "xpack.sessionView.alertTotalCountStatusLabel": "Affichage de {count} alertes", "xpack.sessionView.backToInvestigatedAlert": "Retour à l'alerte examinée", "xpack.sessionView.blockedBadge": "Bloqué", "xpack.sessionView.childProcesses": "Processus enfants", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b4224908c64a5..172efd4a34334 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -41475,7 +41475,6 @@ "xpack.sessionView.alertFilteredCountStatusLabel": " {count}件のアラートを表示しています", "xpack.sessionView.alerts": "アラート", "xpack.sessionView.alertsLoadMoreButton": "その他のアラートを読み込む", - "xpack.sessionView.alertTotalCountStatusLabel": "{count}件のアラートを表示しています", "xpack.sessionView.backToInvestigatedAlert": "調査されたアラートに戻る", "xpack.sessionView.blockedBadge": "ブロック", "xpack.sessionView.childProcesses": "子プロセス", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 78ff3faf194f1..cac113f4f420e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -41518,7 +41518,6 @@ "xpack.sessionView.alertFilteredCountStatusLabel": " 正在显示 {count} 个告警", "xpack.sessionView.alerts": "告警", "xpack.sessionView.alertsLoadMoreButton": "加载更多告警", - "xpack.sessionView.alertTotalCountStatusLabel": "正在显示 {count} 个告警", "xpack.sessionView.backToInvestigatedAlert": "返回到已调查告警", "xpack.sessionView.blockedBadge": "已阻止", "xpack.sessionView.childProcesses": "子进程", From 801c17f6d6020dd045d12efc3e7e67dd2a99d94d Mon Sep 17 00:00:00 2001 From: Maxim Kholod Date: Thu, 22 Aug 2024 17:38:25 +0200 Subject: [PATCH 43/45] [Cloud Security] fix responsiveness on the Benchmark Rules page (#191080) ## Summary It ain't much but it's honest work - fixes https://github.com/elastic/kibana/issues/181641 - part pf https://github.com/elastic/security-team/issues/10316 ## Screencast tbh before is not as bad as in the linked ticket, so we can also leave it as it is until we do responsiveness on this page properly. before [screencast-localhost_5601-2024.08.22-15_14_34.webm](https://github.com/user-attachments/assets/4f119203-6a91-4a52-9514-3489789649ce) after [screencast-localhost_5601-2024.08.22-15_05_32.webm](https://github.com/user-attachments/assets/36276e71-8d2d-4095-b2b6-e5524f6d0692) --- .../public/pages/rules/rules_counters.tsx | 2 +- .../public/pages/rules/rules_table_header.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx index ec8d4a653c222..dc8de41c0bf7b 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx @@ -272,7 +272,7 @@ export const RulesCounters = ({ ]; return ( - + {counters.map((counter) => ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx index 116bae053fd32..3350dc26156d5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx @@ -106,7 +106,7 @@ export const RulesTableHeader = ({ return ( - + From d673743aa422a079e75fd041513abfe3e0059074 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 22 Aug 2024 11:54:12 -0400 Subject: [PATCH 44/45] [Detection Engine] update alert assignment to disable unassign action if no assignees exist (#190937) ## Summary Addresses https://github.com/elastic/kibana/issues/177864 Disables the unassign alert action if no assignees exist. --- .../use_bulk_alert_assignees_items.test.tsx | 61 +++++++++++++++++++ .../use_bulk_alert_assignees_items.tsx | 7 ++- .../use_alert_assignees_actions.tsx | 11 +++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx index f3269fceb2a64..82e9b062d9a7d 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx @@ -39,6 +39,7 @@ const mockUserProfiles = [ const defaultProps: UseBulkAlertAssigneesItemsProps = { onAssigneesUpdate: () => {}, + alertAssignments: [], }; const mockAssigneeItems = [ @@ -170,6 +171,66 @@ describe('useBulkAlertAssigneesItems', () => { ); }); + it('should set unnasign alert action to disabled if no assignees exist', () => { + const mockSetAlertAssignees = jest.fn(); + (useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees); + const { result } = renderHook( + () => + useBulkAlertAssigneesItems({ + onAssigneesUpdate: () => {}, + alertAssignments: [], + }), + { + wrapper: TestProviders, + } + ); + + expect( + ( + result.current.alertAssigneesItems[0] as unknown as { + disable: boolean; + } + ).disable + ).toBeFalsy(); + expect( + ( + result.current.alertAssigneesItems[1] as unknown as { + disable: boolean; + } + ).disable + ).toBeTruthy(); + }); + + it('should set unnasign alert action to enabled if assignees exist', () => { + const mockSetAlertAssignees = jest.fn(); + (useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees); + const { result } = renderHook( + () => + useBulkAlertAssigneesItems({ + onAssigneesUpdate: () => {}, + alertAssignments: ['user1'], + }), + { + wrapper: TestProviders, + } + ); + + expect( + ( + result.current.alertAssigneesItems[0] as unknown as { + disable: boolean; + } + ).disable + ).toBeFalsy(); + expect( + ( + result.current.alertAssigneesItems[1] as unknown as { + disable: boolean; + } + ).disable + ).toBeFalsy(); + }); + it('should return 0 items for the VIEWER role', () => { (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx index 6e9c4cc0c871b..4c25665afa5ca 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx @@ -15,6 +15,7 @@ import type { RenderContentPanelProps, } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { isEmpty } from 'lodash/fp'; import { useLicense } from '../../../hooks/use_license'; import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { ASSIGNEES_PANEL_WIDTH } from '../../assignees/constants'; @@ -24,6 +25,7 @@ import { useSetAlertAssignees } from './use_set_alert_assignees'; export interface UseBulkAlertAssigneesItemsProps { onAssigneesUpdate?: () => void; + alertAssignments?: string[]; } export interface UseBulkAlertAssigneesPanel { @@ -36,6 +38,7 @@ export interface UseBulkAlertAssigneesPanel { export const useBulkAlertAssigneesItems = ({ onAssigneesUpdate, + alertAssignments, }: UseBulkAlertAssigneesItemsProps) => { const isPlatinumPlus = useLicense().isPlatinumPlus(); @@ -89,6 +92,7 @@ export const useBulkAlertAssigneesItems = ({ panel: 2, label: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE, disableOnQuery: true, + disable: false, }, { key: 'remove-all-alert-assignees', @@ -97,10 +101,11 @@ export const useBulkAlertAssigneesItems = ({ label: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE, disableOnQuery: true, onClick: onRemoveAllAssignees, + disable: alertAssignments ? isEmpty(alertAssignments) : false, }, ] : [], - [hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees] + [alertAssignments, hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees] ); const TitleContent = useMemo( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx index ff7e64a5e4dc8..84d9f8b5ca5db 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx @@ -31,6 +31,11 @@ export const useAlertAssigneesActions = ({ const { hasIndexWrite } = useAlertsPrivileges(); const alertId = ecsRowData._id; + const alertAssignments = useMemo( + () => ecsRowData?.kibana?.alert.workflow_assignee_ids ?? [], + [ecsRowData?.kibana?.alert.workflow_assignee_ids] + ); + const alertAssigneeData = useMemo(() => { return [ { @@ -39,7 +44,7 @@ export const useAlertAssigneesActions = ({ data: [ { field: ALERT_WORKFLOW_ASSIGNEE_IDS, - value: ecsRowData?.kibana?.alert.workflow_assignee_ids ?? [], + value: alertAssignments, }, ], ecs: { @@ -48,7 +53,7 @@ export const useAlertAssigneesActions = ({ }, }, ]; - }, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_assignee_ids]); + }, [alertId, ecsRowData._index, alertAssignments]); const onAssigneesUpdate = useCallback(() => { closePopover(); @@ -59,6 +64,7 @@ export const useAlertAssigneesActions = ({ const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ onAssigneesUpdate, + alertAssignments, }); const itemsToReturn: AlertTableContextMenuItem[] = useMemo( @@ -69,6 +75,7 @@ export const useAlertAssigneesActions = ({ 'data-test-subj': item['data-test-subj'], key: item.key, onClick: () => item.onClick?.(alertAssigneeData, false, noop, noop, noop), + disabled: item.disable, })), [alertAssigneeData, alertAssigneesItems] ); From 2d80214cf0729962a687db66676ddde2379ab560 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Thu, 22 Aug 2024 17:00:53 +0100 Subject: [PATCH 45/45] [Logs] Use central log sources setting in Logs Explorer as the default data source (#190438) ## Summary Implements https://github.com/elastic/logs-dev/issues/169. This uses the new central log sources setting as the default data source in the Logs Explorer. The `AllSelection` is now amended to use a set of indices, this `AllSelection` can be defined by the consumer (or falls back to a default). In the case of the Observability Logs Explorer this value is resolved from the setting and used as the `AllSelection` passed to the Logs Explorer controller. --- .../all_dataset_selection.ts | 19 ++++++++++----- .../hydrate_data_source_selection.ts | 7 ++++-- .../common/datasets/models/dataset.ts | 4 ++-- .../data_source_selector.stories.tsx | 2 +- .../data_source_selector.tsx | 7 +++++- .../state_machine/defaults.ts | 5 ++-- .../state_machine/state_machine.ts | 3 +-- .../state_machine/types.ts | 2 ++ .../sub_components/selector_footer.tsx | 6 +++-- .../components/data_source_selector/types.ts | 3 +++ .../components/data_source_selector/utils.tsx | 10 ++++---- .../public/controller/create_controller.ts | 16 +++++++++---- .../public/controller/public_state.ts | 6 +++-- .../custom_data_source_selector.tsx | 6 ++--- .../public/hooks/use_data_source_selection.ts | 5 +++- .../logs_explorer/public/index.ts | 1 + .../src/default_all_selection.ts | 10 ++++++++ .../logs_explorer_controller/src/defaults.ts | 5 ++-- .../logs_explorer_controller/src/index.ts | 1 + .../src/state_machine.ts | 3 +-- .../logs_explorer_controller/src/types.ts | 22 ++++++++++++++++- .../common/locators/all_datasets_locator.ts | 4 ++-- .../observability_logs_explorer/kibana.jsonc | 3 ++- .../public/components/alerts_popover.tsx | 6 +++-- .../public/components/discover_link.tsx | 8 +++---- .../public/routes/main/main_route.tsx | 14 +++++++++-- .../src/all_selection_service.ts | 23 ++++++++++++++++++ .../src/controller_service.ts | 2 +- .../src/defaults.ts | 2 ++ .../src/state_machine.ts | 24 ++++++++++++++++++- .../observability_logs_explorer/src/types.ts | 6 ++++- .../public/types.ts | 2 ++ .../observability_logs_explorer/tsconfig.json | 1 + 33 files changed, 186 insertions(+), 52 deletions(-) create mode 100644 x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/default_all_selection.ts create mode 100644 x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/all_selection_service.ts diff --git a/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/all_dataset_selection.ts b/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/all_dataset_selection.ts index 7c5631345eec6..1145f035b86b6 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/all_dataset_selection.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/all_dataset_selection.ts @@ -8,16 +8,18 @@ import { Dataset } from '../datasets'; import { DataSourceSelectionStrategy } from './types'; +const SELECTION_TYPE = 'all' as const; + export class AllDatasetSelection implements DataSourceSelectionStrategy { - selectionType: 'all'; + selectionType: typeof SELECTION_TYPE; selection: { dataset: Dataset; }; - private constructor() { - this.selectionType = 'all'; + private constructor({ indices }: { indices: string }) { + this.selectionType = SELECTION_TYPE; this.selection = { - dataset: Dataset.createAllLogsDataset(), + dataset: Dataset.createAllLogsDataset({ indices }), }; } @@ -30,8 +32,13 @@ export class AllDatasetSelection implements DataSourceSelectionStrategy { selectionType: this.selectionType, }; } + public static getLocatorPlainSelection() { + return { + selectionType: SELECTION_TYPE, + }; + } - public static create() { - return new AllDatasetSelection(); + public static create({ indices }: { indices: string }) { + return new AllDatasetSelection({ indices }); } } diff --git a/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/hydrate_data_source_selection.ts b/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/hydrate_data_source_selection.ts index ffc5cacd4045c..a91ebd91fc765 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/hydrate_data_source_selection.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/common/data_source_selection/hydrate_data_source_selection.ts @@ -11,9 +11,12 @@ import { SingleDatasetSelection } from './single_dataset_selection'; import { DataSourceSelectionPlain } from './types'; import { UnresolvedDatasetSelection } from './unresolved_dataset_selection'; -export const hydrateDataSourceSelection = (dataSourceSelection: DataSourceSelectionPlain) => { +export const hydrateDataSourceSelection = ( + dataSourceSelection: DataSourceSelectionPlain, + allSelection: AllDatasetSelection +) => { if (dataSourceSelection.selectionType === 'all') { - return AllDatasetSelection.create(); + return allSelection; } else if (dataSourceSelection.selectionType === 'single') { return SingleDatasetSelection.fromSelection(dataSourceSelection.selection); } else if (dataSourceSelection.selectionType === 'dataView') { diff --git a/x-pack/plugins/observability_solution/logs_explorer/common/datasets/models/dataset.ts b/x-pack/plugins/observability_solution/logs_explorer/common/datasets/models/dataset.ts index 3f279d83af64c..7b832bea85be2 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/common/datasets/models/dataset.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/common/datasets/models/dataset.ts @@ -72,9 +72,9 @@ export class Dataset { return new Dataset({ ...dataset, title: datasetTitle }, parentIntegration); } - public static createAllLogsDataset() { + public static createAllLogsDataset({ indices }: { indices: string }) { return new Dataset({ - name: 'logs-*-*' as IndexPattern, + name: indices as IndexPattern, title: 'All logs', iconType: 'pagesSelect', }); diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.stories.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.stories.tsx index f5eb74955ef10..dc9ab10d22c3f 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.stories.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.stories.tsx @@ -64,7 +64,7 @@ const KibanaReactContext = createKibanaReactContext(coreMock); const DataSourceSelectorTemplate: Story = (args) => { const [dataSourceSelection, setDataSourceSelection] = useState(() => - AllDatasetSelection.create() + AllDatasetSelection.create({ indices: 'logs-*-*' }) ); const [search, setSearch] = useState({ diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.tsx index 6cd080913c2e4..76eb4ab3b33eb 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/data_source_selector.tsx @@ -40,6 +40,7 @@ import { DataViewsFilter } from './sub_components/data_view_filter'; export function DataSourceSelector({ datasets, dataSourceSelection, + allSelection, datasetsError, dataViews, dataViewCount, @@ -307,7 +308,11 @@ export function DataSourceSelector({ /> - + {isEsqlEnabled && } diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/defaults.ts b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/defaults.ts index 683ac55dabc8f..a3de2c8edf69f 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/defaults.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/defaults.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AllDatasetSelection } from '../../../../common/data_source_selection'; +import { DEFAULT_ALL_SELECTION } from '../../../state_machines/logs_explorer_controller'; import { HashedCache } from '../../../../common/hashed_cache'; import { INTEGRATIONS_PANEL_ID, INTEGRATIONS_TAB_ID } from '../constants'; import { DataSourceSelectorSearchParams } from '../types'; @@ -17,7 +17,8 @@ export const defaultSearch: DataSourceSelectorSearchParams = { }; export const DEFAULT_CONTEXT: DefaultDataSourceSelectorContext = { - selection: AllDatasetSelection.create(), + selection: DEFAULT_ALL_SELECTION, + allSelection: DEFAULT_ALL_SELECTION, searchCache: new HashedCache(), panelId: INTEGRATIONS_PANEL_ID, tabId: INTEGRATIONS_TAB_ID, diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/state_machine.ts b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/state_machine.ts index 7fd26c7529baf..b68ceddf20c69 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/state_machine.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/state_machine.ts @@ -7,7 +7,6 @@ import { actions, assign, createMachine, raise } from 'xstate'; import { - AllDatasetSelection, DataViewSelection, isAllDatasetSelection, isDataViewSelection, @@ -233,7 +232,7 @@ export const createPureDataSourceSelectorStateMachine = ( return {}; }), storeAllSelection: assign((_context) => ({ - selection: AllDatasetSelection.create(), + selection: _context.allSelection, })), storeSingleSelection: assign((_context, event) => event.type === 'SELECT_DATASET' diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/types.ts b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/types.ts index 49d0ebe698e02..1b6b4ecdb9b9a 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/types.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/state_machine/types.ts @@ -7,6 +7,7 @@ import { DataViewDescriptor } from '../../../../common/data_views/models/data_view_descriptor'; import { FilterDataViews, SearchDataViews } from '../../../hooks/use_data_views'; import { + AllDatasetSelection, DataSourceSelection, DataSourceSelectionChangeHandler, } from '../../../../common/data_source_selection'; @@ -23,6 +24,7 @@ import { DataViewsFilterParams } from '../../../state_machines/data_views'; export interface DefaultDataSourceSelectorContext { selection: DataSourceSelection; + allSelection: AllDatasetSelection; tabId: TabId; panelId: PanelId; searchCache: IHashedCache; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/sub_components/selector_footer.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/sub_components/selector_footer.tsx index 7c92fa8f28bb3..fd1ae95770506 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/sub_components/selector_footer.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/sub_components/selector_footer.tsx @@ -15,6 +15,7 @@ import { EuiFlexGroupProps, } from '@elastic/eui'; import { getRouterLinkProps } from '@kbn/router-utils'; +import { AllDatasetSelection } from '../../../../common'; import { DiscoverEsqlUrlProps } from '../../../hooks/use_esql'; import { createAllLogsItem } from '../utils'; import { showAllLogsLabel, tryEsql } from '../constants'; @@ -22,6 +23,7 @@ import { showAllLogsLabel, tryEsql } from '../constants'; interface ShowAllLogsProps { isSelected: boolean; onClick(): void; + allSelection: AllDatasetSelection; } export const SelectorFooter = (props: EuiFlexGroupProps) => { @@ -32,8 +34,8 @@ export const SelectorFooter = (props: EuiFlexGroupProps) => { ); }; -export const ShowAllLogsButton = ({ isSelected, onClick }: ShowAllLogsProps) => { - const allLogs = createAllLogsItem(); +export const ShowAllLogsButton = ({ isSelected, onClick, allSelection }: ShowAllLogsProps) => { + const allLogs = createAllLogsItem(allSelection); return ( diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/types.ts b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/types.ts index 37d43cb50a478..29cf3b14d0f80 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/types.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/types.ts @@ -9,6 +9,7 @@ import { EuiContextMenuPanelId } from '@elastic/eui/src/components/context_menu/ import type { DataSourceSelectionChangeHandler, DataSourceSelection, + AllDatasetSelection, } from '../../../common/data_source_selection'; import { SortOrder } from '../../../common/latest'; import { Dataset, Integration, IntegrationId } from '../../../common/datasets'; @@ -39,6 +40,8 @@ import { DataViewsFilterParams } from '../../state_machines/data_views'; export interface DataSourceSelectorProps { /* The generic data stream list */ datasets: Dataset[] | null; + /* Class to represent the current "All logs" selection */ + allSelection: AllDatasetSelection; /* Any error occurred to show when the user preview the generic data streams */ datasetsError: Error | null; /* The current selection instance */ diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/utils.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/utils.tsx index 6825a9528ea5e..a462cfe4c5eb9 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/utils.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/data_source_selector/utils.tsx @@ -8,7 +8,8 @@ import React, { RefCallback } from 'react'; import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; import { PackageIcon } from '@kbn/fleet-plugin/public'; -import { Dataset, Integration } from '../../../common/datasets'; +import { AllDatasetSelection } from '../../../common'; +import { Integration } from '../../../common/datasets'; import { DATA_SOURCE_SELECTOR_WIDTH, noDatasetsDescriptionLabel, @@ -78,12 +79,11 @@ export const buildIntegrationsTree = ({ ); }; -export const createAllLogsItem = () => { - const allLogs = Dataset.createAllLogsDataset(); +export const createAllLogsItem = (allSelection: AllDatasetSelection) => { return { 'data-test-subj': 'dataSourceSelectorShowAllLogs', - iconType: allLogs.iconType, - name: allLogs.title, + iconType: allSelection.selection.dataset.iconType, + name: allSelection.selection.dataset.title, }; }; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts b/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts index 64f42cb5649a4..59d873385f21b 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts @@ -10,8 +10,12 @@ import { getDevToolsOptions } from '@kbn/xstate-utils'; import equal from 'fast-deep-equal'; import { distinctUntilChanged, from, map, shareReplay, Subject } from 'rxjs'; import { interpret } from 'xstate'; +import { AllDatasetSelection } from '../../common'; import { DatasetsService } from '../services/datasets'; -import { createLogsExplorerControllerStateMachine } from '../state_machines/logs_explorer_controller'; +import { + createLogsExplorerControllerStateMachine, + DEFAULT_CONTEXT, +} from '../state_machines/logs_explorer_controller'; import { LogsExplorerStartDeps } from '../types'; import { LogsExplorerCustomizations } from '../customizations/types'; import { createDataServiceProxy } from './custom_data_service'; @@ -33,7 +37,7 @@ interface Dependencies { plugins: LogsExplorerStartDeps; } -type InitialState = LogsExplorerPublicStateUpdate; +type InitialState = LogsExplorerPublicStateUpdate & { allSelection?: AllDatasetSelection }; export const createLogsExplorerControllerFactory = ({ core, plugins }: Dependencies) => @@ -66,15 +70,19 @@ export const createLogsExplorerControllerFactory = timefilter: customData.query.timefilter.timefilter, urlStateStorage: customMemoryUrlStateStorage, }; + const allSelection = initialState?.allSelection ?? DEFAULT_CONTEXT.allSelection; - const initialContext = getContextFromPublicState(initialState ?? {}); + const initialContext = getContextFromPublicState(initialState ?? {}, allSelection); const publicEvents$ = new Subject(); const machine = createLogsExplorerControllerStateMachine({ datasetsClient, dataViews, events: customizations.events, - initialContext, + initialContext: { + ...initialContext, + allSelection, + }, query: discoverServices.data.query, toasts: core.notifications.toasts, uiSettings: customUiSettings, diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/controller/public_state.ts b/x-pack/plugins/observability_solution/logs_explorer/public/controller/public_state.ts index ced3e7fc69cc1..7b228ff2afbfd 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/controller/public_state.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/controller/public_state.ts @@ -6,6 +6,7 @@ */ import { + AllDatasetSelection, availableControlsPanels, controlPanelConfigs, ControlPanels, @@ -37,7 +38,8 @@ export const getPublicStateFromContext = ( }; export const getContextFromPublicState = ( - publicState: LogsExplorerPublicStateUpdate + publicState: LogsExplorerPublicStateUpdate, + allSelection: AllDatasetSelection ): LogsExplorerControllerContext => ({ ...DEFAULT_CONTEXT, chart: { @@ -47,7 +49,7 @@ export const getContextFromPublicState = ( controlPanels: getControlPanelsFromPublicControlsState(publicState.controls), dataSourceSelection: publicState.dataSourceSelection != null - ? hydrateDataSourceSelection(publicState.dataSourceSelection) + ? hydrateDataSourceSelection(publicState.dataSourceSelection, allSelection) : DEFAULT_CONTEXT.dataSourceSelection, grid: { ...DEFAULT_CONTEXT.grid, diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_data_source_selector.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_data_source_selector.tsx index feb2b6ceb9cee..adb9ba59e14f2 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_data_source_selector.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_data_source_selector.tsx @@ -23,9 +23,8 @@ interface CustomDataSourceSelectorProps { } export const CustomDataSourceSelector = withProviders(({ logsExplorerControllerStateService }) => { - const { dataSourceSelection, handleDataSourceSelectionChange } = useDataSourceSelection( - logsExplorerControllerStateService - ); + const { dataSourceSelection, handleDataSourceSelectionChange, allSelection } = + useDataSourceSelection(logsExplorerControllerStateService); const { error: integrationsError, @@ -70,6 +69,7 @@ export const CustomDataSourceSelector = withProviders(({ logsExplorerControllerS { return state.context.dataSourceSelection; }); + const allSelection = useSelector(logsExplorerControllerStateService, (state) => { + return state.context.allSelection; + }); const handleDataSourceSelectionChange: DataSourceSelectionChangeHandler = useCallback( (data) => { @@ -24,5 +27,5 @@ export const useDataSourceSelection = ( [logsExplorerControllerStateService] ); - return { dataSourceSelection, handleDataSourceSelectionChange }; + return { dataSourceSelection, allSelection, handleDataSourceSelectionChange }; }; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/index.ts b/x-pack/plugins/observability_solution/logs_explorer/public/index.ts index 62794429c8c2e..8b0eae25e6030 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/index.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/index.ts @@ -20,6 +20,7 @@ export type { LogsExplorerCustomizationEvents, } from './customizations/types'; export type { LogsExplorerControllerContext } from './state_machines/logs_explorer_controller'; +export { DEFAULT_ALL_SELECTION } from './state_machines/logs_explorer_controller/src/default_all_selection'; export type { LogsExplorerPluginSetup, LogsExplorerPluginStart } from './types'; export { getDiscoverColumnsFromDisplayOptions, diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/default_all_selection.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/default_all_selection.ts new file mode 100644 index 0000000000000..e00defe175916 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/default_all_selection.ts @@ -0,0 +1,10 @@ +/* + * 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 { AllDatasetSelection } from '../../../../common'; + +export const DEFAULT_ALL_SELECTION = AllDatasetSelection.create({ indices: 'logs-*-*' }); diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/defaults.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/defaults.ts index 0aa128825ed3a..33294b491b28b 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/defaults.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/defaults.ts @@ -11,11 +11,12 @@ import { DEFAULT_ROWS_PER_PAGE, LOG_LEVEL_FIELD, } from '../../../../common/constants'; -import { AllDatasetSelection } from '../../../../common/data_source_selection'; import { DefaultLogsExplorerControllerState } from './types'; +import { DEFAULT_ALL_SELECTION } from './default_all_selection'; export const DEFAULT_CONTEXT: DefaultLogsExplorerControllerState = { - dataSourceSelection: AllDatasetSelection.create(), + dataSourceSelection: DEFAULT_ALL_SELECTION, + allSelection: DEFAULT_ALL_SELECTION, grid: { columns: DEFAULT_COLUMNS, rows: { diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/index.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/index.ts index 3f426130cbf38..e6b4ac04ac3e7 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/index.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/index.ts @@ -6,5 +6,6 @@ */ export * from './defaults'; +export * from './default_all_selection'; export * from './state_machine'; export * from './types'; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts index e1d4fa6f91c6d..d7c5359cae2ff 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts @@ -14,7 +14,6 @@ import { OBSERVABILITY_LOGS_EXPLORER_ALLOWED_DATA_VIEWS_ID } from '@kbn/manageme import type { LogsExplorerCustomizations, LogsExplorerPublicEvent } from '../../../controller'; import { ControlPanelRT } from '../../../../common/control_panels'; import { - AllDatasetSelection, isDataSourceSelection, isDataViewSelection, } from '../../../../common/data_source_selection'; @@ -271,7 +270,7 @@ export const createPureLogsExplorerControllerStateMachine = ( { actions: { storeDefaultSelection: actions.assign((_context) => ({ - dataSourceSelection: AllDatasetSelection.create(), + dataSourceSelection: _context.allSelection, })), storeDataSourceSelection: actions.assign((_context, event) => 'data' in event && isDataSourceSelection(event.data) diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/types.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/types.ts index 56ee5405841cc..442418d88779d 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/types.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/types.ts @@ -16,6 +16,7 @@ import { DoneInvokeEvent } from 'xstate'; import type { DataTableRecord } from '@kbn/discover-utils/src/types'; import { ControlPanels, DisplayOptions } from '../../../../common'; import type { + AllDatasetSelection, DatasetSelection, DataSourceSelection, DataViewSelection, @@ -25,6 +26,9 @@ export interface WithDataSourceSelection { dataSourceSelection: DataSourceSelection; } +export interface WithAllSelection { + allSelection: AllDatasetSelection; +} export interface WithControlPanelGroupAPI { controlGroupAPI: ControlGroupAPI; } @@ -46,6 +50,7 @@ export interface WithDataTableRecord { } export type DefaultLogsExplorerControllerState = WithDataSourceSelection & + WithAllSelection & WithQueryState & WithDisplayOptions & WithDataTableRecord; @@ -53,11 +58,16 @@ export type DefaultLogsExplorerControllerState = WithDataSourceSelection & export type LogsExplorerControllerTypeState = | { value: 'uninitialized'; - context: WithDataSourceSelection & WithControlPanels & WithQueryState & WithDisplayOptions; + context: WithDataSourceSelection & + WithAllSelection & + WithControlPanels & + WithQueryState & + WithDisplayOptions; } | { value: 'initializingSelection'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -67,6 +77,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initializingDataset'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -75,6 +86,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initializingDataView'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -83,6 +95,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initializingControlPanels'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -91,6 +104,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -100,6 +114,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized.dataSourceSelection.idle'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -109,6 +124,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized.dataSourceSelection.changingDataView'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -118,6 +134,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized.dataSourceSelection.creatingAdHocDataView'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -127,6 +144,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized.controlGroups.uninitialized'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanels & WithQueryState & WithDisplayOptions & @@ -136,6 +154,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized.controlGroups.idle'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanelGroupAPI & WithControlPanels & WithQueryState & @@ -146,6 +165,7 @@ export type LogsExplorerControllerTypeState = | { value: 'initialized.controlGroups.updatingControlPanels'; context: WithDataSourceSelection & + WithAllSelection & WithControlPanelGroupAPI & WithControlPanels & WithQueryState & diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/common/locators/all_datasets_locator.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/common/locators/all_datasets_locator.ts index 44d0213885891..cbcfb5802c15c 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/common/locators/all_datasets_locator.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/common/locators/all_datasets_locator.ts @@ -6,11 +6,11 @@ */ import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; -import { AllDatasetSelection } from '@kbn/logs-explorer-plugin/common'; import { AllDatasetsLocatorParams, ALL_DATASETS_LOCATOR_ID, } from '@kbn/deeplinks-observability/locators'; +import { AllDatasetSelection } from '@kbn/logs-explorer-plugin/common'; import { ObsLogsExplorerLocatorDependencies } from './types'; import { constructLocatorPath } from './utils'; @@ -25,7 +25,7 @@ export class AllDatasetsLocatorDefinition implements LocatorDefinition { ) { const { logsExplorerState } = pageState.context; const index = hydrateDataSourceSelection( - logsExplorerState.dataSourceSelection + logsExplorerState.dataSourceSelection, + pageState.context.allSelection ).toDataviewSpec(); return triggersActionsUi.getAddRuleFlyout({ @@ -92,7 +93,8 @@ export const AlertsPopover = () => { if (isCreateSLOFlyoutOpen && pageState.matches({ initialized: 'validLogsExplorerState' })) { const { logsExplorerState } = pageState.context; const dataView = hydrateDataSourceSelection( - logsExplorerState.dataSourceSelection + logsExplorerState.dataSourceSelection, + pageState.context.allSelection ).toDataviewSpec(); const query = logsExplorerState?.query && 'query' in logsExplorerState.query diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/discover_link.tsx b/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/discover_link.tsx index 4e3482816b026..3ec1425348493 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/discover_link.tsx +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/discover_link.tsx @@ -30,7 +30,6 @@ export const ConnectedDiscoverLink = React.memo(() => { } = useKibanaContextForPlugin(); const [pageState] = useActor(useObservabilityLogsExplorerPageStateContext()); - if (pageState.matches({ initialized: 'validLogsExplorerState' })) { return ; } else { @@ -47,7 +46,7 @@ export const DiscoverLinkForValidState = React.memo( ({ discover, pageState: { - context: { logsExplorerState }, + context: { logsExplorerState, allSelection }, }, }: { discover: DiscoverStart; @@ -55,7 +54,8 @@ export const DiscoverLinkForValidState = React.memo( }) => { const discoverLinkParams = useMemo(() => { const index = hydrateDataSourceSelection( - logsExplorerState.dataSourceSelection + logsExplorerState.dataSourceSelection, + allSelection ).toDataviewSpec(); return { breakdownField: logsExplorerState.chart.breakdownField ?? undefined, @@ -70,7 +70,7 @@ export const DiscoverLinkForValidState = React.memo( timeRange: logsExplorerState.time, dataViewSpec: index, }; - }, [logsExplorerState]); + }, [allSelection, logsExplorerState]); return ; } diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx b/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx index d36dc5cec5e4a..d1a2dc1e74439 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx @@ -27,8 +27,17 @@ import { useKibanaContextForPlugin } from '../../utils/use_kibana'; export const ObservabilityLogsExplorerMainRoute = () => { const { services } = useKibanaContextForPlugin(); - const { logsExplorer, serverless, chrome, notifications, appParams, analytics, i18n, theme } = - services; + const { + logsExplorer, + serverless, + chrome, + notifications, + appParams, + analytics, + i18n, + theme, + logsDataAccess, + } = services; const { history } = appParams; useBreadcrumbs(noBreadcrumbs, chrome, serverless); @@ -51,6 +60,7 @@ export const ObservabilityLogsExplorerMainRoute = () => { urlStateStorageContainer={urlStateStorageContainer} timeFilterService={services.data.query.timefilter.timefilter} analytics={services.analytics} + logSourcesService={logsDataAccess.services.logSourcesService} > => + async (context) => { + const logSources = await logSourcesService.getLogSources(); + const indices = logSources.map((logSource) => logSource.indexPattern).join(','); + return AllDatasetSelection.create({ indices }); + }; diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/controller_service.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/controller_service.ts index 5389eb70f4511..7d615d4be7fb9 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/controller_service.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/controller_service.ts @@ -19,7 +19,7 @@ export const createController = (context, event) => (send) => { createLogsExplorerController({ - initialState: context.initialLogsExplorerState, + initialState: { ...context.initialLogsExplorerState, allSelection: context.allSelection }, }).then((controller) => { send({ type: 'CONTROLLER_CREATED', diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/defaults.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/defaults.ts index 4484eb9fe1b4c..8117233120c06 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/defaults.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/defaults.ts @@ -5,8 +5,10 @@ * 2.0. */ +import { DEFAULT_ALL_SELECTION } from '@kbn/logs-explorer-plugin/public'; import { CommonObservabilityLogsExplorerContext } from './types'; export const DEFAULT_CONTEXT: CommonObservabilityLogsExplorerContext = { initialLogsExplorerState: {}, + allSelection: DEFAULT_ALL_SELECTION, }; diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/state_machine.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/state_machine.ts index f5ac4947c2119..d829008af5dc1 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/state_machine.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/state_machine.ts @@ -11,6 +11,7 @@ import { CreateLogsExplorerController } from '@kbn/logs-explorer-plugin/public'; import { actions, createMachine, InterpreterFrom } from 'xstate'; import { TimefilterContract } from '@kbn/data-plugin/public'; import { AnalyticsServiceStart } from '@kbn/core-analytics-browser'; +import { LogSourcesService } from '@kbn/logs-data-access-plugin/common/types'; import { DEFAULT_CONTEXT } from './defaults'; import { ObservabilityLogsExplorerContext, @@ -25,6 +26,7 @@ import { } from './controller_service'; import { initializeFromTimeFilterService } from './time_filter_service'; import { createDataReceivedTelemetryEventEmitter } from './telemetry_events'; +import { initializeAllSelection } from './all_selection_service'; export const createPureObservabilityLogsExplorerStateMachine = ( initialContext: ObservabilityLogsExplorerContext @@ -42,7 +44,17 @@ export const createPureObservabilityLogsExplorerStateMachine = ( initial: 'uninitialized', states: { uninitialized: { - always: 'initializingFromTimeFilterService', + always: 'initializeAllSelection', + }, + initializeAllSelection: { + invoke: { + src: 'initializeAllSelection', + onDone: { + target: 'initializingFromTimeFilterService', + actions: ['storeAllSelection'], + }, + onError: 'initializingFromTimeFilterService', + }, }, initializingFromTimeFilterService: { invoke: { @@ -119,6 +131,13 @@ export const createPureObservabilityLogsExplorerStateMachine = ( ? { controller: event.controller } : {}; }), + storeAllSelection: actions.assign((context, event) => { + return 'data' in event + ? { + allSelection: event.data, + } + : {}; + }), storeInitialTimeFilter: actions.assign((context, event) => { return 'time' in event && 'refreshInterval' in event && @@ -162,6 +181,7 @@ export interface ObservabilityLogsExplorerStateMachineDependencies { toasts: IToasts; urlStateStorageContainer: IKbnUrlStateStorage; analytics: AnalyticsServiceStart; + logSourcesService: LogSourcesService; } export const createObservabilityLogsExplorerStateMachine = ({ @@ -171,6 +191,7 @@ export const createObservabilityLogsExplorerStateMachine = ({ createLogsExplorerController, timeFilterService, analytics, + logSourcesService, }: ObservabilityLogsExplorerStateMachineDependencies) => createPureObservabilityLogsExplorerStateMachine(initialContext).withConfig({ actions: { @@ -181,6 +202,7 @@ export const createObservabilityLogsExplorerStateMachine = ({ createController: createController({ createLogsExplorerController }), initializeFromTimeFilterService: initializeFromTimeFilterService({ timeFilterService }), initializeFromUrl: initializeFromUrl({ urlStateStorageContainer, toastsService: toasts }), + initializeAllSelection: initializeAllSelection({ logSourcesService }), subscribeToLogsExplorerState, subscribeToLogsExplorerPublicEvents, }, diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/types.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/types.ts index d0b1f959962b3..2cb62e33bac45 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/types.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/state_machines/observability_logs_explorer/src/types.ts @@ -6,16 +6,19 @@ */ import { QueryState } from '@kbn/data-plugin/common'; +import { AllDatasetSelection } from '@kbn/logs-explorer-plugin/common'; import { LogsExplorerController, LogsExplorerPublicState, LogsExplorerPublicStateUpdate, } from '@kbn/logs-explorer-plugin/public'; +import { DoneInvokeEvent } from 'xstate'; export type ObservabilityLogsExplorerContext = ObservabilityLogsExplorerTypeState['context']; export interface CommonObservabilityLogsExplorerContext { initialLogsExplorerState: LogsExplorerPublicStateUpdate; + allSelection: AllDatasetSelection; } export interface WithLogsExplorerState { @@ -47,7 +50,8 @@ export type ObservabilityLogsExplorerEvent = | { type: 'LOGS_EXPLORER_DATA_RECEIVED'; rowCount: number; - }; + } + | DoneInvokeEvent; export type ObservabilityLogsExplorerTypeState = | { diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/types.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/types.ts index 12d56f91c4dda..b1bbfb1b504a1 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/types.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/types.ts @@ -26,6 +26,7 @@ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { SloPublicStart } from '@kbn/slo-plugin/public'; +import { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; import { ObservabilityLogsExplorerLocators, ObservabilityLogsExplorerLocationState, @@ -49,6 +50,7 @@ export interface ObservabilityLogsExplorerStartDeps { discover: DiscoverStart; logsExplorer: LogsExplorerPluginStart; logsShared: LogsSharedClientStartExports; + logsDataAccess: LogsDataAccessPluginStart; observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; observabilityShared: ObservabilitySharedPluginStart; slo: SloPublicStart; diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/tsconfig.json b/x-pack/plugins/observability_solution/observability_logs_explorer/tsconfig.json index 782e7f3280be3..6ea751aaed3de 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/tsconfig.json @@ -49,6 +49,7 @@ "@kbn/es-query", "@kbn/core-analytics-browser", "@kbn/react-hooks", + "@kbn/logs-data-access-plugin", ], "exclude": [ "target/**/*"