- -
-
-

- Create your first index lifecycle policy -

-
+
+
-

- An index lifecycle policy helps you manage your indices as they age. -

-
-
- + class="emotion-euiButtonDisplayContent" + > + + Create policy + + +
-
+ `; exports[`policy table sorts when linked index templates header is clicked 1`] = ` diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx index 1290304ef6165..2bad87b149e0f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -7,12 +7,7 @@ import React, { useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { - EuiButton, - EuiEmptyPrompt, - EuiLoadingSpinner, - EuiPageContent_Deprecated as EuiPageContent, -} from '@elastic/eui'; +import { EuiButton, EuiLoadingSpinner, EuiPageTemplate } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { MIN_SEARCHABLE_SNAPSHOT_LICENSE } from '../../../../common/constants'; @@ -45,47 +40,44 @@ export const EditPolicy: React.FunctionComponent - } - body={ - - } - /> - + } + body={ + + } + /> ); } if (error || !policies) { const { statusCode, message } = error ? error : { statusCode: '', message: '' }; return ( - - - - - } - body={ -

- {message} ({statusCode}) -

- } - actions={ - - - - } - /> -
+ + + + } + body={ +

+ {message} ({statusCode}) +

+ } + actions={ + + + + } + /> ); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.container.tsx index 7113b00cf4ec2..669f22ccb9b3f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.container.tsx @@ -6,12 +6,7 @@ */ import React, { useEffect } from 'react'; -import { - EuiButton, - EuiEmptyPrompt, - EuiLoadingSpinner, - EuiPageContent_Deprecated as EuiPageContent, -} from '@elastic/eui'; +import { EuiButton, EuiLoadingSpinner, EuiPageTemplate } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { PolicyList as PresentationComponent } from './policy_list'; import { useKibana } from '../../../shared_imports'; @@ -30,47 +25,44 @@ export const PolicyList: React.FunctionComponent = () => { if (isLoading) { return ( - - } - body={ - - } - /> - + } + body={ + + } + /> ); } if (error) { const { statusCode, message } = error ? error : { statusCode: '', message: '' }; return ( - - - - - } - body={ -

- {message} ({statusCode}) -

- } - actions={ - - - - } - /> -
+ + + + } + body={ +

+ {message} ({statusCode}) +

+ } + actions={ + + + + } + /> ); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.tsx index 09fbc2d1c41c6..0a81f6b16bf43 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/policy_list.tsx @@ -8,13 +8,7 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiButton, - EuiEmptyPrompt, - EuiSpacer, - EuiPageHeader, - EuiPageContent_Deprecated as EuiPageContent, -} from '@elastic/eui'; +import { EuiButton, EuiSpacer, EuiPageHeader, EuiPageTemplate } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { PolicyFromES } from '../../../../common/types'; @@ -46,30 +40,28 @@ export const PolicyList: React.FunctionComponent = ({ policies, updatePol if (policies.length === 0) { return ( - - + + + + } + body={ + +

- - } - body={ - -

- -

-
- } - actions={createPolicyButton} - /> -
+

+ + } + actions={createPolicyButton} + /> ); } From 58334b54ac199744cae16d4b9f690dcb01ae6b88 Mon Sep 17 00:00:00 2001 From: Isaac Karrer Date: Tue, 22 Aug 2023 07:30:08 -0500 Subject: [PATCH 05/26] Kibana QualityGate Scaffold (#163831) The main question is here did I get the github team names correct for fleet and security? Are there any other groups I am missing who will have quality gates they want to see execute on every kibana release? - The PR which registered the repo and controls who can write secrets to vault was merged [here](https://github.com/elastic/catalog-info/pull/488/files) - Quality gate registration with argo deploy pipeline [here](https://github.com/elastic/serverless-gitops/pull/586). Should merge after this and pipelines are working. - [this one](https://github.com/elastic/catalog-info/pull/485/files) merges last and tells terrazzo about the catalog-info.yaml file with our pipeline. Other pipelines should be migrated over but are not included here. Rel: https://github.com/elastic/ingest-dev/issues/2201 Rel: https://elasticco.atlassian.net/browse/QX-282 --------- Co-authored-by: Ramon Butter --- .../quality-gates/pipeline.kibana-tests.yaml | 10 +++ .../pipeline.tests-production.yaml | 15 ++++ .../quality-gates/pipeline.tests-qa.yaml | 15 ++++ .../quality-gates/pipeline.tests-staging.yaml | 15 ++++ .github/CODEOWNERS | 1 + catalog-info.yaml | 70 +++++++++++++++++++ src/dev/precommit_hook/casing_check_config.js | 3 + 7 files changed, 129 insertions(+) create mode 100644 .buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml create mode 100644 .buildkite/pipelines/quality-gates/pipeline.tests-production.yaml create mode 100644 .buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml create mode 100644 .buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml create mode 100644 catalog-info.yaml diff --git a/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml b/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml new file mode 100644 index 0000000000000..27e55dfced9d7 --- /dev/null +++ b/.buildkite/pipelines/quality-gates/pipeline.kibana-tests.yaml @@ -0,0 +1,10 @@ +env: + ENVIRONMENT: ${ENVIRONMENT?} + +steps: + - label: ":pipeline::grey_question::seedling: Trigger Kibana Tests for ${ENVIRONMENT}" + env: + QG_PIPELINE_LOCATION: ".buildkite/pipelines/quality-gates" + command: "make -C /agent run-environment-tests" + agents: + image: "docker.elastic.co/ci-agent-images/quality-gate-seedling:0.0.2" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml new file mode 100644 index 0000000000000..483532b9c7435 --- /dev/null +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-production.yaml @@ -0,0 +1,15 @@ +steps: + - label: ":pipeline::kibana::seedling: Trigger Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Kibana specific tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + + - label: ":pipeline::fleet::seedling: Trigger Fleet Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Fleet specific Kibana tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + + - label: ":pipeline::lock::seedling: Trigger Security Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Security specific Kibana tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml new file mode 100644 index 0000000000000..483532b9c7435 --- /dev/null +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml @@ -0,0 +1,15 @@ +steps: + - label: ":pipeline::kibana::seedling: Trigger Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Kibana specific tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + + - label: ":pipeline::fleet::seedling: Trigger Fleet Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Fleet specific Kibana tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + + - label: ":pipeline::lock::seedling: Trigger Security Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Security specific Kibana tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml new file mode 100644 index 0000000000000..483532b9c7435 --- /dev/null +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml @@ -0,0 +1,15 @@ +steps: + - label: ":pipeline::kibana::seedling: Trigger Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Kibana specific tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + + - label: ":pipeline::fleet::seedling: Trigger Fleet Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Fleet specific Kibana tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" + + - label: ":pipeline::lock::seedling: Trigger Security Kibana Tests for ${ENVIRONMENT}" + command: echo "replace me with Security specific Kibana tests" + agent: + image: "docker.elastic.co/ci-agent-images/basic-buildkite-agent:1688566364" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 650c71e26c93b..eecffc061f483 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -988,6 +988,7 @@ x-pack/plugins/infra/server/lib/alerting @elastic/actionable-observability /.buildkite/ @elastic/kibana-operations /kbn_pm/ @elastic/kibana-operations /x-pack/dev-tools @elastic/kibana-operations +catalog-info.yaml @elastic/kibana-operations @elastic/kibana-tech-leads # Appex QA /src/dev/code_coverage @elastic/appex-qa diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 0000000000000..534e34ba27008 --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,70 @@ +--- +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: kibana + description: Kibana is a user interface that lets you visualize your Elasticsearch data and navigate the Elastic Stack. + + annotations: + backstage.io/source-location: url:https://github.com/elastic/kibana/tree/main + github.com/project-slug: elastic/kibana + github.com/team-slug: elastic/kibana-tech-leads + buildkite.com/project-slug: elastic/kibana + + tags: + - typescript + - javascript + - dashboards + - metrics + - visualizations + - observability + + links: + - title: Documentation + url: https://www.elastic.co/guide/en/kibana/current/index.html + +spec: + type: monorepo + owner: group:kibana-tech-leads + lifecycle: production + +--- + +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: kibana-tests-pipeline + description: Definition of the kibana pipeline + links: + - title: Pipeline + url: https://buildkite.com/elastic/kibana-tests +spec: + type: buildkite-pipeline + owner: group:kibana-tech-leads + system: buildkite + implementation: + apiVersion: buildkite.elastic.dev/v1 + kind: Pipeline + metadata: + name: kibana-tests + description: Pipeline that tests the service integration in various environments + spec: + repository: elastic/kibana + pipeline_file: ./.buildkite/pipelines/pipeline.kibana-tests.yaml + provider_settings: + trigger_mode: none + teams: + kibana-operations: + access_level: MANAGE_BUILD_AND_READ + security-engineering-productivity: + access_level: BUILD_AND_READ + fleet: + access_level: BUILD_AND_READ + kibana-tech-leads: + access_level: BUILD_AND_READ + kibana-core: + access_level: BUILD_AND_READ + cloud-tooling: + access_level: BUILD_AND_READ + everyone: + access_level: READ_ONLY diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index c3b1a27491e67..93eba1bb171b2 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -50,6 +50,9 @@ export const IGNORE_FILE_GLOBS = [ // Required to match the name in the docs.elastic.dev repo. 'nav-kibana-dev.docnav.json', + // Match elastic wide naming convention for catalog-info.yaml + 'catalog-info.yaml', + // filename must match language code which requires capital letters '**/translations/*.json', From 83a0eecf25bd6cb5adf71251ceb81ef8ef429ba5 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 22 Aug 2023 13:53:50 +0100 Subject: [PATCH 06/26] skip flaky suite (#164381) --- .../apps/ml/anomaly_detection_result_views/forecasts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/forecasts.ts b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/forecasts.ts index 93ec331230a8a..63f72381d0185 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/forecasts.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/forecasts.ts @@ -42,7 +42,8 @@ export default function ({ getService }: FtrProviderContext) { describe('forecasts', function () { this.tags(['ml']); - describe('with single metric job', function () { + // FLAKY: https://github.com/elastic/kibana/issues/164381 + describe.skip('with single metric job', function () { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); From dd8239fdaf841b23bb89b5e7b4b41606846501f0 Mon Sep 17 00:00:00 2001 From: Saikat Sarkar <132922331+saikatsarkar056@users.noreply.github.com> Date: Tue, 22 Aug 2023 07:09:40 -0600 Subject: [PATCH 07/26] [Search Relevance] Move error handler to selector (#164113) This change is related to this issue: https://github.com/elastic/enterprise-search-team/issues/4440 In this PR, we are moving error handler to selectors. Screenshot 2023-08-16 at 6 04 38 PM --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../text_expansion_callout.test.tsx | 14 ++---- .../ml_inference/text_expansion_callout.tsx | 14 ++---- .../text_expansion_callout_logic.test.ts | 44 +++++++++++++++++++ .../text_expansion_callout_logic.ts | 23 ++++++++++ 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.test.tsx index a1ee2410128c1..1ef7480b25c81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.test.tsx @@ -11,8 +11,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { HttpError } from '../../../../../../../common/types/api'; - import { DeployModel } from './deploy_model'; import { ModelDeployed } from './model_deployed'; import { ModelDeploymentInProgress } from './model_deployment_in_progress'; @@ -31,7 +29,6 @@ jest.mock('./text_expansion_callout_data', () => ({ })); const DEFAULT_VALUES = { - startTextExpansionModelError: undefined, isCreateButtonDisabled: false, isModelDownloadInProgress: false, isModelDownloaded: false, @@ -47,13 +44,10 @@ describe('TextExpansionCallOut', () => { it('renders error panel instead of normal panel if there are some errors', () => { setMockValues({ ...DEFAULT_VALUES, - startTextExpansionModelError: { - body: { - error: 'some-error', - message: 'some-error-message', - statusCode: 500, - }, - } as HttpError, + textExpansionError: { + title: 'Error with ELSER deployment', + message: 'Mocked error message', + }, }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.tsx index 7ded5c3e9035d..310f8f273a2cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout.tsx @@ -20,7 +20,7 @@ import { ModelDeployed } from './model_deployed'; import { ModelDeploymentInProgress } from './model_deployment_in_progress'; import { ModelStarted } from './model_started'; import { useTextExpansionCallOutData } from './text_expansion_callout_data'; -import { getTextExpansionError, TextExpansionCalloutLogic } from './text_expansion_callout_logic'; +import { TextExpansionCalloutLogic } from './text_expansion_callout_logic'; import { TextExpansionErrors } from './text_expansion_errors'; import { TRAINED_MODELS_PATH } from './utils'; @@ -78,24 +78,16 @@ export const TextExpansionCallOut: React.FC = (props) const { dismiss, isCompact, isDismissable, show } = useTextExpansionCallOutData(props); const { ingestionMethod } = useValues(IndexViewLogic); const { - createTextExpansionModelError, - fetchTextExpansionModelError, isCreateButtonDisabled, isModelDownloadInProgress, isModelDownloaded, isModelRunningSingleThreaded, isModelStarted, + textExpansionError, isStartButtonDisabled, - startTextExpansionModelError, } = useValues(TextExpansionCalloutLogic); - // In case of an error, show the error callout only - const error = getTextExpansionError( - createTextExpansionModelError, - fetchTextExpansionModelError, - startTextExpansionModelError - ); - if (error) return ; + if (textExpansionError) return ; if (!show) return null; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts index 744eb9af56042..0227f52b0c041 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts @@ -13,6 +13,7 @@ import { ErrorResponse, HttpError, Status } from '../../../../../../../common/ty import { MlModelDeploymentState } from '../../../../../../../common/types/ml'; import { CreateTextExpansionModelApiLogic } from '../../../../api/ml_models/text_expansion/create_text_expansion_model_api_logic'; import { FetchTextExpansionModelApiLogic } from '../../../../api/ml_models/text_expansion/fetch_text_expansion_model_api_logic'; +import { StartTextExpansionModelApiLogic } from '../../../../api/ml_models/text_expansion/start_text_expansion_model_api_logic'; import { getTextExpansionError, @@ -36,6 +37,7 @@ const DEFAULT_VALUES: TextExpansionCalloutValues = { startTextExpansionModelStatus: Status.IDLE, textExpansionModel: undefined, textExpansionModelPollTimeoutId: null, + textExpansionError: null, }; jest.useFakeTimers(); @@ -48,11 +50,15 @@ describe('TextExpansionCalloutLogic', () => { const { mount: mountFetchTextExpansionModelApiLogic } = new LogicMounter( FetchTextExpansionModelApiLogic ); + const { mount: mountStartTextExpansionModelApiLogic } = new LogicMounter( + StartTextExpansionModelApiLogic + ); beforeEach(() => { jest.clearAllMocks(); mountCreateTextExpansionModelApiLogic(); mountFetchTextExpansionModelApiLogic(); + mountStartTextExpansionModelApiLogic(); mount(); }); @@ -293,6 +299,44 @@ describe('TextExpansionCalloutLogic', () => { }); }); + describe('textExpansionError', () => { + const error = { + body: { + error: 'Error with ELSER deployment', + message: 'Mocked error message', + statusCode: 500, + }, + } as HttpError; + + it('returns null when there are no errors', () => { + CreateTextExpansionModelApiLogic.actions.apiReset(); + FetchTextExpansionModelApiLogic.actions.apiReset(); + StartTextExpansionModelApiLogic.actions.apiReset(); + expect(TextExpansionCalloutLogic.values.textExpansionError).toBe(null); + }); + it('returns extracted error for create', () => { + CreateTextExpansionModelApiLogic.actions.apiError(error); + expect(TextExpansionCalloutLogic.values.textExpansionError).toStrictEqual({ + title: 'Error with ELSER deployment', + message: 'Mocked error message', + }); + }); + it('returns extracted error for fetch', () => { + FetchTextExpansionModelApiLogic.actions.apiError(error); + expect(TextExpansionCalloutLogic.values.textExpansionError).toStrictEqual({ + title: 'Error fetching ELSER model', + message: 'Mocked error message', + }); + }); + it('returns extracted error for start', () => { + StartTextExpansionModelApiLogic.actions.apiError(error); + expect(TextExpansionCalloutLogic.values.textExpansionError).toStrictEqual({ + title: 'Error starting ELSER deployment', + message: 'Mocked error message', + }); + }); + }); + describe('isModelDownloadInProgress', () => { it('is set to true if the model is downloading', () => { FetchTextExpansionModelApiLogic.actions.apiSuccess({ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts index a775ad3f05b89..86721c7808f14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts @@ -48,6 +48,11 @@ interface TextExpansionCalloutActions { textExpansionModel: FetchTextExpansionModelApiLogicActions['apiSuccess']; } +export interface TextExpansionCalloutError { + title: string; + message: string; +} + export interface TextExpansionCalloutValues { createTextExpansionModelError: HttpError | undefined; createTextExpansionModelStatus: Status; @@ -64,6 +69,7 @@ export interface TextExpansionCalloutValues { startTextExpansionModelStatus: Status; textExpansionModel: FetchTextExpansionModelResponse | undefined; textExpansionModelPollTimeoutId: null | ReturnType; + textExpansionError: TextExpansionCalloutError | null; } /** @@ -257,6 +263,23 @@ export const TextExpansionCalloutLogic = kea< (pollingTimeoutId: TextExpansionCalloutValues['textExpansionModelPollTimeoutId']) => pollingTimeoutId !== null, ], + textExpansionError: [ + () => [ + selectors.createTextExpansionModelError, + selectors.fetchTextExpansionModelError, + selectors.startTextExpansionModelError, + ], + ( + createTextExpansionError: TextExpansionCalloutValues['createTextExpansionModelError'], + fetchTextExpansionError: TextExpansionCalloutValues['fetchTextExpansionModelError'], + startTextExpansionError: TextExpansionCalloutValues['startTextExpansionModelError'] + ) => + getTextExpansionError( + createTextExpansionError, + fetchTextExpansionError, + startTextExpansionError + ), + ], isStartButtonDisabled: [ () => [selectors.startTextExpansionModelStatus], (status: Status) => status !== Status.IDLE && status !== Status.ERROR, From 34733bef94147c50e607fe734821865ad053888b Mon Sep 17 00:00:00 2001 From: GitStart <1501599+gitstart@users.noreply.github.com> Date: Tue, 22 Aug 2023 14:16:32 +0100 Subject: [PATCH 08/26] [Watcher] Migrate all usages of EuiPage*_Deprecated (#163128) ## What does this PR do? * Migrate all usages of EuiPage*_Deprecated in Watcher ## Issue References * https://github.com/elastic/kibana/issues/163070 ## Video/Screenshot Demo --- This code was written and reviewed by GitStart Community. Growing great engineers, one PR at a time. --------- Co-authored-by: LuisChiej <54555805+LuisChiej@users.noreply.github.com> Co-authored-by: gitstart_bot Co-authored-by: Yulia Cech --- .../license_prompt.test.tsx.snap | 124 ++++++++---------- .../page_error/page_error_forbidden.tsx | 5 +- .../page_error/page_error_not_exist.tsx | 5 +- .../public/application/license_prompt.tsx | 31 +++-- .../json_watch_edit/json_watch_edit.tsx | 10 +- .../monitoring_watch_edit.tsx | 30 ++--- .../threshold_watch_edit.tsx | 6 +- .../watch_edit_page/watch_edit_page.tsx | 12 +- .../watch_list_page/watch_list_page.tsx | 45 +++---- .../watch_status_page/watch_status_page.tsx | 12 +- 10 files changed, 125 insertions(+), 155 deletions(-) diff --git a/x-pack/plugins/watcher/__jest__/__snapshots__/license_prompt.test.tsx.snap b/x-pack/plugins/watcher/__jest__/__snapshots__/license_prompt.test.tsx.snap index 7d3768c237574..419a83d62c9b8 100644 --- a/x-pack/plugins/watcher/__jest__/__snapshots__/license_prompt.test.tsx.snap +++ b/x-pack/plugins/watcher/__jest__/__snapshots__/license_prompt.test.tsx.snap @@ -1,80 +1,70 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`License prompt renders a prompt with a link to License Management 1`] = ` - - - - , - ] - } - body={ -

- License error -

- } - iconType="warning" - title={ -

+<_EuiPageEmptyPrompt + actions={ + Array [ + -

- } - /> -
+ , + ] + } + body={ +

+ License error +

+ } + color="danger" + iconType="warning" + title={ +

+ +

+ } +/> `; exports[`License prompt renders a prompt without a link to License Management 1`] = ` - - -

- License error -

-

- -

- - } - iconType="warning" - title={ -

+<_EuiPageEmptyPrompt + actions={ + Array [ + undefined, + ] + } + body={ + +

+ License error +

+

-

- } - /> -
+

+ + } + color="danger" + iconType="warning" + title={ +

+ +

+ } +/> `; diff --git a/x-pack/plugins/watcher/public/application/components/page_error/page_error_forbidden.tsx b/x-pack/plugins/watcher/public/application/components/page_error/page_error_forbidden.tsx index 883f6f1401c7a..12a02318a6c7d 100644 --- a/x-pack/plugins/watcher/public/application/components/page_error/page_error_forbidden.tsx +++ b/x-pack/plugins/watcher/public/application/components/page_error/page_error_forbidden.tsx @@ -7,13 +7,14 @@ import React from 'react'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiPageTemplate } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; export function PageErrorForbidden() { return ( - ); return ( - - - - - } - body={promptBody} - actions={[promptAction]} - /> - + + + + } + body={promptBody} + actions={[promptAction]} + /> ); }; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/json_watch_edit/json_watch_edit.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/json_watch_edit/json_watch_edit.tsx index fe54e48ce5f1d..66be8a0298bde 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/json_watch_edit/json_watch_edit.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/json_watch_edit/json_watch_edit.tsx @@ -7,11 +7,7 @@ import React, { useContext, useState } from 'react'; -import { - EuiPageHeader, - EuiSpacer, - EuiPageContentBody_Deprecated as EuiPageContentBody, -} from '@elastic/eui'; +import { EuiPageHeader, EuiSpacer, EuiPageSection } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ExecuteDetails } from '../../../../models/execute_details'; import { getActionType } from '../../../../../../common/lib/get_action_type'; @@ -94,7 +90,7 @@ export const JsonWatchEdit = ({ pageTitle }: { pageTitle: string }) => { ); return ( - + {pageTitle}} bottomBorder @@ -128,6 +124,6 @@ export const JsonWatchEdit = ({ pageTitle }: { pageTitle: string }) => { )} {selectedTab === WATCH_EDIT_TAB && } - + ); }; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/monitoring_watch_edit/monitoring_watch_edit.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/monitoring_watch_edit/monitoring_watch_edit.tsx index efbc56033d12a..d330aa4f41e5d 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/monitoring_watch_edit/monitoring_watch_edit.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/monitoring_watch_edit/monitoring_watch_edit.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; -import { EuiPageContent_Deprecated as EuiPageContent, EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { EuiLink, EuiPageTemplate } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { WatchContext } from '../../watch_context'; @@ -28,20 +28,18 @@ export const MonitoringWatchEdit = ({ pageTitle }: { pageTitle: string }) => { ); return ( - - {pageTitle}} - body={

{systemWatchMessage}

} - actions={[ - - - , - ]} - /> -
+ {pageTitle}} + body={

{systemWatchMessage}

} + actions={[ + + + , + ]} + /> ); }; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_edit.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_edit.tsx index 2934cc67cb8a0..9ebb0d8170fbe 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_edit.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_edit.tsx @@ -25,7 +25,7 @@ import { EuiText, EuiTitle, EuiPageHeader, - EuiPageContentBody_Deprecated as EuiPageContentBody, + EuiPageSection, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -236,7 +236,7 @@ export const ThresholdWatchEdit = ({ pageTitle }: { pageTitle: string }) => { }; return ( - + {pageTitle}} description={watch.titleDescription} @@ -953,6 +953,6 @@ export const ThresholdWatchEdit = ({ pageTitle }: { pageTitle: string }) => { close={() => setIsRequestVisible(false)} /> ) : null} - + ); }; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/watch_edit_page.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/watch_edit_page.tsx index 1c543d167d642..e2adfe25ea197 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/watch_edit_page.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/watch_edit_page.tsx @@ -8,10 +8,10 @@ import React, { useEffect, useReducer } from 'react'; import { isEqual } from 'lodash'; -import { EuiPageContent_Deprecated as EuiPageContent } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiPageTemplate } from '@elastic/eui'; import { WATCH_TYPES } from '../../../../common/constants'; import { BaseWatch } from '../../../../common/types/watch_types'; import { getPageErrorCode, PageError, SectionLoading } from '../../components'; @@ -133,11 +133,7 @@ export const WatchEditPage = ({ const errorCode = getPageErrorCode(loadError); if (errorCode) { - return ( - - - - ); + return ; } else if (loadError) { return ( + - + ); } diff --git a/x-pack/plugins/watcher/public/application/sections/watch_list_page/watch_list_page.tsx b/x-pack/plugins/watcher/public/application/sections/watch_list_page/watch_list_page.tsx index d2a60fcce5f28..76a8328fb532a 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_list_page/watch_list_page.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_list_page/watch_list_page.tsx @@ -11,20 +11,19 @@ import { CriteriaWithPagination, EuiButton, EuiButtonEmpty, + EuiCallOut, EuiInMemoryTable, EuiIcon, EuiLink, - EuiPageContent_Deprecated as EuiPageContent, - EuiCallOut, EuiSpacer, EuiText, EuiToolTip, - EuiEmptyPrompt, EuiButtonIcon, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem, EuiPageHeader, + EuiPageTemplate, EuiSearchBarOnChangeArgs, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -178,24 +177,20 @@ export const WatchListPage = () => { if (isWatchesLoading) { return ( - + - +
); } const errorCode = getPageErrorCode(error); if (errorCode) { - return ( - - - - ); + return ; } else if (error) { return ( { ); return ( - - - - - } - body={emptyPromptBody} - actions={createWatchContextMenu} - data-test-subj="emptyPrompt" - /> - + + + + } + body={emptyPromptBody} + actions={createWatchContextMenu} + data-test-subj="emptyPrompt" + /> ); } diff --git a/x-pack/plugins/watcher/public/application/sections/watch_status_page/watch_status_page.tsx b/x-pack/plugins/watcher/public/application/sections/watch_status_page/watch_status_page.tsx index f20e4cae114e2..fd423a4f00788 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_status_page/watch_status_page.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_status_page/watch_status_page.tsx @@ -7,12 +7,12 @@ import React, { useEffect, useState } from 'react'; import { - EuiPageContent_Deprecated as EuiPageContent, EuiSpacer, EuiToolTip, EuiBadge, EuiButtonEmpty, EuiPageHeader, + EuiPageTemplate, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -77,23 +77,19 @@ export const WatchStatusPage = ({ if (isWatchDetailLoading) { return ( - + - + ); } if (errorCode) { - return ( - - - - ); + return ; } if (watchDetail) { From b5ef8a61c6c97a14b52a6088beca87b2bbb5fe70 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Tue, 22 Aug 2023 15:31:56 +0200 Subject: [PATCH 09/26] [Infra UI] Add functional test for Node Details page (#164174) part of https://github.com/elastic/kibana/issues/162898 ## Summary This PR adds a functional test for the Node Details page using the Asset Details component. It also refactors the Hosts View flyout test cases, limiting them to high-level checks. The focus of Hosts View test focuses on the page's own functionalities Detailed Asset Details-related tests will be implemented in the Node Details test suite. ### How to test ```bash yarn test:ftr:server --config x-pack/test/functional/apps/infra/config.ts ``` ```bash node scripts/functional_test_runner --config=x-pack/test/functional/apps/infra/config.ts --include x-pack/test/functional/apps/infra/hosts_view.ts ``` ```bash node scripts/functional_test_runner --config=x-pack/test/functional/apps/infra/config.ts --include x-pack/test/functional/apps/infra/node_details.ts ``` --- .../test/functional/apps/infra/constants.ts | 1 + .../test/functional/apps/infra/hosts_view.ts | 165 ++------------ x-pack/test/functional/apps/infra/index.ts | 1 + .../functional/apps/infra/node_details.ts | 215 ++++++++++++++++++ .../functional/page_objects/asset_details.ts | 132 +++++++++++ x-pack/test/functional/page_objects/index.ts | 2 + .../page_objects/infra_hosts_view.ts | 113 --------- 7 files changed, 374 insertions(+), 255 deletions(-) create mode 100644 x-pack/test/functional/apps/infra/node_details.ts create mode 100644 x-pack/test/functional/page_objects/asset_details.ts diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 7add59b59dfda..8297df165a9b7 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -45,4 +45,5 @@ export const ML_JOB_IDS = [ export const HOSTS_LINK_LOCAL_STORAGE_KEY = 'inventoryUI:hostsLinkClicked'; +export const NODE_DETAILS_PATH = 'detail/host'; export const HOSTS_VIEW_PATH = 'metrics/hosts'; diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index 11d0a9675573b..c0c425b219637 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -93,6 +93,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const security = getService('security'); const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects([ + 'assetDetails', 'common', 'infraHome', 'timePicker', @@ -281,7 +282,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Overview Tab', () => { before(async () => { - await pageObjects.infraHostsView.clickOverviewFlyoutTab(); + await pageObjects.assetDetails.clickOverviewFlyoutTab(); }); [ @@ -292,7 +293,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ].forEach(({ metric, value }) => { it(`${metric} tile should show ${value}`, async () => { await retry.try(async () => { - const tileValue = await pageObjects.infraHostsView.getAssetDetailsKPITileValue( + const tileValue = await pageObjects.assetDetails.getAssetDetailsKPITileValue( metric ); expect(tileValue).to.eql(value); @@ -301,141 +302,67 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should render 8 charts in the Metrics section', async () => { - const hosts = await pageObjects.infraHostsView.getAssetDetailsMetricsCharts(); + const hosts = await pageObjects.assetDetails.getAssetDetailsMetricsCharts(); expect(hosts.length).to.equal(8); }); - it('should navigate to metadata tab', async () => { - await pageObjects.infraHostsView.clickShowAllMetadataOverviewTab(); - await pageObjects.header.waitUntilLoadingHasFinished(); - await pageObjects.infraHostsView.metadataTableExist(); - await pageObjects.infraHostsView.clickOverviewFlyoutTab(); - }); - it('should show alerts', async () => { await pageObjects.header.waitUntilLoadingHasFinished(); - await pageObjects.infraHostsView.overviewAlertsTitleExist(); - }); - - it('should open alerts flyout', async () => { - await pageObjects.header.waitUntilLoadingHasFinished(); - await pageObjects.infraHostsView.clickOverviewOpenAlertsFlyout(); - // There are 2 flyouts open (asset details and alerts) - // so we need a stricter selector - // to be sure that we are closing the alerts flyout - const closeAlertFlyout = await find.byCssSelector( - '[aria-labelledby="flyoutRuleAddTitle"] > [data-test-subj="euiFlyoutCloseButton"]' - ); - await closeAlertFlyout.click(); - }); - - it('should navigate to alerts', async () => { - await pageObjects.infraHostsView.clickOverviewLinkToAlerts(); - await pageObjects.header.waitUntilLoadingHasFinished(); - const url = parse(await browser.getCurrentUrl()); - - const query = decodeURIComponent(url.query ?? ''); - - const alertsQuery = - "_a=(kuery:'host.name:\"Jennys-MBP.fritz.box\"',rangeFrom:'2023-03-28T18:20:00.000Z',rangeTo:'2023-03-28T18:21:00.000Z',status:all)"; - - expect(url.pathname).to.eql('/app/observability/alerts'); - expect(query).to.contain(alertsQuery); - - await returnTo(HOSTS_VIEW_PATH); + await pageObjects.assetDetails.overviewAlertsTitleExists(); }); }); describe('Metadata Tab', () => { before(async () => { - await pageObjects.infraHostsView.clickMetadataFlyoutTab(); + await pageObjects.assetDetails.clickMetadataFlyoutTab(); }); - it('should render metadata tab, add and remove filter', async () => { - await pageObjects.infraHostsView.metadataTableExist(); - - // Add Pin - await pageObjects.infraHostsView.clickAddMetadataPin(); - expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(true); - - // Persist pin after refresh - await browser.refresh(); - await retry.try(async () => { - await pageObjects.infraHome.waitForLoading(); - const removePinExist = await pageObjects.infraHostsView.getRemovePinExist(); - expect(removePinExist).to.be(true); - }); - - // Remove Pin - await pageObjects.infraHostsView.clickRemoveMetadataPin(); - expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(false); + it('should show metadata table', async () => { + await pageObjects.assetDetails.metadataTableExists(); + }); - await pageObjects.infraHostsView.clickAddMetadataFilter(); + it('should render metadata tab, add and remove filter', async () => { + // Add Filter + await pageObjects.assetDetails.clickAddMetadataFilter(); await pageObjects.header.waitUntilLoadingHasFinished(); - // Add Filter - const addedFilter = await pageObjects.infraHostsView.getAppliedFilter(); + const addedFilter = await pageObjects.assetDetails.getMetadataAppliedFilter(); expect(addedFilter).to.contain('host.architecture: arm64'); - const removeFilterExists = await pageObjects.infraHostsView.getRemoveFilterExist(); + const removeFilterExists = await pageObjects.assetDetails.metadataRemoveFilterExists(); expect(removeFilterExists).to.be(true); // Remove filter - await pageObjects.infraHostsView.clickRemoveMetadataFilter(); + await pageObjects.assetDetails.clickRemoveMetadataFilter(); await pageObjects.header.waitUntilLoadingHasFinished(); const removeFilterShouldNotExist = - await pageObjects.infraHostsView.getRemoveFilterExist(); + await pageObjects.assetDetails.metadataRemovePinExists(); expect(removeFilterShouldNotExist).to.be(false); }); - - it('should render metadata tab, pin and unpin table row', async () => { - // Add Pin - await pageObjects.infraHostsView.clickAddMetadataPin(); - expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(true); - - // Persist pin after refresh - await browser.refresh(); - await retry.try(async () => { - await pageObjects.infraHome.waitForLoading(); - const removePinExist = await pageObjects.infraHostsView.getRemovePinExist(); - expect(removePinExist).to.be(true); - }); - - // Remove Pin - await pageObjects.infraHostsView.clickRemoveMetadataPin(); - expect(await pageObjects.infraHostsView.getRemovePinExist()).to.be(false); - }); }); describe('Processes Tab', () => { before(async () => { - await pageObjects.infraHostsView.clickProcessesFlyoutTab(); - }); - it('should render processes tab and with Total Value summary', async () => { - const processesTotalValue = - await pageObjects.infraHostsView.getProcessesTabContentTotalValue(); - const processValue = await processesTotalValue.getVisibleText(); - expect(processValue).to.eql('313'); + await pageObjects.assetDetails.clickProcessesFlyoutTab(); }); - it('should expand processes table row', async () => { - await pageObjects.infraHostsView.getProcessesTable(); - await pageObjects.infraHostsView.getProcessesTableBody(); - await pageObjects.infraHostsView.clickProcessesTableExpandButton(); + it('should show processes table', async () => { + await pageObjects.assetDetails.processesTableExists(); }); }); describe('Logs Tab', () => { before(async () => { - await pageObjects.infraHostsView.clickLogsFlyoutTab(); + await pageObjects.assetDetails.clickLogsFlyoutTab(); }); + it('should render logs tab', async () => { - await testSubjects.existOrFail('infraAssetDetailsLogsTabContent'); + await pageObjects.assetDetails.logsExists(); }); }); describe('Flyout links', () => { it('should navigate to APM services after click', async () => { - await pageObjects.infraHostsView.clickFlyoutApmServicesLink(); + await pageObjects.assetDetails.clickApmServicesLink(); const url = parse(await browser.getCurrentUrl()); const query = decodeURIComponent(url.query ?? ''); const kuery = 'kuery=host.hostname:"Jennys-MBP.fritz.box"'; @@ -447,52 +374,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); }); - - describe('Host with alerts', () => { - before(async () => { - await pageObjects.timePicker.setAbsoluteRange( - START_DATE.format(timepickerFormat), - END_DATE.format(timepickerFormat) - ); - await pageObjects.infraHostsView.clickHostCheckbox('demo-stack-mysql-01', '-'); - await pageObjects.infraHostsView.clickSelectedHostsButton(); - await pageObjects.infraHostsView.clickSelectedHostsAddFilterButton(); - - await waitForPageToLoad(); - - await pageObjects.infraHostsView.clickTableOpenFlyoutButton(); - }); - - after(async () => { - await retry.try(async () => { - await pageObjects.infraHostsView.clickCloseFlyoutButton(); - }); - }); - - it('should render alerts count for a host inside a flyout', async () => { - await pageObjects.infraHostsView.clickOverviewFlyoutTab(); - - retry.tryForTime(30 * 1000, async () => { - await observability.components.alertSummaryWidget.getFullSizeComponentSelectorOrFail(); - }); - - const activeAlertsCount = - await observability.components.alertSummaryWidget.getActiveAlertCount(); - const totalAlertsCount = - await observability.components.alertSummaryWidget.getTotalAlertCount(); - - expect(activeAlertsCount.trim()).to.equal('2'); - expect(totalAlertsCount.trim()).to.equal('3'); - }); - - it('should render "N/A" when processes summary is not available in flyout', async () => { - await pageObjects.infraHostsView.clickProcessesFlyoutTab(); - const processesTotalValue = - await pageObjects.infraHostsView.getProcessesTabContentTotalValue(); - const processValue = await processesTotalValue.getVisibleText(); - expect(processValue).to.eql('N/A'); - }); - }); }); describe('#Page Content', () => { diff --git a/x-pack/test/functional/apps/infra/index.ts b/x-pack/test/functional/apps/infra/index.ts index e43c9bb47a5dc..b389d56b9032c 100644 --- a/x-pack/test/functional/apps/infra/index.ts +++ b/x-pack/test/functional/apps/infra/index.ts @@ -18,6 +18,7 @@ export default ({ loadTestFile }: FtrProviderContext) => { loadTestFile(require.resolve('./metrics_source_configuration')); loadTestFile(require.resolve('./metrics_anomalies')); loadTestFile(require.resolve('./metrics_explorer')); + loadTestFile(require.resolve('./node_details')); loadTestFile(require.resolve('./hosts_view')); }); diff --git a/x-pack/test/functional/apps/infra/node_details.ts b/x-pack/test/functional/apps/infra/node_details.ts new file mode 100644 index 0000000000000..60b6590eef788 --- /dev/null +++ b/x-pack/test/functional/apps/infra/node_details.ts @@ -0,0 +1,215 @@ +/* + * 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 moment from 'moment'; +import expect from '@kbn/expect'; +import rison from '@kbn/rison'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { DATES, NODE_DETAILS_PATH } from './constants'; + +const START_HOST_ALERTS_DATE = moment.utc(DATES.metricsAndLogs.hosts.min); +const END_HOST_ALERTS_DATE = moment.utc(DATES.metricsAndLogs.hosts.max); +const START_HOST_PROCESSES_DATE = moment.utc(DATES.metricsAndLogs.hosts.processesDataStartDate); +const END_HOST_PROCESSES_DATE = moment.utc(DATES.metricsAndLogs.hosts.processesDataEndDate); + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const observability = getService('observability'); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['assetDetails', 'common', 'infraHome', 'header']); + + const getNodeDetailsUrl = (assetName: string, dateRange: { from: string; to: string }) => { + const queryParams = new URLSearchParams(); + + queryParams.set( + '_a', + rison.encode({ + autoReload: false, + refreshInterval: 5000, + time: { ...dateRange, interval: '>1m' }, + }) + ); + + queryParams.set('assetName', assetName); + + return queryParams.toString(); + }; + + const navigateToNodeDetails = async ( + assetId: string, + assetName: string, + dateRange: { from: string; to: string } + ) => { + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraOps', + `/${NODE_DETAILS_PATH}/${assetId}`, + getNodeDetailsUrl(assetName, dateRange), + { + insertTimestamp: false, + ensureCurrentUrl: false, + useActualUrl: true, + } + ); + }; + + describe('Node Details', () => { + describe('#With Asset Details', () => { + describe('#Asset Type: host', () => { + before(async () => { + await Promise.all([ + esArchiver.load('x-pack/test/functional/es_archives/infra/alerts'), + esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'), + esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_hosts_processes'), + kibanaServer.savedObjects.cleanStandardList(), + ]); + await browser.setWindowSize(1600, 1200); + + await navigateToNodeDetails('Jennys-MBP.fritz.box', 'Jennys-MBP.fritz.box', { + from: START_HOST_PROCESSES_DATE.toISOString(), + to: END_HOST_PROCESSES_DATE.toISOString(), + }); + + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await Promise.all([ + esArchiver.unload('x-pack/test/functional/es_archives/infra/alerts'), + esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'), + esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_hosts_processes'), + ]); + }); + + describe('Overview Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickOverviewFlyoutTab(); + }); + + [ + { metric: 'cpuUsage', value: '13.9%' }, + { metric: 'normalizedLoad1m', value: '18.8%' }, + { metric: 'memoryUsage', value: '94.9%' }, + { metric: 'diskSpaceUsage', value: 'N/A' }, + ].forEach(({ metric, value }) => { + it(`${metric} tile should show ${value}`, async () => { + await retry.tryForTime(3 * 1000, async () => { + const tileValue = await pageObjects.assetDetails.getAssetDetailsKPITileValue( + metric + ); + expect(tileValue).to.eql(value); + }); + }); + }); + + it('should render 8 charts in the Metrics section', async () => { + const hosts = await pageObjects.assetDetails.getAssetDetailsMetricsCharts(); + expect(hosts.length).to.equal(8); + }); + + it('should show alerts', async () => { + await pageObjects.header.waitUntilLoadingHasFinished(); + await pageObjects.assetDetails.overviewAlertsTitleExists(); + }); + }); + + describe('Metadata Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickMetadataFlyoutTab(); + }); + + it('should show metadata table', async () => { + await pageObjects.assetDetails.metadataTableExists(); + }); + + it('should render metadata tab, pin and unpin table row', async () => { + // Add Pin + await pageObjects.assetDetails.clickAddMetadataPin(); + expect(await pageObjects.assetDetails.metadataRemovePinExists()).to.be(true); + + // Persist pin after refresh + await browser.refresh(); + await retry.try(async () => { + // Temporary until URL state isn't implemented + await pageObjects.assetDetails.clickMetadataFlyoutTab(); + await pageObjects.infraHome.waitForLoading(); + const removePinExist = await pageObjects.assetDetails.metadataRemovePinExists(); + expect(removePinExist).to.be(true); + }); + + // Remove Pin + await pageObjects.assetDetails.clickRemoveMetadataPin(); + expect(await pageObjects.assetDetails.metadataRemovePinExists()).to.be(false); + }); + }); + + describe('Processes Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickProcessesFlyoutTab(); + }); + + it('should render processes tab and with Total Value summary', async () => { + const processesTotalValue = + await pageObjects.assetDetails.getProcessesTabContentTotalValue(); + const processValue = await processesTotalValue.getVisibleText(); + expect(processValue).to.eql('313'); + }); + + it('should expand processes table row', async () => { + await pageObjects.assetDetails.processesTableExists(); + await pageObjects.assetDetails.getProcessesTableBody(); + await pageObjects.assetDetails.clickProcessesTableExpandButton(); + }); + }); + + describe('Logs Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickLogsFlyoutTab(); + }); + it('should render logs tab', async () => { + await testSubjects.existOrFail('infraAssetDetailsLogsTabContent'); + }); + }); + + describe('Host with alerts and no processes', () => { + before(async () => { + await navigateToNodeDetails('demo-stack-mysql-01', 'demo-stack-mysql-01', { + from: START_HOST_ALERTS_DATE.toISOString(), + to: END_HOST_ALERTS_DATE.toISOString(), + }); + }); + + it('should render alerts count for a host inside a flyout', async () => { + await pageObjects.assetDetails.clickOverviewFlyoutTab(); + + retry.tryForTime(30 * 1000, async () => { + await observability.components.alertSummaryWidget.getFullSizeComponentSelectorOrFail(); + }); + + const activeAlertsCount = + await observability.components.alertSummaryWidget.getActiveAlertCount(); + const totalAlertsCount = + await observability.components.alertSummaryWidget.getTotalAlertCount(); + + expect(activeAlertsCount.trim()).to.equal('2'); + expect(totalAlertsCount.trim()).to.equal('3'); + }); + + it('should render "N/A" when processes summary is not available in flyout', async () => { + await pageObjects.assetDetails.clickProcessesFlyoutTab(); + const processesTotalValue = + await pageObjects.assetDetails.getProcessesTabContentTotalValue(); + const processValue = await processesTotalValue.getVisibleText(); + expect(processValue).to.eql('N/A'); + }); + }); + }); + }); + }); +}; diff --git a/x-pack/test/functional/page_objects/asset_details.ts b/x-pack/test/functional/page_objects/asset_details.ts new file mode 100644 index 0000000000000..ee1084a86987e --- /dev/null +++ b/x-pack/test/functional/page_objects/asset_details.ts @@ -0,0 +1,132 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +export function AssetDetailsProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + // Overview + async clickOverviewFlyoutTab() { + return testSubjects.click('infraAssetDetailsOverviewTab'); + }, + + async getAssetDetailsKPITileValue(type: string) { + const container = await testSubjects.find('infraAssetDetailsKPIGrid'); + const element = await container.findByTestSubject(`infraAssetDetailsKPI${type}`); + const div = await element.findByClassName('echMetricText__value'); + return div.getAttribute('title'); + }, + + async overviewAlertsTitleExists() { + return testSubjects.existOrFail('infraAssetDetailsAlertsTitle'); + }, + + async getAssetDetailsMetricsCharts() { + const container = await testSubjects.find('infraAssetDetailsMetricsChartGrid'); + return container.findAllByCssSelector('[data-test-subj*="infraAssetDetailsMetricsChart"]'); + }, + + async clickOverviewLinkToAlerts() { + return testSubjects.click('infraAssetDetailsAlertsShowAllButton'); + }, + + async clickOverviewOpenAlertsFlyout() { + return testSubjects.click('infraAssetDetailsCreateAlertsRuleButton'); + }, + + async clickShowAllMetadataOverviewTab() { + return testSubjects.click('infraAssetDetailsMetadataShowAllButton'); + }, + + async clickApmServicesLink() { + return testSubjects.click('infraAssetDetailsViewAPMServicesButton'); + }, + + // Metadata + async clickMetadataFlyoutTab() { + return testSubjects.click('infraAssetDetailsMetadataTab'); + }, + + async clickAddMetadataPin() { + return testSubjects.click('infraAssetDetailsMetadataAddPin'); + }, + + async clickRemoveMetadataPin() { + return testSubjects.click('infraAssetDetailsMetadataRemovePin'); + }, + + async clickAddMetadataFilter() { + return testSubjects.click('infraAssetDetailsMetadataAddFilterButton'); + }, + + async clickRemoveMetadataFilter() { + return testSubjects.click('infraAssetDetailsMetadataRemoveFilterButton'); + }, + + async metadataTableExists() { + return testSubjects.existOrFail('infraAssetDetailsMetadataTable'); + }, + + async metadataRemovePinExists() { + return testSubjects.exists('infraAssetDetailsMetadataRemovePin'); + }, + + async getMetadataAppliedFilter() { + const filter = await testSubjects.find( + "filter-badge-'host.architecture: arm64' filter filter-enabled filter-key-host.architecture filter-value-arm64 filter-unpinned filter-id-0" + ); + return filter.getVisibleText(); + }, + + async metadataRemoveFilterExists() { + return testSubjects.exists('infraAssetDetailsMetadataRemoveFilterButton'); + }, + + // Processes + async clickProcessesFlyoutTab() { + return testSubjects.click('infraAssetDetailsProcessesTab'); + }, + + async getProcessesTabContentTitle(index: number) { + const processesListElements = await testSubjects.findAll( + 'infraAssetDetailsProcessesSummaryTableItem' + ); + return processesListElements[index].findByCssSelector('dt'); + }, + + async getProcessesTabContentTotalValue() { + const processesListElements = await testSubjects.findAll( + 'infraAssetDetailsProcessesSummaryTableItem' + ); + return processesListElements[0].findByCssSelector('dd'); + }, + + async processesTableExists() { + return testSubjects.existOrFail('infraAssetDetailsProcessesTable'); + }, + + async getProcessesTableBody() { + const processesTable = await testSubjects.find('infraAssetDetailsProcessesTable'); + return processesTable.findByCssSelector('tbody'); + }, + + async clickProcessesTableExpandButton() { + return testSubjects.click('infraProcessRowButton'); + }, + + // Logs + async clickLogsFlyoutTab() { + return testSubjects.click('infraAssetDetailsLogsTab'); + }, + + async logsExists() { + await testSubjects.existOrFail('infraAssetDetailsLogsTabContent'); + }, + }; +} diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 786b8294a1347..7e0409b3c19d1 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -9,6 +9,7 @@ import { pageObjects as kibanaFunctionalPageObjects } from '../../../../test/fun import { AccountSettingsPageObject } from './account_settings_page'; import { ApiKeysPageProvider } from './api_keys_page'; +import { AssetDetailsProvider } from './asset_details'; import { BannersPageObject } from './banners_page'; import { CanvasPageProvider } from './canvas_page'; import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page'; @@ -54,6 +55,7 @@ export const pageObjects = { ...kibanaFunctionalPageObjects, accountSetting: AccountSettingsPageObject, apiKeys: ApiKeysPageProvider, + assetDetails: AssetDetailsProvider, banners: BannersPageObject, canvas: CanvasPageProvider, copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider, diff --git a/x-pack/test/functional/page_objects/infra_hosts_view.ts b/x-pack/test/functional/page_objects/infra_hosts_view.ts index 25e4b0302a763..e449fb8f05b57 100644 --- a/x-pack/test/functional/page_objects/infra_hosts_view.ts +++ b/x-pack/test/functional/page_objects/infra_hosts_view.ts @@ -45,60 +45,6 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { return await testSubjects.click('inventory-hostsView-link-badge'); }, - // Asset Details Flyout - - async clickOverviewFlyoutTab() { - return testSubjects.click('infraAssetDetailsOverviewTab'); - }, - - async clickMetadataFlyoutTab() { - return testSubjects.click('infraAssetDetailsMetadataTab'); - }, - - async clickProcessesFlyoutTab() { - return testSubjects.click('infraAssetDetailsProcessesTab'); - }, - - async clickLogsFlyoutTab() { - return testSubjects.click('infraAssetDetailsLogsTab'); - }, - - async clickOverviewLinkToAlerts() { - return testSubjects.click('infraAssetDetailsAlertsShowAllButton'); - }, - - async clickOverviewOpenAlertsFlyout() { - return testSubjects.click('infraAssetDetailsCreateAlertsRuleButton'); - }, - - async clickShowAllMetadataOverviewTab() { - return testSubjects.click('infraAssetDetailsMetadataShowAllButton'); - }, - - async clickProcessesTableExpandButton() { - return testSubjects.click('infraProcessRowButton'); - }, - - async clickFlyoutApmServicesLink() { - return testSubjects.click('infraAssetDetailsViewAPMServicesButton'); - }, - - async clickAddMetadataPin() { - return testSubjects.click('infraAssetDetailsMetadataAddPin'); - }, - - async clickRemoveMetadataPin() { - return testSubjects.click('infraAssetDetailsMetadataRemovePin'); - }, - - async clickAddMetadataFilter() { - return testSubjects.click('infraAssetDetailsMetadataAddFilterButton'); - }, - - async clickRemoveMetadataFilter() { - return testSubjects.click('infraAssetDetailsMetadataRemoveFilterButton'); - }, - // Splash screen async getHostsLandingPageDisabled() { @@ -208,65 +154,6 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { return div.getAttribute('title'); }, - // Asset Details Flyout Tabs - async getAssetDetailsKPITileValue(type: string) { - const container = await testSubjects.find('infraAssetDetailsKPIGrid'); - const element = await container.findByTestSubject(`infraAssetDetailsKPI${type}`); - const div = await element.findByClassName('echMetricText__value'); - return div.getAttribute('title'); - }, - - overviewAlertsTitleExist() { - return testSubjects.exists('infraAssetDetailsAlertsTitle'); - }, - - async getAssetDetailsMetricsCharts() { - const container = await testSubjects.find('infraAssetDetailsMetricsChartGrid'); - return container.findAllByCssSelector('[data-test-subj*="infraAssetDetailsMetricsChart"]'); - }, - - metadataTableExist() { - return testSubjects.exists('infraAssetDetailsMetadataTable'); - }, - - async getRemovePinExist() { - return testSubjects.exists('infraAssetDetailsMetadataRemovePin'); - }, - - async getAppliedFilter() { - const filter = await testSubjects.find( - "filter-badge-'host.architecture: arm64' filter filter-enabled filter-key-host.architecture filter-value-arm64 filter-unpinned filter-id-0" - ); - return filter.getVisibleText(); - }, - - async getRemoveFilterExist() { - return testSubjects.exists('infraAssetDetailsMetadataRemoveFilterButton'); - }, - - async getProcessesTabContentTitle(index: number) { - const processesListElements = await testSubjects.findAll( - 'infraAssetDetailsProcessesSummaryTableItem' - ); - return processesListElements[index].findByCssSelector('dt'); - }, - - async getProcessesTabContentTotalValue() { - const processesListElements = await testSubjects.findAll( - 'infraAssetDetailsProcessesSummaryTableItem' - ); - return processesListElements[0].findByCssSelector('dd'); - }, - - getProcessesTable() { - return testSubjects.find('infraAssetDetailsProcessesTable'); - }, - - async getProcessesTableBody() { - const processesTable = await this.getProcessesTable(); - return processesTable.findByCssSelector('tbody'); - }, - // Logs Tab getLogsTab() { return testSubjects.find('hostsView-tabs-logs'); From cd219942d12083897d7c56723f83da2a2f3c2586 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 22 Aug 2023 14:39:58 +0100 Subject: [PATCH 10/26] [ML] Fix anomaly detection module manifest queries for kibana sample data sets (#164332) Follow up to https://github.com/elastic/kibana/pull/119635 All modules which contain a query in their manifest file should have a filter to avoid querying cold and frozen tiers. The original PR did not include the sample data sets as it was assumed it was not necessary due to these data sets never being added to cold or frozen tiers. However it was overlooked that these queries will be run on any index pattern passed to the `/internal/ml/modules/recognize` endpoint and so has the potential to negatively impact the speed of all calls to this endpoint, especially if the index pattern contains multiple indices and wildcards. --- .../modules/sample_data_ecommerce/manifest.json | 3 ++- .../data_recognizer/modules/sample_data_weblogs/manifest.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json index 11d4f8e0b97df..0108292be19b7 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json @@ -7,7 +7,8 @@ "defaultIndexPattern": "kibana_sample_data_ecommerce", "query": { "bool": { - "filter": [{ "term": { "event.dataset": "sample_ecommerce" } }] + "filter": [{ "term": { "event.dataset": "sample_ecommerce" } }], + "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } } }, "jobs": [ diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json index 446f56a717e11..bb85d26b72f41 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json @@ -7,7 +7,8 @@ "defaultIndexPattern": "kibana_sample_data_logs", "query": { "bool": { - "filter": [{ "term": { "event.dataset": "sample_web_logs" } }] + "filter": [{ "term": { "event.dataset": "sample_web_logs" } }], + "must_not": { "terms": { "_tier": [ "data_frozen", "data_cold" ] } } } }, "jobs": [ From 03e2ba9ad0e37bf7a7f1c3e985aa582b64d74c56 Mon Sep 17 00:00:00 2001 From: GitStart <1501599+gitstart@users.noreply.github.com> Date: Tue, 22 Aug 2023 14:56:31 +0100 Subject: [PATCH 11/26] [Index Management] Migrate all usages of EuiPage*_Deprecated (#163133) --- .../component_template_create.tsx | 10 +-- .../component_template_edit.tsx | 10 +-- .../data_stream_list/data_stream_list.tsx | 6 +- .../index_list/index_table/index_table.js | 71 ++++++++----------- .../template_clone/template_clone.tsx | 6 +- .../template_create/template_create.tsx | 6 +- .../sections/template_edit/template_edit.tsx | 10 +-- 7 files changed, 47 insertions(+), 72 deletions(-) diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx index a7e9a504eb8cc..548577c486aca 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx @@ -8,11 +8,7 @@ import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiPageContentBody_Deprecated as EuiPageContentBody, - EuiSpacer, - EuiPageHeader, -} from '@elastic/eui'; +import { EuiPageSection, EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { ComponentTemplateDeserialized } from '../../shared_imports'; import { useComponentTemplatesContext } from '../../component_templates_context'; @@ -63,7 +59,7 @@ export const ComponentTemplateCreate: React.FunctionComponent + @@ -85,6 +81,6 @@ export const ComponentTemplateCreate: React.FunctionComponent - + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx index 38e45ade11d01..8fa4694ee033a 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx @@ -9,11 +9,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; -import { - EuiPageContentBody_Deprecated as EuiPageContentBody, - EuiPageHeader, - EuiSpacer, -} from '@elastic/eui'; +import { EuiPageSection, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { History } from 'history'; import { useComponentTemplatesContext } from '../../component_templates_context'; @@ -165,7 +161,7 @@ export const ComponentTemplateEdit: React.FunctionComponent + @@ -192,6 +188,6 @@ export const ComponentTemplateEdit: React.FunctionComponent - + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx index ecce9b92ffc2f..0d17936ec7553 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx @@ -16,7 +16,7 @@ import { EuiText, EuiIconTip, EuiSpacer, - EuiPageContent_Deprecated as EuiPageContent, + EuiPageSection, EuiEmptyPrompt, EuiLink, } from '@elastic/eui'; @@ -270,7 +270,7 @@ export const DataStreamList: React.FunctionComponent + {renderHeader()} @@ -285,7 +285,7 @@ export const DataStreamList: React.FunctionComponent - + ); } diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index 65efc158fbf42..5f2bfb3de011b 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -19,7 +19,7 @@ import { EuiCheckbox, EuiFlexGroup, EuiFlexItem, - EuiPageContent_Deprecated as EuiPageContent, + EuiPageSection, EuiScreenReaderOnly, EuiSpacer, EuiSearchBar, @@ -435,56 +435,43 @@ export class IndexTable extends Component { const hasContent = !indicesLoading && !indicesError; if (!hasContent) { - const renderNoContent = () => { - if (indicesLoading) { - return ( - - - - ); - } - - if (indicesError) { - if (indicesError.status === 403) { - return ( - - } - /> - ); - } + if (indicesLoading) { + return ( + + + + ); + } + if (indicesError) { + if (indicesError.status === 403) { return ( } - error={indicesError.body} /> ); } - }; - return ( - - {renderNoContent()} - - ); + return ( + + } + error={indicesError.body} + /> + ); + } } const { selectedIndicesMap } = this.state; @@ -496,7 +483,7 @@ export class IndexTable extends Component { const { extensionsService } = services; return ( - + @@ -665,7 +652,7 @@ export class IndexTable extends Component { {indices.length > 0 ? this.renderPager() : null} - + ); }} diff --git a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx index eff5cbb554904..25b84a8bc6fde 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiPageContentBody_Deprecated as EuiPageContentBody } from '@elastic/eui'; +import { EuiPageSection } from '@elastic/eui'; import { ScopedHistory } from '@kbn/core/public'; import { PageLoading, PageError, Error } from '../../../shared_imports'; @@ -103,7 +103,7 @@ export const TemplateClone: React.FunctionComponent + - + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx index e5422ca93db26..6961c223e6993 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiPageContentBody_Deprecated as EuiPageContentBody } from '@elastic/eui'; +import { EuiPageSection } from '@elastic/eui'; import { useLocation } from 'react-router-dom'; import { parse } from 'query-string'; import { ScopedHistory } from '@kbn/core/public'; @@ -57,7 +57,7 @@ export const TemplateCreate: React.FunctionComponent = ({ h }, []); return ( - + = ({ h isLegacy={isLegacy} history={history as ScopedHistory} /> - + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index b0a6b95351386..c37a7ce44b672 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -9,11 +9,7 @@ import React, { useEffect, useState, Fragment } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiPageContentBody_Deprecated as EuiPageContentBody, - EuiSpacer, - EuiCallOut, -} from '@elastic/eui'; +import { EuiPageSection, EuiSpacer, EuiCallOut } from '@elastic/eui'; import { ScopedHistory } from '@kbn/core/public'; import { TemplateDeserialized } from '../../../../common'; @@ -131,7 +127,7 @@ export const TemplateEdit: React.FunctionComponent + {isSystemTemplate && ( - + ); }; From 813eebe1cef0fa63245f1a9df2c0e1b5a1945b6c Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Tue, 22 Aug 2023 16:02:11 +0200 Subject: [PATCH 12/26] [Security Solutions] Add telemetry to the side navigation tracking for serverless pages (#164309) issue: https://github.com/elastic/kibana/issues/164306 ## Summary Add telemetry to serverless-specific Security pages Pages: Investigations, Assets, ML, Project Settings. ## How to test it * Visit Investigations, Assets, ML, and Project Settings. When you navigate between pages the app should log "Reporting application usage for securitySolutionUI, {PAGE}" like it does for the other pages --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution_serverless/kibana.jsonc | 5 ++- .../public/pages/assets.tsx | 37 ++++++++++--------- .../public/pages/investigations.tsx | 11 ++++-- .../public/pages/machine_learning.tsx | 11 ++++-- .../public/pages/project_settings.tsx | 17 +++++---- .../tsconfig.json | 3 +- 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution_serverless/kibana.jsonc b/x-pack/plugins/security_solution_serverless/kibana.jsonc index 68b6eb71af8d5..3f9080adc5173 100644 --- a/x-pack/plugins/security_solution_serverless/kibana.jsonc +++ b/x-pack/plugins/security_solution_serverless/kibana.jsonc @@ -25,6 +25,9 @@ "optionalPlugins": [ "securitySolutionEss" ], - "requiredBundles": ["kibanaUtils"] + "requiredBundles": [ + "kibanaUtils", + "usageCollection" + ] } } diff --git a/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx b/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx index 1781a52769297..ae3b90c0ec877 100644 --- a/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx +++ b/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx @@ -12,6 +12,7 @@ import { SecurityPageName } from '@kbn/security-solution-navigation'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { EuiCallOut, EuiPageHeader, EuiSpacer, useEuiTheme } from '@elastic/eui'; import { LinkButton } from '@kbn/security-solution-navigation/links'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { useNavLink } from '../common/hooks/use_nav_links'; import { ExternalPageName } from '../navigation/links/constants'; @@ -42,23 +43,25 @@ export const AssetsRoute: React.FC = () => { return ( - - - - - - - -

{INTEGRATIONS_CALLOUT_DESCRIPTION}

- - {INTEGRATIONS_CALLOUT_BUTTON_TEXT} - -
+ + + + + + + + +

{INTEGRATIONS_CALLOUT_DESCRIPTION}

+ + {INTEGRATIONS_CALLOUT_BUTTON_TEXT} + +
+
); diff --git a/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx b/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx index 0aeedb8133a58..af217b94e8998 100644 --- a/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx +++ b/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx @@ -10,6 +10,7 @@ import { LandingLinksIcons } from '@kbn/security-solution-navigation/landing_lin import { SecurityPageName } from '@kbn/security-solution-navigation'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { useNavLink } from '../common/hooks/use_nav_links'; export const InvestigationsRoute: React.FC = () => { @@ -19,10 +20,12 @@ export const InvestigationsRoute: React.FC = () => { return ( - - - - + + + + + + ); diff --git a/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx b/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx index 84d5841d14a07..a790e7784e37e 100644 --- a/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx +++ b/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx @@ -10,6 +10,7 @@ import { LandingLinksIconsCategories } from '@kbn/security-solution-navigation/l import { SecurityPageName } from '@kbn/security-solution-navigation'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { useNavLink } from '../common/hooks/use_nav_links'; export const MachineLearningRoute: React.FC = () => { @@ -19,10 +20,12 @@ export const MachineLearningRoute: React.FC = () => { return ( - - - - + + + + + + ); diff --git a/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx b/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx index dc9e81b819411..9c3f8e5012a67 100644 --- a/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx +++ b/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx @@ -18,6 +18,7 @@ import { SecurityPageName, } from '@kbn/security-solution-navigation'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { useNavLink } from '../common/hooks/use_nav_links'; export const ProjectSettingsRoute: React.FC = () => { @@ -34,13 +35,15 @@ export const ProjectSettingsRoute: React.FC = () => { return ( - - - - - - - + + + + + + + + + ); diff --git a/x-pack/plugins/security_solution_serverless/tsconfig.json b/x-pack/plugins/security_solution_serverless/tsconfig.json index ba8d4bbd9688f..6ea4adba54291 100644 --- a/x-pack/plugins/security_solution_serverless/tsconfig.json +++ b/x-pack/plugins/security_solution_serverless/tsconfig.json @@ -37,6 +37,7 @@ "@kbn/cloud-plugin", "@kbn/cloud-security-posture-plugin", "@kbn/fleet-plugin", - "@kbn/core-elasticsearch-server" + "@kbn/core-elasticsearch-server", + "@kbn/usage-collection-plugin" ] } From 9c83d2eeb160ca0434127b9b1ddbe1432836e9ce Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Tue, 22 Aug 2023 10:29:42 -0400 Subject: [PATCH 13/26] [Ingest Pipelines] Add serverless test coverage (#163208) --- .../ingest_pipelines/ingest_pipelines.ts | 344 ++++---------- .../management/ingest_pipelines/lib/api.ts | 69 +++ .../ingest_pipelines/lib/elasticsearch.ts | 70 --- .../ingest_pipelines/lib/fixtures.ts | 109 +++++ .../management/ingest_pipelines/lib/index.ts | 3 +- x-pack/test/api_integration/services/index.ts | 2 + .../services/ingest_pipelines.ts | 22 + .../test_suites/common/index.ts | 1 + .../test_suites/common/ingest_pipelines.ts | 440 ++++++++++++++++++ 9 files changed, 744 insertions(+), 316 deletions(-) create mode 100644 x-pack/test/api_integration/apis/management/ingest_pipelines/lib/api.ts delete mode 100644 x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts create mode 100644 x-pack/test/api_integration/apis/management/ingest_pipelines/lib/fixtures.ts create mode 100644 x-pack/test/api_integration/services/ingest_pipelines.ts create mode 100644 x-pack/test_serverless/api_integration/test_suites/common/ingest_pipelines.ts diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index 3c044250b00bf..c3d3406f61eb2 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -6,66 +6,28 @@ */ import expect from '@kbn/expect'; -import { registerEsHelpers } from './lib'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; -const API_BASE_PATH = '/api/ingest_pipelines'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - - const { createPipeline, deletePipeline, cleanupPipelines, createIndex, deleteIndex } = - registerEsHelpers(getService); + const ingestPipelines = getService('ingestPipelines'); + const log = getService('log'); describe('Pipelines', function () { after(async () => { - await cleanupPipelines(); + await ingestPipelines.api.deletePipelines(); }); describe('Create', () => { - const PIPELINE_ID = 'test_create_pipeline'; - const REQUIRED_FIELDS_PIPELINE_ID = 'test_create_required_fields_pipeline'; - - after(async () => { - // Clean up any pipelines created in test cases - await Promise.all([PIPELINE_ID, REQUIRED_FIELDS_PIPELINE_ID].map(deletePipeline)).catch( - (err) => { - // eslint-disable-next-line no-console - console.log(`[Cleanup error] Error deleting pipelines: ${err.message}`); - throw err; - } - ); - }); - it('should create a pipeline', async () => { + const pipelineRequestBody = ingestPipelines.fixtures.createPipelineBody(); const { body } = await supertest - .post(API_BASE_PATH) + .post(ingestPipelines.fixtures.apiBasePath) .set('kbn-xsrf', 'xxx') - .send({ - name: PIPELINE_ID, - description: 'test pipeline description', - processors: [ - { - script: { - source: 'ctx._type = null', - }, - }, - ], - on_failure: [ - { - set: { - field: 'error.message', - value: '{{ failure_message }}', - }, - }, - ], - version: 1, - _meta: { - field_1: 'test', - field_2: 10, - }, - }) + .send(pipelineRequestBody) .expect(200); expect(body).to.eql({ @@ -74,20 +36,12 @@ export default function ({ getService }: FtrProviderContext) { }); it('should create a pipeline with only required fields', async () => { + const pipelineRequestBody = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); // Includes name and processors[] only + const { body } = await supertest - .post(API_BASE_PATH) + .post(ingestPipelines.fixtures.apiBasePath) .set('kbn-xsrf', 'xxx') - // Excludes description, version, on_failure processors, and _meta - .send({ - name: REQUIRED_FIELDS_PIPELINE_ID, - processors: [ - { - script: { - source: 'ctx._type = null', - }, - }, - ], - }) + .send(pipelineRequestBody) .expect(200); expect(body).to.eql({ @@ -96,80 +50,56 @@ export default function ({ getService }: FtrProviderContext) { }); it('should not allow creation of an existing pipeline', async () => { + const pipelineRequestBody = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); // Includes name and processors[] only + + const { name, ...esPipelineRequestBody } = pipelineRequestBody; + + // First, create a pipeline using the ES API + await ingestPipelines.api.createPipeline({ id: name, ...esPipelineRequestBody }); + + // Then, create a pipeline with our internal API const { body } = await supertest - .post(API_BASE_PATH) + .post(ingestPipelines.fixtures.apiBasePath) .set('kbn-xsrf', 'xxx') - .send({ - name: PIPELINE_ID, - description: 'test pipeline description', - processors: [ - { - script: { - source: 'ctx._type = null', - }, - }, - ], - version: 1, - _meta: { - field_1: 'test', - field_2: 10, - }, - }) + .send(pipelineRequestBody) .expect(409); expect(body).to.eql({ statusCode: 409, error: 'Conflict', - message: `There is already a pipeline with name '${PIPELINE_ID}'.`, + message: `There is already a pipeline with name '${name}'.`, }); }); }); describe('Update', () => { - const PIPELINE_ID = 'test_update_pipeline'; - const PIPELINE = { - description: 'test pipeline description', - processors: [ - { - script: { - source: 'ctx._type = null', - }, - }, - ], - version: 1, - on_failure: [ - { - set: { - field: '_index', - value: 'failed-{{ _index }}', - }, - }, - ], - _meta: { - field_1: 'test', - field_2: 10, - }, - }; + let pipeline: Omit; + let pipelineName: string; before(async () => { // Create pipeline that can be used to test PUT request try { - await createPipeline({ body: PIPELINE, id: PIPELINE_ID }, true); + const pipelineRequestBody = + ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const { name, ...esPipelineRequestBody } = pipelineRequestBody; + + pipeline = esPipelineRequestBody; + pipelineName = name; + await ingestPipelines.api.createPipeline({ id: name, ...esPipelineRequestBody }); } catch (err) { - // eslint-disable-next-line no-console - console.log('[Setup error] Error creating ingest pipeline'); + log.debug('[Setup error] Error creating ingest pipeline'); throw err; } }); it('should allow an existing pipeline to be updated', async () => { - const uri = `${API_BASE_PATH}/${PIPELINE_ID}`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineName}`; const { body } = await supertest .put(uri) .set('kbn-xsrf', 'xxx') .send({ - ...PIPELINE, + ...pipeline, description: 'updated test pipeline description', _meta: { field_1: 'updated', @@ -184,14 +114,14 @@ export default function ({ getService }: FtrProviderContext) { }); it('should allow optional fields to be removed', async () => { - const uri = `${API_BASE_PATH}/${PIPELINE_ID}`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineName}`; const { body } = await supertest .put(uri) .set('kbn-xsrf', 'xxx') .send({ - processors: PIPELINE.processors, // removes description, version, on_failure, and _meta + processors: pipeline.processors, }) .expect(200); @@ -201,13 +131,13 @@ export default function ({ getService }: FtrProviderContext) { }); it('should not allow a non-existing pipeline to be updated', async () => { - const uri = `${API_BASE_PATH}/pipeline_does_not_exist`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/pipeline_does_not_exist`; const { body } = await supertest .put(uri) .set('kbn-xsrf', 'xxx') .send({ - ...PIPELINE, + ...pipeline, description: 'updated test pipeline description', _meta: { field_1: 'updated', @@ -226,118 +156,100 @@ export default function ({ getService }: FtrProviderContext) { }); describe('Get', () => { - const PIPELINE_ID = 'test_get_pipeline'; - const PIPELINE = { - description: 'test pipeline description', - processors: [ - { - script: { - source: 'ctx._type = null', - }, - }, - ], - version: 1, - _meta: { - field_1: 'test', - field_2: 10, - }, - }; + let pipeline: Omit; + let pipelineName: string; before(async () => { // Create pipeline that can be used to test GET request try { - await createPipeline({ body: PIPELINE, id: PIPELINE_ID }, true); + const pipelineRequestBody = + ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const { name, ...esPipelineRequestBody } = pipelineRequestBody; + + pipeline = esPipelineRequestBody; + pipelineName = name; + await ingestPipelines.api.createPipeline({ id: name, ...esPipelineRequestBody }); } catch (err) { - // eslint-disable-next-line no-console - console.log('[Setup error] Error creating ingest pipeline'); + log.debug('[Setup error] Error creating ingest pipeline'); throw err; } }); describe('all pipelines', () => { it('should return an array of pipelines', async () => { - const { body } = await supertest.get(API_BASE_PATH).set('kbn-xsrf', 'xxx').expect(200); + const { body } = await supertest + .get(ingestPipelines.fixtures.apiBasePath) + .set('kbn-xsrf', 'xxx') + .expect(200); expect(Array.isArray(body)).to.be(true); // There are some pipelines created OOTB with ES // To not be dependent on these, we only confirm the pipeline we created as part of the test exists - const testPipeline = body.find(({ name }: { name: string }) => name === PIPELINE_ID); + const testPipeline = body.find(({ name }: { name: string }) => name === pipelineName); expect(testPipeline).to.eql({ - ...PIPELINE, + ...pipeline, isManaged: false, - name: PIPELINE_ID, + name: pipelineName, }); }); }); describe('one pipeline', () => { it('should return a single pipeline', async () => { - const uri = `${API_BASE_PATH}/${PIPELINE_ID}`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineName}`; const { body } = await supertest.get(uri).set('kbn-xsrf', 'xxx').expect(200); expect(body).to.eql({ - ...PIPELINE, + ...pipeline, isManaged: false, - name: PIPELINE_ID, + name: pipelineName, }); }); }); }); describe('Delete', () => { - const PIPELINE = { - description: 'test pipeline description', - processors: [ - { - script: { - source: 'ctx._type = null', - }, - }, - ], - version: 1, - _meta: { - field_1: 'test', - field_2: 10, - }, - }; - - const pipelineA = { body: PIPELINE, id: 'test_delete_pipeline_a' }; - const pipelineB = { body: PIPELINE, id: 'test_delete_pipeline_b' }; - const pipelineC = { body: PIPELINE, id: 'test_delete_pipeline_c' }; - const pipelineD = { body: PIPELINE, id: 'test_delete_pipeline_d' }; + const pipelineIds: string[] = []; before(async () => { + const pipelineA = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const pipelineB = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const pipelineC = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const pipelineD = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + // Create several pipelines that can be used to test deletion await Promise.all( - [pipelineA, pipelineB, pipelineC, pipelineD].map((pipeline) => createPipeline(pipeline)) + [pipelineA, pipelineB, pipelineC, pipelineD].map((pipeline) => { + const { name, ...pipelineRequestBody } = pipeline; + pipelineIds.push(pipeline.name); + return ingestPipelines.api.createPipeline({ id: name, ...pipelineRequestBody }); + }) ).catch((err) => { - // eslint-disable-next-line no-console - console.log(`[Setup error] Error creating pipelines: ${err.message}`); + log.debug(`[Setup error] Error creating pipelines: ${err.message}`); throw err; }); }); it('should delete a pipeline', async () => { - const { id } = pipelineA; + const pipelineA = pipelineIds[0]; - const uri = `${API_BASE_PATH}/${id}`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineA}`; const { body } = await supertest.delete(uri).set('kbn-xsrf', 'xxx').expect(200); expect(body).to.eql({ - itemsDeleted: [id], + itemsDeleted: [pipelineA], errors: [], }); }); it('should delete multiple pipelines', async () => { - const { id: pipelineBId } = pipelineB; - const { id: pipelineCId } = pipelineC; - - const uri = `${API_BASE_PATH}/${pipelineBId},${pipelineCId}`; + const pipelineB = pipelineIds[1]; + const pipelineC = pipelineIds[2]; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineIds[1]},${pipelineIds[2]}`; const { body: { itemsDeleted, errors }, @@ -346,21 +258,21 @@ export default function ({ getService }: FtrProviderContext) { expect(errors).to.eql([]); // The itemsDeleted array order isn't guaranteed, so we assert against each pipeline name instead - [pipelineBId, pipelineCId].forEach((pipelineName) => { + [pipelineB, pipelineC].forEach((pipelineName) => { expect(itemsDeleted.includes(pipelineName)).to.be(true); }); }); it('should return an error for any pipelines not sucessfully deleted', async () => { const PIPELINE_DOES_NOT_EXIST = 'pipeline_does_not_exist'; - const { id: existingPipelineId } = pipelineD; + const pipelineD = pipelineIds[3]; - const uri = `${API_BASE_PATH}/${existingPipelineId},${PIPELINE_DOES_NOT_EXIST}`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineD},${PIPELINE_DOES_NOT_EXIST}`; const { body } = await supertest.delete(uri).set('kbn-xsrf', 'xxx').expect(200); expect(body).to.eql({ - itemsDeleted: [existingPipelineId], + itemsDeleted: [pipelineD], errors: [ { name: PIPELINE_DOES_NOT_EXIST, @@ -383,49 +295,14 @@ export default function ({ getService }: FtrProviderContext) { describe('Simulate', () => { it('should successfully simulate a pipeline', async () => { + const { name, ...pipeline } = ingestPipelines.fixtures.createPipelineBody(); + const documents = ingestPipelines.fixtures.createDocuments(); const { body } = await supertest - .post(`${API_BASE_PATH}/simulate`) + .post(`${ingestPipelines.fixtures.apiBasePath}/simulate`) .set('kbn-xsrf', 'xxx') .send({ - pipeline: { - description: 'test simulate pipeline description', - processors: [ - { - set: { - field: 'field2', - value: '_value', - }, - }, - ], - version: 1, - on_failure: [ - { - set: { - field: '_index', - value: 'failed-{{ _index }}', - }, - }, - ], - _meta: { - field: 'test simulate metadata', - }, - }, - documents: [ - { - _index: 'index', - _id: 'id', - _source: { - foo: 'bar', - }, - }, - { - _index: 'index', - _id: 'id', - _source: { - foo: 'rab', - }, - }, - ], + pipeline, + documents, }) .expect(200); @@ -435,36 +312,15 @@ export default function ({ getService }: FtrProviderContext) { }); it('should successfully simulate a pipeline with only required pipeline fields', async () => { + const { name, ...pipeline } = + ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const documents = ingestPipelines.fixtures.createDocuments(); const { body } = await supertest - .post(`${API_BASE_PATH}/simulate`) + .post(`${ingestPipelines.fixtures.apiBasePath}/simulate`) .set('kbn-xsrf', 'xxx') .send({ - pipeline: { - processors: [ - { - set: { - field: 'field2', - value: '_value', - }, - }, - ], - }, - documents: [ - { - _index: 'index', - _id: 'id', - _source: { - foo: 'bar', - }, - }, - { - _index: 'index', - _id: 'id', - _source: { - foo: 'rab', - }, - }, - ], + pipeline, + documents, }) .expect(200); @@ -484,10 +340,9 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { // Create an index with a document that can be used to test GET request try { - await createIndex({ id: DOCUMENT_ID, index: INDEX, body: DOCUMENT }); + await ingestPipelines.api.createIndex({ id: DOCUMENT_ID, index: INDEX, body: DOCUMENT }); } catch (err) { - // eslint-disable-next-line no-console - console.log('[Setup error] Error creating index'); + log.debug('[Setup error] Error creating index'); throw err; } }); @@ -495,16 +350,15 @@ export default function ({ getService }: FtrProviderContext) { after(async () => { // Clean up index created try { - await deleteIndex(INDEX); + await ingestPipelines.api.deleteIndex(INDEX); } catch (err) { - // eslint-disable-next-line no-console - console.log('[Cleanup error] Error deleting index'); + log.debug('[Cleanup error] Error deleting index'); throw err; } }); it('should return a document', async () => { - const uri = `${API_BASE_PATH}/documents/${INDEX}/${DOCUMENT_ID}`; + const uri = `${ingestPipelines.fixtures.apiBasePath}/documents/${INDEX}/${DOCUMENT_ID}`; const { body } = await supertest.get(uri).set('kbn-xsrf', 'xxx').expect(200); @@ -516,7 +370,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('should return an error if the document does not exist', async () => { - const uri = `${API_BASE_PATH}/documents/${INDEX}/2`; // Document 2 does not exist + const uri = `${ingestPipelines.fixtures.apiBasePath}/documents/${INDEX}/2`; // Document 2 does not exist const { body } = await supertest.get(uri).set('kbn-xsrf', 'xxx').expect(404); @@ -534,7 +388,7 @@ export default function ({ getService }: FtrProviderContext) { const validCsv = 'source_field,copy_action,format_action,timestamp_format,destination_field,Notes\nsrcip,,,,source.address,Copying srcip to source.address'; const { body } = await supertest - .post(`${API_BASE_PATH}/parse_csv`) + .post(`${ingestPipelines.fixtures.apiBasePath}/parse_csv`) .set('kbn-xsrf', 'xxx') .send({ copyAction: 'copy', diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/api.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/api.ts new file mode 100644 index 0000000000000..1e185b88b7587 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/api.ts @@ -0,0 +1,69 @@ +/* + * 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 { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export function IngestPipelinesAPIProvider({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const log = getService('log'); + + return { + async createPipeline(pipeline: IngestPutPipelineRequest) { + log.debug(`Creating pipeline: '${pipeline.id}'`); + + const createResponse = await es.ingest.putPipeline(pipeline); + expect(createResponse) + .to.have.property('acknowledged') + .eql(true, 'Response for create pipelines should be acknowledged.'); + + await this.waitForPipelinesToExist(pipeline.id, `expected ${pipeline.id} to be created`); + }, + + async waitForPipelinesToExist(pipelineId: string, errorMsg?: string) { + await retry.tryForTime(30 * 1000, async () => { + const pipeline = await es.ingest.getPipeline({ id: pipelineId }); + const pipelineNames = Object.keys(pipeline); + + if (pipelineNames.length === 1 && pipelineNames[0] === pipelineId) { + return true; + } else { + throw new Error(errorMsg || `pipeline '${pipelineId}' should exist`); + } + }); + }, + + async deletePipelines() { + const pipelines = await es.ingest.getPipeline(); + // Assumes all test pipelines will be prefixed with `test-pipeline*` + const pipelineIds = Object.keys(pipelines).filter((pipeline) => + pipeline.includes('test-pipeline') + ); + + const deletePipeline = (pipelineId: string) => es.ingest.deletePipeline({ id: pipelineId }); + + return Promise.all(pipelineIds.map(deletePipeline)).catch((err) => { + log.debug(`[Cleanup error] Error deleting ES resources: ${err.message}`); + }); + }, + + async createIndex(index: { index: string; id: string; body: object }) { + log.debug(`Creating index: '${index.index}'`); + + return await es.index(index); + }, + + async deleteIndex(indexName: string) { + log.debug(`Deleting index: '${indexName}'`); + + return await es.indices.delete({ index: indexName }); + }, + }; +} diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts deleted file mode 100644 index c2a42356f5f51..0000000000000 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts +++ /dev/null @@ -1,70 +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 { FtrProviderContext } from '../../../../ftr_provider_context'; - -interface Processor { - [key: string]: { - [key: string]: unknown; - }; -} - -interface Pipeline { - id: string; - body: { - description: string; - processors: Processor[]; - version?: number; - }; -} - -/** - * Helpers to create and delete pipelines on the Elasticsearch instance - * during our tests. - * @param {ElasticsearchClient} es The Elasticsearch client instance - */ -export const registerEsHelpers = (getService: FtrProviderContext['getService']) => { - let pipelinesCreated: string[] = []; - - const es = getService('es'); - - const createPipeline = (pipeline: Pipeline, cachePipeline?: boolean) => { - if (cachePipeline) { - pipelinesCreated.push(pipeline.id); - } - - return es.ingest.putPipeline(pipeline); - }; - - const deletePipeline = (pipelineId: string) => es.ingest.deletePipeline({ id: pipelineId }); - - const cleanupPipelines = () => - Promise.all(pipelinesCreated.map(deletePipeline)) - .then(() => { - pipelinesCreated = []; - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.log(`[Cleanup error] Error deleting ES resources: ${err.message}`); - }); - - const createIndex = (index: { index: string; id: string; body: object }) => { - return es.index(index); - }; - - const deleteIndex = (indexName: string) => { - return es.indices.delete({ index: indexName }); - }; - - return { - createPipeline, - deletePipeline, - cleanupPipelines, - createIndex, - deleteIndex, - }; -}; diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/fixtures.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/fixtures.ts new file mode 100644 index 0000000000000..c148101749085 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/fixtures.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + IngestProcessorContainer, + VersionNumber, + Metadata, + IngestPutPipelineRequest, +} from '@elastic/elasticsearch/lib/api/types'; + +interface Pipeline { + name: string; + description?: string; + onFailureProcessors?: IngestProcessorContainer[]; + processors: IngestProcessorContainer[]; + version?: VersionNumber; + metadata?: Metadata; +} + +interface IngestPutPipelineInternalRequest extends Omit { + name: string; +} + +export function IngestPipelinesFixturesProvider() { + const defaultProcessors: IngestProcessorContainer[] = [ + { + script: { + source: 'ctx._type = null', + }, + }, + ]; + + const defaultOnFailureProcessors: IngestProcessorContainer[] = [ + { + set: { + field: 'error.message', + value: '{{ failure_message }}', + }, + }, + ]; + + const defaultMetadata: Metadata = { + field_1: 'test', + field_2: 10, + }; + + const apiBasePath = '/api/ingest_pipelines'; + + const createPipelineBodyWithRequiredFields = (): IngestPutPipelineInternalRequest => { + return { + name: `test-pipeline-required-fields-${Math.random()}`, + processors: defaultProcessors, + }; + }; + + const createPipelineBody = (pipeline?: Pipeline): IngestPutPipelineInternalRequest => { + if (pipeline) { + const { name, description, processors, onFailureProcessors, version, metadata } = pipeline; + return { + name, + description, + processors, + on_failure: onFailureProcessors, + version, + _meta: metadata, + }; + } + + // Use default payload if none is provided + return { + name: `test-pipeline-${Math.random()}`, + description: 'test pipeline description', + processors: defaultProcessors, + on_failure: defaultOnFailureProcessors, + version: 1, + _meta: defaultMetadata, + }; + }; + + const createDocuments = () => { + return [ + { + _index: 'index', + _id: 'id1', + _source: { + foo: 'bar', + }, + }, + { + _index: 'index', + _id: 'id2', + _source: { + foo: 'rab', + }, + }, + ]; + }; + + return { + createPipelineBodyWithRequiredFields, + createPipelineBody, + createDocuments, + apiBasePath, + }; +} diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/index.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/index.ts index 27a4d9c59cff0..a734e993f7728 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/index.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/index.ts @@ -5,4 +5,5 @@ * 2.0. */ -export { registerEsHelpers } from './elasticsearch'; +export { IngestPipelinesAPIProvider } from './api'; +export { IngestPipelinesFixturesProvider } from './fixtures'; diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index d83825349f1e9..6ef3e393a86e6 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -20,6 +20,7 @@ import { InfraOpsSourceConfigurationProvider } from './infraops_source_configura import { MachineLearningProvider } from './ml'; import { IngestManagerProvider } from '../../common/services/ingest_manager'; import { TransformProvider } from './transform'; +import { IngestPipelinesProvider } from './ingest_pipelines'; export const services = { ...commonServices, @@ -35,4 +36,5 @@ export const services = { ml: MachineLearningProvider, ingestManager: IngestManagerProvider, transform: TransformProvider, + ingestPipelines: IngestPipelinesProvider, }; diff --git a/x-pack/test/api_integration/services/ingest_pipelines.ts b/x-pack/test/api_integration/services/ingest_pipelines.ts new file mode 100644 index 0000000000000..589ef8135bb7a --- /dev/null +++ b/x-pack/test/api_integration/services/ingest_pipelines.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 { FtrProviderContext } from '../ftr_provider_context'; +import { + IngestPipelinesAPIProvider, + IngestPipelinesFixturesProvider, +} from '../apis/management/ingest_pipelines/lib'; + +export function IngestPipelinesProvider(context: FtrProviderContext) { + const api = IngestPipelinesAPIProvider(context); + const fixtures = IngestPipelinesFixturesProvider(); + + return { + api, + fixtures, + }; +} diff --git a/x-pack/test_serverless/api_integration/test_suites/common/index.ts b/x-pack/test_serverless/api_integration/test_suites/common/index.ts index 1bfb13f2c5f2c..dbd561a07197c 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./rollups')); loadTestFile(require.resolve('./index_management')); loadTestFile(require.resolve('./alerting')); + loadTestFile(require.resolve('./ingest_pipelines')); }); } diff --git a/x-pack/test_serverless/api_integration/test_suites/common/ingest_pipelines.ts b/x-pack/test_serverless/api_integration/test_suites/common/ingest_pipelines.ts new file mode 100644 index 0000000000000..1e5ee6d39bb71 --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/common/ingest_pipelines.ts @@ -0,0 +1,440 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const ingestPipelines = getService('ingestPipelines'); + const log = getService('log'); + + describe('Ingest Pipelines', function () { + after(async () => { + await ingestPipelines.api.deletePipelines(); + }); + + describe('Create', () => { + it('should create a pipeline', async () => { + const pipelineRequestBody = ingestPipelines.fixtures.createPipelineBody(); + const { body } = await supertest + .post(ingestPipelines.fixtures.apiBasePath) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send(pipelineRequestBody); + + expect(body).to.eql({ + acknowledged: true, + }); + }); + + it('should create a pipeline with only required fields', async () => { + const pipelineRequestBody = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); // Includes name and processors[] only + + const { body } = await supertest + .post(ingestPipelines.fixtures.apiBasePath) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send(pipelineRequestBody) + .expect(200); + + expect(body).to.eql({ + acknowledged: true, + }); + }); + + it('should not allow creation of an existing pipeline', async () => { + const pipelineRequestBody = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); // Includes name and processors[] only + const { name, ...esPipelineRequestBody } = pipelineRequestBody; + + // First, create a pipeline using the ES API + await ingestPipelines.api.createPipeline({ id: name, ...esPipelineRequestBody }); + + // Then, create a pipeline with our internal API + const { body } = await supertest + .post(ingestPipelines.fixtures.apiBasePath) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send(pipelineRequestBody) + .expect(409); + + expect(body).to.eql({ + statusCode: 409, + error: 'Conflict', + message: `There is already a pipeline with name '${name}'.`, + }); + }); + }); + + describe('Update', () => { + let pipeline: Omit; + let pipelineName: string; + + before(async () => { + // Create pipeline that can be used to test PUT request + try { + const pipelineRequestBody = + ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const { name, ...esPipelineRequestBody } = pipelineRequestBody; + + pipeline = esPipelineRequestBody; + pipelineName = name; + await ingestPipelines.api.createPipeline({ id: name, ...esPipelineRequestBody }); + } catch (err) { + log.debug('[Setup error] Error creating ingest pipeline'); + throw err; + } + }); + + it('should allow an existing pipeline to be updated', async () => { + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineName}`; + + const { body } = await supertest + .put(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send({ + ...pipeline, + description: 'updated test pipeline description', + _meta: { + field_1: 'updated', + new_field: 3, + }, + }) + .expect(200); + + expect(body).to.eql({ + acknowledged: true, + }); + }); + + it('should allow optional fields to be removed', async () => { + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineName}`; + + const { body } = await supertest + .put(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send({ + // removes description, version, on_failure, and _meta + processors: pipeline.processors, + }) + .expect(200); + + expect(body).to.eql({ + acknowledged: true, + }); + }); + + it('should not allow a non-existing pipeline to be updated', async () => { + const uri = `${ingestPipelines.fixtures.apiBasePath}/pipeline_does_not_exist`; + + const { body } = await supertest + .put(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send({ + ...pipeline, + description: 'updated test pipeline description', + _meta: { + field_1: 'updated', + new_field: 3, + }, + }) + .expect(404); + + expect(body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: '{}', + attributes: {}, + }); + }); + }); + + describe('Get', () => { + let pipeline: Omit; + let pipelineName: string; + + before(async () => { + // Create pipeline that can be used to test GET request + try { + const pipelineRequestBody = + ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const { name, ...esPipelineRequestBody } = pipelineRequestBody; + + pipeline = esPipelineRequestBody; + pipelineName = name; + await ingestPipelines.api.createPipeline({ id: name, ...esPipelineRequestBody }); + } catch (err) { + log.debug('[Setup error] Error creating ingest pipeline'); + throw err; + } + }); + + describe('all pipelines', () => { + it('should return an array of pipelines', async () => { + const { body } = await supertest + .get(ingestPipelines.fixtures.apiBasePath) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(Array.isArray(body)).to.be(true); + + // There are some pipelines created OOTB with ES + // To not be dependent on these, we only confirm the pipeline we created as part of the test exists + const testPipeline = body.find(({ name }: { name: string }) => name === pipelineName); + + expect(testPipeline).to.eql({ + ...pipeline, + isManaged: false, + name: pipelineName, + }); + }); + }); + describe('one pipeline', () => { + it('should return a single pipeline', async () => { + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineName}`; + + const { body } = await supertest + .get(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(body).to.eql({ + ...pipeline, + isManaged: false, + name: pipelineName, + }); + }); + }); + }); + + describe('Delete', () => { + const pipelineIds: string[] = []; + + before(async () => { + const pipelineA = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const pipelineB = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const pipelineC = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const pipelineD = ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + + // Create several pipelines that can be used to test deletion + await Promise.all( + [pipelineA, pipelineB, pipelineC, pipelineD].map((pipeline) => { + const { name, ...pipelineRequestBody } = pipeline; + pipelineIds.push(pipeline.name); + return ingestPipelines.api.createPipeline({ id: name, ...pipelineRequestBody }); + }) + ).catch((err) => { + log.debug(`[Setup error] Error creating pipelines: ${err.message}`); + throw err; + }); + }); + + it('should delete a pipeline', async () => { + const pipelineA = pipelineIds[0]; + + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineA}`; + + const { body } = await supertest + .delete(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(body).to.eql({ + itemsDeleted: [pipelineA], + errors: [], + }); + }); + + it('should delete multiple pipelines', async () => { + const pipelineB = pipelineIds[1]; + const pipelineC = pipelineIds[2]; + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineB},${pipelineC}`; + + const { + body: { itemsDeleted, errors }, + } = await supertest + .delete(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(errors).to.eql([]); + + // The itemsDeleted array order isn't guaranteed, so we assert against each pipeline name instead + [pipelineB, pipelineC].forEach((pipelineName) => { + expect(itemsDeleted.includes(pipelineName)).to.be(true); + }); + }); + + it('should return an error for any pipelines not sucessfully deleted', async () => { + const PIPELINE_DOES_NOT_EXIST = 'pipeline_does_not_exist'; + const pipelineD = pipelineIds[3]; + + const uri = `${ingestPipelines.fixtures.apiBasePath}/${pipelineD},${PIPELINE_DOES_NOT_EXIST}`; + + const { body } = await supertest + .delete(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(body).to.eql({ + itemsDeleted: [pipelineD], + errors: [ + { + name: PIPELINE_DOES_NOT_EXIST, + error: { + root_cause: [ + { + type: 'resource_not_found_exception', + reason: 'pipeline [pipeline_does_not_exist] is missing', + }, + ], + type: 'resource_not_found_exception', + reason: 'pipeline [pipeline_does_not_exist] is missing', + }, + status: 404, + }, + ], + }); + }); + }); + + describe('Simulate', () => { + it('should successfully simulate a pipeline', async () => { + const { name, ...pipeline } = ingestPipelines.fixtures.createPipelineBody(); + const documents = ingestPipelines.fixtures.createDocuments(); + const { body } = await supertest + .post(`${ingestPipelines.fixtures.apiBasePath}/simulate`) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send({ + pipeline, + documents, + }) + .expect(200); + + // The simulate ES response is quite long and includes timestamps + // so for now, we just confirm the docs array is returned with the correct length + expect(body.docs?.length).to.eql(2); + }); + + it('should successfully simulate a pipeline with only required pipeline fields', async () => { + const { name, ...pipeline } = + ingestPipelines.fixtures.createPipelineBodyWithRequiredFields(); + const documents = ingestPipelines.fixtures.createDocuments(); + const { body } = await supertest + .post(`${ingestPipelines.fixtures.apiBasePath}/simulate`) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send({ + pipeline, + documents, + }) + .expect(200); + + // The simulate ES response is quite long and includes timestamps + // so for now, we just confirm the docs array is returned with the correct length + expect(body.docs?.length).to.eql(2); + }); + }); + + describe('Fetch documents', () => { + const INDEX = 'test_index'; + const DOCUMENT_ID = '1'; + const DOCUMENT = { + name: 'John Doe', + }; + + before(async () => { + // Create an index with a document that can be used to test GET request + try { + await ingestPipelines.api.createIndex({ id: DOCUMENT_ID, index: INDEX, body: DOCUMENT }); + } catch (err) { + log.debug('[Setup error] Error creating index'); + throw err; + } + }); + + after(async () => { + // Clean up index created + try { + await ingestPipelines.api.deleteIndex(INDEX); + } catch (err) { + log.debug('[Cleanup error] Error deleting index'); + throw err; + } + }); + + it('should return a document', async () => { + const uri = `${ingestPipelines.fixtures.apiBasePath}/documents/${INDEX}/${DOCUMENT_ID}`; + + const { body } = await supertest + .get(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(body).to.eql({ + _index: INDEX, + _id: DOCUMENT_ID, + _source: DOCUMENT, + }); + }); + + it('should return an error if the document does not exist', async () => { + const uri = `${ingestPipelines.fixtures.apiBasePath}/documents/${INDEX}/2`; // Document 2 does not exist + + const { body } = await supertest + .get(uri) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(404); + + expect(body).to.eql({ + error: 'Not Found', + message: '{"_index":"test_index","_id":"2","found":false}', + statusCode: 404, + attributes: {}, + }); + }); + }); + + describe('Map CSV to pipeline', () => { + it('should map to a pipeline', async () => { + const validCsv = + 'source_field,copy_action,format_action,timestamp_format,destination_field,Notes\nsrcip,,,,source.address,Copying srcip to source.address'; + const { body } = await supertest + .post(`${ingestPipelines.fixtures.apiBasePath}/parse_csv`) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .send({ + copyAction: 'copy', + file: validCsv, + }) + .expect(200); + + expect(body.processors).to.eql([ + { + set: { + field: 'source.address', + value: '{{srcip}}', + if: 'ctx.srcip != null', + }, + }, + ]); + }); + }); + }); +} From c2a552ef66ac8e0c2a0372091ba85b3bf1fa7a3a Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Tue, 22 Aug 2023 16:33:39 +0200 Subject: [PATCH 14/26] [Fleet][Kafka] When compression is enabled, "None" shouldn't be an option for codec (#164416) Closes https://github.com/elastic/ingest-dev/issues/2327 `None` option removed from UI, tests aligned. https://github.com/elastic/kibana/assets/29123534/b8b7ac7a-e3e9-4800-94b9-f80e1f42f044 --- .../fleet/cypress/e2e/fleet_settings_outputs.cy.ts | 2 -- .../output_form_kafka_compression.tsx | 8 ++++---- .../edit_output_flyout/use_output_form.tsx | 12 +++++++++--- .../services/agent_policies/full_agent_policy.ts | 4 ++-- x-pack/plugins/fleet/server/services/output.ts | 5 +++++ 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts b/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts index 2d9f33d8e1efa..c5cf05bc1edd3 100644 --- a/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts +++ b/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts @@ -184,8 +184,6 @@ describe('Outputs', () => { // Compression cy.getBySel(SETTINGS_OUTPUTS_KAFKA.COMPRESSION_CODEC_INPUT).should('not.exist'); cy.getBySel(SETTINGS_OUTPUTS_KAFKA.COMPRESSION_SWITCH).click(); - cy.getBySel(SETTINGS_OUTPUTS_KAFKA.COMPRESSION_LEVEL_INPUT).should('not.exist'); - cy.getBySel(SETTINGS_OUTPUTS_KAFKA.COMPRESSION_CODEC_INPUT).select('gzip'); cy.getBySel(SETTINGS_OUTPUTS_KAFKA.COMPRESSION_LEVEL_INPUT).should('exist'); cy.getBySel(SETTINGS_OUTPUTS_KAFKA.COMPRESSION_LEVEL_INPUT).select('1'); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_compression.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_compression.tsx index 704fdfa893603..b11ef116920b8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_compression.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_compression.tsx @@ -21,12 +21,12 @@ export const OutputFormKafkaCompression: React.FunctionComponent<{ const kafkaCompressionTypeOptions = useMemo( () => - (Object.keys(kafkaCompressionType) as Array).map( - (key) => ({ + (Object.keys(kafkaCompressionType) as Array) + .filter((c) => c !== 'None') + .map((key) => ({ text: kafkaCompressionType[key], label: kafkaCompressionType[key], - }) - ), + })), [] ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx index bd1891f3ebc12..6c22ec05b6ece 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx @@ -391,7 +391,9 @@ export function useOutputForm(onSucess: () => void, output?: Output) { isDisabled('compression_level') ); const kafkaCompressionCodecInput = useInput( - kafkaOutput?.compression ?? kafkaCompressionType.None, + kafkaOutput?.compression && kafkaOutput.compression !== kafkaCompressionType.None + ? kafkaOutput.compression + : kafkaCompressionType.Gzip, undefined, isDisabled('compression') ); @@ -642,8 +644,11 @@ export function useOutputForm(onSucess: () => void, output?: Output) { client_id: kafkaClientIdInput.value || undefined, version: kafkaVersionInput.value, ...(kafkaKeyInput.value ? { key: kafkaKeyInput.value } : {}), - compression: kafkaCompressionCodecInput.value, - ...(kafkaCompressionCodecInput.value === kafkaCompressionType.Gzip + compression: kafkaCompressionInput.value + ? kafkaCompressionCodecInput.value + : kafkaCompressionType.None, + ...(kafkaCompressionInput.value && + kafkaCompressionCodecInput.value === kafkaCompressionType.Gzip ? { compression_level: parseIntegerIfStringDefined( kafkaCompressionLevelInput.value @@ -785,6 +790,7 @@ export function useOutputForm(onSucess: () => void, output?: Output) { loadBalanceEnabledInput.value, typeInput.value, kafkaSslCertificateAuthoritiesInput.value, + kafkaCompressionInput.value, nameInput.value, kafkaHostsInput.value, defaultOutputInput.value, diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts index 0a05d178ac745..9107a334dad83 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts @@ -20,7 +20,7 @@ import type { } from '../../types'; import type { FullAgentPolicyOutputPermissions, PackageInfo } from '../../../common/types'; import { agentPolicyService } from '../agent_policy'; -import { dataTypes, outputType } from '../../../common/constants'; +import { dataTypes, kafkaCompressionType, outputType } from '../../../common/constants'; import { DEFAULT_OUTPUT } from '../../constants'; import { getPackageInfo } from '../epm/packages'; @@ -344,7 +344,7 @@ export function transformOutputToFullPolicyOutput( version, key, compression, - compression_level, + ...(compression === kafkaCompressionType.Gzip ? { compression_level } : {}), ...(username ? { username } : {}), ...(password ? { password } : {}), ...(sasl ? { sasl } : {}), diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index f190fc7d2315f..15b256fc84207 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -764,6 +764,11 @@ class OutputService { ) { updateData.compression_level = 4; } + if (data.compression && data.compression !== kafkaCompressionType.Gzip) { + // Clear compression level if compression is not gzip + updateData.compression_level = null; + } + if (!data.client_id) { updateData.client_id = 'Elastic'; } From 209e7750d8df31e634efa1b0bf38000728aafc6c Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 22 Aug 2023 10:56:15 -0400 Subject: [PATCH 15/26] [Security Solution] [Timeline] Open a timeline to any tab from a url without a saved object id (#163033) ## Summary This change enables the timeline to be opened to any specific tab on page load without needing a saved object id, normally generated when a user creates some sort of state that is either auto saved or explicitly saved by a user action. The video below is just a hard coded window.location = security url redirect in the discover plugin, the discover part will come in a follow up pr. ![open_timeline_discover_tab](https://github.com/elastic/kibana/assets/56408403/ef834d72-c42e-4584-bf98-1d8ef29dd530) ### Checklist - [ ] [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 - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] 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)) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/app/home/index.test.tsx | 8 +- .../timeline/use_init_timeline_url_param.ts | 2 +- .../timeline/use_sync_timeline_url_param.ts | 17 ++- .../common/hooks/use_resolve_conflict.tsx | 15 +-- .../components/open_timeline/helpers.ts | 116 ++++++++++-------- .../timelines/components/timeline/index.tsx | 4 +- .../timeline/query_tab_content/index.tsx | 2 +- .../timelines/store/timeline/helpers.ts | 2 +- .../public/timelines/store/timeline/model.ts | 2 +- .../investigations/timelines/url_state.cy.ts | 69 +++++++++++ .../cypress/e2e/urls/state.cy.ts | 56 +++++---- 11 files changed, 194 insertions(+), 99 deletions(-) create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/url_state.cy.ts diff --git a/x-pack/plugins/security_solution/public/app/home/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/index.test.tsx index 83ac458032c2c..d9e940f0c2f07 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.test.tsx @@ -625,7 +625,7 @@ describe('HomePage', () => { ); }); - it('it removes empty timeline state from URL', async () => { + it('it keeps timeline visibility and selected tab state in URL', async () => { const { storage } = createSecuritySolutionStorageMock(); const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -649,7 +649,11 @@ describe('HomePage', () => { rerender(); - expect(mockUpdateUrlParam).toHaveBeenCalledWith(null); + expect(mockUpdateUrlParam).toHaveBeenCalledWith({ + activeTab: 'query', + graphEventId: '', + isOpen: false, + }); }); it('it updates URL when timeline store changes', async () => { diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts index 8368023627908..cb4bd92d205d7 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts @@ -23,7 +23,7 @@ export const useInitTimelineFromUrlParam = () => { const onInitialize = useCallback( (initialState: TimelineUrl | null) => { - if (initialState != null && initialState.id !== '') { + if (initialState != null) { queryTimelineById({ activeTimelineTab: initialState.activeTab, duplicate: false, diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts index 8b3d7fb680c96..fc2e9b620c314 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts @@ -22,15 +22,12 @@ export const useSyncTimelineUrlParam = () => { ); useEffect(() => { - updateUrlParam( - savedObjectId != null - ? { - id: savedObjectId, - isOpen: show, - activeTab, - graphEventId: graphEventId ?? '', - } - : null - ); + const params = { + ...(savedObjectId ? { id: savedObjectId } : {}), + isOpen: show, + activeTab, + graphEventId: graphEventId ?? '', + }; + updateUrlParam(params); }, [activeTab, graphEventId, savedObjectId, show, updateUrlParam]); }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx index d0082f858ca12..40eace1bd6e6e 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx @@ -36,13 +36,6 @@ export const useResolveConflict = () => { const getLegacyUrlConflictCallout = useCallback(() => { // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario - if ( - !spaces || - resolveTimelineConfig?.outcome !== 'conflict' || - resolveTimelineConfig?.alias_target_id == null - ) { - return null; - } const searchQuery = new URLSearchParams(search); const timelineRison = searchQuery.get(URL_PARAM_KEY.timeline) ?? undefined; @@ -59,6 +52,14 @@ export const useResolveConflict = () => { // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a // callout with a warning for the user, and provide a way for them to navigate to the other object. const currentObjectId = timelineSearch?.id; + if ( + !spaces || + resolveTimelineConfig?.outcome !== 'conflict' || + resolveTimelineConfig?.alias_target_id == null || + currentObjectId == null + ) { + return null; + } const newSavedObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'conflict' const newTimelineSearch: TimelineUrl = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 6cd332c23a311..ca43ba15dbf31 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -314,7 +314,7 @@ export interface QueryTimelineById { activeTimelineTab?: TimelineTabs; duplicate?: boolean; graphEventId?: string; - timelineId: string; + timelineId?: string; timelineType?: TimelineType; onError?: TimelineErrorCallback; onOpenTimeline?: (timeline: TimelineModel) => void; @@ -342,55 +342,73 @@ export const queryTimelineById = ({ updateTimeline, }: QueryTimelineById) => { updateIsLoading({ id: TimelineId.active, isLoading: true }); - Promise.resolve(resolveTimeline(timelineId)) - .then((result) => { - const data: SingleTimelineResolveResponse['data'] | null = getOr(null, 'data', result); - if (!data) return; - - const timelineToOpen = omitTypenameInTimeline(data.timeline); - - const { timeline, notes } = formatTimelineResultToModel( - timelineToOpen, - duplicate, - timelineType - ); - - if (onOpenTimeline != null) { - onOpenTimeline(timeline); - } else if (updateTimeline) { - const { from, to } = normalizeTimeRange({ - from: getOr(null, 'dateRange.start', timeline), - to: getOr(null, 'dateRange.end', timeline), - }); - updateTimeline({ + if (timelineId == null) { + updateTimeline({ + id: TimelineId.active, + duplicate: false, + notes: [], + from: DEFAULT_FROM_MOMENT.toISOString(), + to: DEFAULT_TO_MOMENT.toISOString(), + timeline: { + ...timelineDefaults, + id: TimelineId.active, + activeTab: activeTimelineTab, + show: openTimeline, + initialized: true, + }, + })(); + updateIsLoading({ id: TimelineId.active, isLoading: false }); + } else { + Promise.resolve(resolveTimeline(timelineId)) + .then((result) => { + const data: SingleTimelineResolveResponse['data'] | null = getOr(null, 'data', result); + if (!data) return; + + const timelineToOpen = omitTypenameInTimeline(data.timeline); + + const { timeline, notes } = formatTimelineResultToModel( + timelineToOpen, duplicate, - from, - id: TimelineId.active, - notes, - resolveTimelineConfig: { - outcome: data.outcome, - alias_target_id: data.alias_target_id, - alias_purpose: data.alias_purpose, - }, - timeline: { - ...timeline, - activeTab: activeTimelineTab, - graphEventId, - show: openTimeline, - dateRange: { start: from, end: to }, - }, - to, - })(); - } - }) - .catch((error) => { - if (onError != null) { - onError(error, timelineId); - } - }) - .finally(() => { - updateIsLoading({ id: TimelineId.active, isLoading: false }); - }); + timelineType + ); + + if (onOpenTimeline != null) { + onOpenTimeline(timeline); + } else if (updateTimeline) { + const { from, to } = normalizeTimeRange({ + from: getOr(null, 'dateRange.start', timeline), + to: getOr(null, 'dateRange.end', timeline), + }); + updateTimeline({ + duplicate, + from, + id: TimelineId.active, + notes, + resolveTimelineConfig: { + outcome: data.outcome, + alias_target_id: data.alias_target_id, + alias_purpose: data.alias_purpose, + }, + timeline: { + ...timeline, + activeTab: activeTimelineTab, + graphEventId, + show: openTimeline, + dateRange: { start: from, end: to }, + }, + to, + })(); + } + }) + .catch((error) => { + if (onError != null) { + onError(error, timelineId); + } + }) + .finally(() => { + updateIsLoading({ id: TimelineId.active, isLoading: false }); + }); + } }; export const dispatchUpdateTimeline = diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index f29dabf065f21..1bbeb32ae0e87 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -77,6 +77,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineType, description, sessionViewConfig, + initialized, } = useDeepEqualSelector((state) => pick( [ @@ -87,6 +88,7 @@ const StatefulTimelineComponent: React.FC = ({ 'timelineType', 'description', 'sessionViewConfig', + 'initialized', ], getTimeline(state, timelineId) ?? timelineDefaults ) @@ -95,7 +97,7 @@ const StatefulTimelineComponent: React.FC = ({ const { timelineFullScreen } = useTimelineFullScreen(); useEffect(() => { - if (!savedObjectId) { + if (!savedObjectId && !initialized) { dispatch( timelineActions.createTimeline({ id: timelineId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 630fb88fe0717..e6707f239f399 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -278,7 +278,7 @@ export const QueryTabContentComponent: React.FC = ({ id: timelineId, }) ); - }, [activeFilterManager, currentTimeline, dispatch, filterManager, timelineId, uiSettings]); + }, [dispatch, filterManager, timelineId]); const [isQueryLoading, { events, inspect, totalCount, pageInfo, loadPage, updatedAt, refetch }] = useTimelineEvents({ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 9cebc52da54fa..ca890b9ac3ec4 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -140,7 +140,7 @@ export const addTimelineToStore = ({ ...timeline, filterManager: timelineById[id].filterManager, isLoading: timelineById[id].isLoading, - initialized: timelineById[id].initialized, + initialized: timeline.initialized ?? timelineById[id].initialized, resolveTimelineConfig, dateRange: timeline.status === TimelineStatus.immutable && diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 0f2939309344f..34cf8b6dcaadf 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -191,7 +191,7 @@ export type SubsetTimelineModel = Readonly< export interface TimelineUrl { activeTab?: TimelineTabs; - id: string; + id?: string; isOpen: boolean; graphEventId?: string; } diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/url_state.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/url_state.cy.ts new file mode 100644 index 0000000000000..8eed754a18600 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/url_state.cy.ts @@ -0,0 +1,69 @@ +/* + * 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 { encode } from '@kbn/rison'; +import { tag } from '../../../tags'; + +import { getTimeline } from '../../../objects/timeline'; + +import { TIMELINE_HEADER } from '../../../screens/timeline'; + +import { createTimeline } from '../../../tasks/api_calls/timelines'; + +import { cleanKibana } from '../../../tasks/common'; +import { ALERTS_URL } from '../../../urls/navigation'; +import { createRule } from '../../../tasks/api_calls/rules'; +import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; +import { getNewRule } from '../../../objects/rule'; + +import { login, visitWithoutDateRange, visit } from '../../../tasks/login'; + +import { TIMELINES_URL } from '../../../urls/navigation'; + +describe('Open timeline', { tags: [tag.BROKEN_IN_SERVERLESS, tag.ESS] }, () => { + let timelineSavedObjectId: string | null = null; + before(function () { + cleanKibana(); + login(); + visitWithoutDateRange(TIMELINES_URL); + + createTimeline(getTimeline()).then((response) => { + timelineSavedObjectId = response.body.data.persistTimeline.timeline.savedObjectId; + return response.body.data.persistTimeline.timeline.savedObjectId; + }); + + createRule(getNewRule()); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + beforeEach(() => { + login(); + }); + + describe('open timeline from url exclusively', () => { + it('should open a timeline via url alone without a saved object id', () => { + const urlWithoutSavedObjectId = `${ALERTS_URL}?timeline=(activeTab:query,isOpen:!t)`; + visitWithoutDateRange(urlWithoutSavedObjectId); + cy.get(TIMELINE_HEADER).should('be.visible'); + }); + + it('should also support opening with a saved object id', () => { + cy.location('search').then((search) => { + const params = new URLSearchParams(search); + const timelineParams = encode({ + activeTab: 'query', + isOpen: true, + id: timelineSavedObjectId, + }); + params.set('timeline', timelineParams); + const urlWithSavedObjectId = `${ALERTS_URL}?${params.toString()}`; + visitWithoutDateRange(urlWithSavedObjectId); + cy.get(TIMELINE_HEADER).should('be.visible'); + }); + }); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/urls/state.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/urls/state.cy.ts index b696f4943ad22..8b6a6c2587be4 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/urls/state.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/urls/state.cy.ts @@ -225,11 +225,12 @@ describe('url state', { tags: [tag.ESS, tag.BROKEN_IN_SERVERLESS] }, () => { navigateFromHeaderTo(HOSTS); openNavigationPanel(EXPLORE_PANEL_BTN); - cy.get(NETWORK).should( - 'have.attr', - 'href', - `/app/security/network?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'source.ip:%20%2210.142.0.9%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))` - ); + cy.get(NETWORK) + .should('have.attr', 'href') + .and( + 'contain', + `/app/security/network?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'source.ip:%20%2210.142.0.9%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))` + ); }); it('sets KQL in host page and detail page and check if href match on breadcrumb, tabs and subTabs', () => { @@ -239,40 +240,43 @@ describe('url state', { tags: [tag.ESS, tag.BROKEN_IN_SERVERLESS] }, () => { waitForAllHostsToBeLoaded(); openNavigationPanel(EXPLORE_PANEL_BTN); - cy.get(HOSTS).should( - 'have.attr', - 'href', - `/app/security/hosts?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))` - ); - cy.get(NETWORK).should( - 'have.attr', - 'href', - `/app/security/network?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))` - ); + cy.get(HOSTS) + .should('have.attr', 'href') + .and( + 'contain', + `/app/security/hosts?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))` + ); + cy.get(NETWORK) + .should('have.attr', 'href') + .and( + 'contain', + `/app/security/network?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))` + ); cy.get(HOSTS_NAMES).first().should('have.text', 'siem-kibana'); openFirstHostDetails(); clearSearchBar(); kqlSearch('agent.type: "auditbeat" {enter}'); - cy.get(ANOMALIES_TAB).should( - 'have.attr', - 'href', - "/app/security/hosts/name/siem-kibana/anomalies?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))&query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')" - ); + cy.get(ANOMALIES_TAB) + .should('have.attr', 'href') + .and( + 'contain', + "/app/security/hosts/name/siem-kibana/anomalies?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))" + ); cy.get(BREADCRUMBS) .eq(2) - .should( - 'have.attr', - 'href', + .should('have.attr', 'href') + .and( + 'contain', `/app/security/hosts?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))` ); cy.get(BREADCRUMBS) .eq(3) - .should( - 'have.attr', - 'href', + .should('have.attr', 'href') + .and( + 'contain', `/app/security/hosts/name/siem-kibana?sourcerer=(default:(id:security-solution-default,selectedPatterns:!('auditbeat-*')))&query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))` ); }); From c90e6d11c2ecb2010ed54b0f8cb37ac423778c4b Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Tue, 22 Aug 2023 17:18:05 +0200 Subject: [PATCH 16/26] [Synthetics] Fix flay test`EnableDefaultAlerting` alert. (#164291) Resolves #158408. --- .../apis/synthetics/enable_default_alerting.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 32ea443da4a09..1d3ed0c6084dc 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 @@ -14,8 +14,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { getFixtureJson } from './helper/get_fixture_json'; export default function ({ getService }: FtrProviderContext) { - // FLAKY: https://github.com/elastic/kibana/issues/158408 - describe.skip('EnableDefaultAlerting', function () { + describe('EnableDefaultAlerting', function () { this.tags('skipCloud'); const supertest = getService('supertest'); @@ -56,6 +55,7 @@ export default function ({ getService }: FtrProviderContext) { 'nextRun', 'lastRun', 'snoozeSchedule', + 'viewInAppRelativeUrl', ]; const statusRule = apiResponse.body.statusRule; @@ -129,6 +129,7 @@ const defaultAlertRules = { lastDuration: 64, }, ruleTypeId: 'xpack.synthetics.alerts.monitorStatus', + viewInAppRelativeUrl: '/app/observability/alerts/rules/574e82f0-1672-11ee-8e7d-c985c0ef6c2e', }, tlsRule: { id: '574eaa00-1672-11ee-8e7d-c985c0ef6c2e', @@ -160,5 +161,6 @@ const defaultAlertRules = { lastDuration: 193, }, ruleTypeId: 'xpack.synthetics.alerts.tls', + viewInAppRelativeUrl: '/app/observability/alerts/rules/574e82f0-1672-11ee-8e7d-c985c0ef6c2e', }, }; From 7c896218dd4dc37bc8ef27fdcd9ee7056b65811c Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Tue, 22 Aug 2023 08:33:37 -0700 Subject: [PATCH 17/26] Remove several `@ts-ignore`/`@ts-expect-error`s around EUI imports (#163984) ## Summary There's a few EUI imports out there that folks are reaching into `@elastic/eui/lib/` for (which doesn't contain any types - something I'm looking into separately) that could instead be imported at the top `@elastic/eui` level, which is properly typed. ### Checklist N/A - types only --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../components/code_editor/code_editor.test.tsx | 3 +-- .../visualizations/point_series/_point_series.js | 2 +- src/plugins/vis_types/xy/public/vis_types/area.ts | 3 +-- .../vis_types/xy/public/vis_types/histogram.ts | 3 +-- .../xy/public/vis_types/horizontal_bar.ts | 3 +-- src/plugins/vis_types/xy/public/vis_types/line.ts | 3 +-- .../src/document_count_chart/brush_badge.tsx | 3 +-- .../components/all_cases/use_cases_columns.tsx | 2 +- .../field_data_expanded_row/date_content.tsx | 3 +-- .../plugins/graph/public/helpers/style_choices.ts | 3 +-- .../log_entry_rate/sections/anomalies/table.tsx | 2 +- .../maps/public/classes/styles/color_palettes.ts | 13 ++----------- .../vector/components/color/color_stops_utils.js | 3 +-- .../extract_color_from_style_property.test.ts | 3 +-- .../annotations_table/annotations_table.js | 3 +-- .../job_selector_table/job_selector_table.js | 4 +++- .../configuration_step/analysis_fields_table.tsx | 14 ++++++++++---- .../job_details/forecasts_table/forecasts_table.js | 2 +- .../public/components/chart/get_color.js | 2 +- .../server/authentication/unauthenticated_page.tsx | 3 +-- .../server/authorization/reset_session_page.tsx | 3 +-- .../restore_list/restore_table/restore_table.tsx | 11 ++++++++--- .../simple/ping_list/expanded_row.tsx | 3 +-- .../rule_details/components/rule_alert_list.tsx | 3 +-- .../components/monitor/ping_list/expanded_row.tsx | 3 +-- 25 files changed, 44 insertions(+), 56 deletions(-) diff --git a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx index b5e23bfc3f95b..9092d94936ffe 100644 --- a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx +++ b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx @@ -9,8 +9,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import EuiCodeEditor from './code_editor'; -// @ts-ignore -import { keys } from '@elastic/eui/lib/services'; +import { keys } from '@elastic/eui'; import { findTestSubject, requiredProps, takeMountedSnapshot } from '@elastic/eui/lib/test'; describe('EuiCodeEditor', () => { diff --git a/src/plugins/vis_types/vislib/public/vislib/visualizations/point_series/_point_series.js b/src/plugins/vis_types/vislib/public/vislib/visualizations/point_series/_point_series.js index dddfde75dbb82..4756c9bf32704 100644 --- a/src/plugins/vis_types/vislib/public/vislib/visualizations/point_series/_point_series.js +++ b/src/plugins/vis_types/vislib/public/vislib/visualizations/point_series/_point_series.js @@ -7,7 +7,7 @@ */ import _ from 'lodash'; -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; const thresholdLineDefaults = { show: false, diff --git a/src/plugins/vis_types/xy/public/vis_types/area.ts b/src/plugins/vis_types/xy/public/vis_types/area.ts index 34afc54720e9b..610d6280343c5 100644 --- a/src/plugins/vis_types/xy/public/vis_types/area.ts +++ b/src/plugins/vis_types/xy/public/vis_types/area.ts @@ -7,8 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { Fit, Position } from '@elastic/charts'; import { AggGroupNames } from '@kbn/data-plugin/public'; diff --git a/src/plugins/vis_types/xy/public/vis_types/histogram.ts b/src/plugins/vis_types/xy/public/vis_types/histogram.ts index 0a898f62ef227..df910de2f3808 100644 --- a/src/plugins/vis_types/xy/public/vis_types/histogram.ts +++ b/src/plugins/vis_types/xy/public/vis_types/histogram.ts @@ -7,8 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { AggGroupNames } from '@kbn/data-plugin/public'; diff --git a/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts b/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts index dfbf51aa6d684..ba578ab916e81 100644 --- a/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts +++ b/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts @@ -7,8 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { AggGroupNames } from '@kbn/data-plugin/public'; diff --git a/src/plugins/vis_types/xy/public/vis_types/line.ts b/src/plugins/vis_types/xy/public/vis_types/line.ts index 49113e2a21b27..272d38e982045 100644 --- a/src/plugins/vis_types/xy/public/vis_types/line.ts +++ b/src/plugins/vis_types/xy/public/vis_types/line.ts @@ -7,8 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { Position, Fit } from '@elastic/charts'; import { AggGroupNames } from '@kbn/data-plugin/public'; diff --git a/x-pack/packages/ml/aiops_components/src/document_count_chart/brush_badge.tsx b/x-pack/packages/ml/aiops_components/src/document_count_chart/brush_badge.tsx index 53563a721026b..15bccea10c8a1 100644 --- a/x-pack/packages/ml/aiops_components/src/document_count_chart/brush_badge.tsx +++ b/x-pack/packages/ml/aiops_components/src/document_count_chart/brush_badge.tsx @@ -8,8 +8,7 @@ import React, { FC } from 'react'; import { EuiBadge, EuiText, EuiToolTip } from '@elastic/eui'; -// @ts-ignore -import { formatDate } from '@elastic/eui/lib/services/format'; +import { formatDate } from '@elastic/eui'; const DATE_FORMAT = 'YYYY-MM-DD'; const TIME_FORMAT = 'HH:mm:ss'; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index ffcc58b531693..0863e51daef5c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -19,8 +19,8 @@ import { EuiIcon, EuiHealth, EuiToolTip, + RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { Status } from '@kbn/cases-components/src/status/status'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx index 98370dabb1f0a..6345412e1651e 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx @@ -7,8 +7,7 @@ import React, { FC, ReactNode } from 'react'; import { EuiBasicTable, HorizontalAlignment, LEFT_ALIGNMENT, RIGHT_ALIGNMENT } from '@elastic/eui'; -// @ts-ignore -import { formatDate } from '@elastic/eui/lib/services/format'; +import { formatDate } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; diff --git a/x-pack/plugins/graph/public/helpers/style_choices.ts b/x-pack/plugins/graph/public/helpers/style_choices.ts index 0030c91c5aadf..b64abb27fbe25 100644 --- a/x-pack/plugins/graph/public/helpers/style_choices.ts +++ b/x-pack/plugins/graph/public/helpers/style_choices.ts @@ -6,8 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; export interface GenericIcon { label: string; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index c208c72558362..e342e538b1433 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -13,8 +13,8 @@ import { EuiFlexItem, EuiButtonIcon, EuiSpacer, + RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts index be07f78ff69ae..a4fd893ea8c90 100644 --- a/x-pack/plugins/maps/public/classes/styles/color_palettes.ts +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts @@ -7,26 +7,17 @@ import tinycolor from 'tinycolor2'; import { - // @ts-ignore colorPalette as colorPaletteGenerator, - // @ts-ignore euiPaletteForStatus, - // @ts-ignore euiPaletteForTemperature, - // @ts-ignore euiPaletteCool, - // @ts-ignore euiPaletteWarm, - // @ts-ignore euiPaletteNegative, - // @ts-ignore euiPalettePositive, - // @ts-ignore euiPaletteGray, - // @ts-ignore euiPaletteColorBlind, -} from '@elastic/eui/lib/services'; -import { EuiColorPalettePickerPaletteProps } from '@elastic/eui'; + EuiColorPalettePickerPaletteProps, +} from '@elastic/eui'; import { PercentilesFieldMeta } from '../../../common/descriptor_types'; export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_stops_utils.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_stops_utils.js index 3627181215471..a92b6db671fcf 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_stops_utils.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_stops_utils.js @@ -5,9 +5,8 @@ * 2.0. */ -import { isValidHex } from '@elastic/eui'; +import { isValidHex, euiPaletteColorBlind } from '@elastic/eui'; import _ from 'lodash'; -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; const DEFAULT_CUSTOM_PALETTE = euiPaletteColorBlind({ rotations: 3 }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.test.ts b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.test.ts index 92e897842ac31..aeeeff361d5a0 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.test.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.test.ts @@ -13,8 +13,7 @@ import { ColorStaticStylePropertyDescriptor, } from '../../../../../../common/descriptor_types'; import { COLOR_MAP_TYPE, FIELD_ORIGIN, STYLE_TYPE } from '../../../../../../common/constants'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; const blue = '#0000ff'; const yellow = '#ffff00'; diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index adce983312d15..f9aa282b4b118 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -25,12 +25,11 @@ import { EuiLink, EuiLoadingSpinner, EuiToolTip, + RIGHT_ALIGNMENT, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; - import { addItemToRecentlyAccessed } from '../../../util/recently_accessed'; import { ml } from '../../../services/ml_api_service'; import { mlJobService } from '../../../services/job_service'; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index 49cd964c09db3..40ebecd135548 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -19,9 +19,11 @@ import { EuiCallOut, EuiButton, EuiText, + LEFT_ALIGNMENT, + CENTER_ALIGNMENT, + SortableProperties, } from '@elastic/eui'; -import { LEFT_ALIGNMENT, CENTER_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../../../contexts/kibana'; import { ML_PAGES } from '../../../../../common/constants/locator'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index b5bf6b012750a..1b2f83ef58fce 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -6,10 +6,16 @@ */ import React, { FC, Fragment, useEffect, useState } from 'react'; -import { EuiCallOut, EuiFormRow, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiCallOut, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiText, + LEFT_ALIGNMENT, + SortableProperties, +} from '@elastic/eui'; import { isEqual } from 'lodash'; -// @ts-ignore no declaration -import { LEFT_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { ES_FIELD_TYPES } from '@kbn/field-types'; @@ -54,7 +60,7 @@ export const AnalysisFieldsTable: FC<{ unsupportedFieldsError, setUnsupportedFieldsError, }) => { - const [sortableProperties, setSortableProperties] = useState(); + const [sortableProperties, setSortableProperties] = useState>(); const [currentPaginationData, setCurrentPaginationData] = useState<{ pageIndex: number; itemsPerPage: number; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js index 2488d55b0a548..9bdc8c4c40abb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js @@ -16,8 +16,8 @@ import { EuiInMemoryTable, EuiLink, EuiLoadingSpinner, + formatNumber, } from '@elastic/eui'; -import { formatNumber } from '@elastic/eui/lib/services/format'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/x-pack/plugins/monitoring/public/components/chart/get_color.js b/x-pack/plugins/monitoring/public/components/chart/get_color.js index 49a98f38e9c2b..7e8cac5540985 100644 --- a/x-pack/plugins/monitoring/public/components/chart/get_color.js +++ b/x-pack/plugins/monitoring/public/components/chart/get_color.js @@ -14,7 +14,7 @@ * @param {Integer} index: index of the chart series, 0-3 * @returns {String} Hex color to use for chart series at the given index */ -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui'; export function getColor(app, index) { let seriesColors; diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx index df26a7b802d4d..fce29bbe89bc3 100644 --- a/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx @@ -5,8 +5,7 @@ * 2.0. */ -// @ts-expect-error no definitions in component folder -import { EuiButton } from '@elastic/eui/lib/components/button'; +import { EuiButton } from '@elastic/eui'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx index 30fafabe0ae43..9cb85f324fab9 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -5,8 +5,7 @@ * 2.0. */ -// @ts-expect-error no definitions in component folder -import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; +import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; import React from 'react'; import type { IBasePath } from '@kbn/core/server'; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/restore_list/restore_table/restore_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/restore_list/restore_table/restore_table.tsx index b94e3f631fb16..edb097cecb796 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/restore_list/restore_table/restore_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/restore_list/restore_table/restore_table.tsx @@ -8,8 +8,13 @@ import React, { useState, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { orderBy } from 'lodash'; -import { EuiBasicTable, EuiButtonIcon, EuiHealth } from '@elastic/eui'; -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButtonIcon, + EuiHealth, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; import { SnapshotRestore } from '../../../../../../common/types'; import { UIM_RESTORE_LIST_EXPAND_INDEX } from '../../../../constants'; @@ -94,7 +99,7 @@ export const RestoreTable: React.FunctionComponent = React.memo(({ restor }, {} as { [key: string]: JSX.Element }); }, [expandedIndices, restores]); - const columns = [ + const columns: Array> = [ { field: 'index', name: i18n.translate('xpack.snapshotRestore.restoreList.table.indexColumnTitle', { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/expanded_row.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/expanded_row.tsx index e3ef6e9cd0cfb..60c80877e7f50 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/expanded_row.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_now_mode/simple/ping_list/expanded_row.tsx @@ -5,8 +5,7 @@ * 2.0. */ -// @ts-ignore formatNumber -import { formatNumber } from '@elastic/eui/lib/services/format'; +import { formatNumber } from '@elastic/eui'; import { EuiCallOut, EuiCodeBlock, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx index 87b33e7bb4933..616f17a2ee685 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx @@ -10,7 +10,6 @@ import moment, { Duration } from 'moment'; import { padStart, chunk } from 'lodash'; import { EuiBasicTable, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { AlertStatus, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; import { AlertStatusValues, MaintenanceWindow } from '@kbn/alerting-plugin/common'; import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; @@ -213,7 +212,7 @@ export const RuleAlertList = (props: RuleAlertListProps) => { }, { field: '', - align: RIGHT_ALIGNMENT, + align: 'right' as const, width: '60px', name: i18n.translate( 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.mute', diff --git a/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/ping_list/expanded_row.tsx b/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/ping_list/expanded_row.tsx index 4335d098eff33..ca4dded961616 100644 --- a/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/ping_list/expanded_row.tsx +++ b/x-pack/plugins/uptime/public/legacy_uptime/components/monitor/ping_list/expanded_row.tsx @@ -5,8 +5,7 @@ * 2.0. */ -// @ts-ignore formatNumber -import { formatNumber } from '@elastic/eui/lib/services/format'; +import { formatNumber } from '@elastic/eui'; import { EuiCallOut, EuiCodeBlock, From deb64c19cf63c054ca15ba3ec8217e031dd37173 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 22 Aug 2023 09:20:40 -0700 Subject: [PATCH 18/26] Reporting/fix visual warning test (#164383) ## Summary Closes https://github.com/elastic/kibana/issues/135309 This PR eliminates a skipped functional test by replacing the test coverage with unit tests. * `x-pack/plugins/screenshotting/server/screenshots/screenshots.test.ts`: ensures that waiting too long for the URL to open will return the expected error message * `x-pack/plugins/screenshotting/server/browsers/chromium/driver.test.ts`: ensures that when the screenshot capture method is passed an error message, that error message is injected into the screenshot --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/ftr_configs.yml | 1 - .../server/__mocks__/puppeteer.ts | 47 ++++ .../server/browsers/chromium/driver.test.ts | 107 +++++++++ .../chromium/driver_factory/index.test.ts | 36 ++- .../server/screenshots/index.ts | 201 +---------------- .../server/screenshots/screenshots.test.ts | 166 ++++++++++++++ .../server/screenshots/screenshots.ts | 208 ++++++++++++++++++ x-pack/plugins/screenshotting/tsconfig.json | 2 + .../reporting_and_timeout.config.ts | 26 --- .../fixtures/baseline/warnings_capture_b.png | Bin 95272 -> 0 bytes .../reporting_and_timeout/index.ts | 98 --------- 11 files changed, 549 insertions(+), 343 deletions(-) create mode 100644 x-pack/plugins/screenshotting/server/__mocks__/puppeteer.ts create mode 100644 x-pack/plugins/screenshotting/server/browsers/chromium/driver.test.ts create mode 100644 x-pack/plugins/screenshotting/server/screenshots/screenshots.test.ts create mode 100644 x-pack/plugins/screenshotting/server/screenshots/screenshots.ts delete mode 100644 x-pack/test/reporting_functional/reporting_and_timeout.config.ts delete mode 100644 x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png delete mode 100644 x-pack/test/reporting_functional/reporting_and_timeout/index.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index b26b54b767658..8f1546099d539 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -353,7 +353,6 @@ enabled: - x-pack/test/reporting_functional/reporting_and_deprecated_security.config.ts - x-pack/test/reporting_functional/reporting_and_security.config.ts - x-pack/test/reporting_functional/reporting_without_security.config.ts - - x-pack/test/reporting_functional/reporting_and_timeout.config.ts - x-pack/test/rule_registry/security_and_spaces/config_basic.ts - x-pack/test/rule_registry/security_and_spaces/config_trial.ts - x-pack/test/rule_registry/spaces_only/config_basic.ts diff --git a/x-pack/plugins/screenshotting/server/__mocks__/puppeteer.ts b/x-pack/plugins/screenshotting/server/__mocks__/puppeteer.ts new file mode 100644 index 0000000000000..f295192723c2b --- /dev/null +++ b/x-pack/plugins/screenshotting/server/__mocks__/puppeteer.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ + +const stubDevTools = { + send: jest.fn(), +}; +const stubTarget = { + createCDPSession: jest.fn(() => { + return stubDevTools; + }), +}; +const stubPage = { + target: jest.fn(() => { + return stubTarget; + }), + emulateTimezone: jest.fn(), + setDefaultTimeout: jest.fn(), + isClosed: jest.fn(), + setViewport: jest.fn(), + evaluate: jest.fn(), + screenshot: jest.fn().mockResolvedValue(`you won't believe this one weird screenshot`), + evaluateOnNewDocument: jest.fn(), + setRequestInterception: jest.fn(), + _client: jest.fn(() => ({ on: jest.fn() })), + on: jest.fn(), + goto: jest.fn(), + waitForSelector: jest.fn().mockResolvedValue(true), + waitForFunction: jest.fn(), +}; +const stubBrowser = { + newPage: jest.fn(() => { + return stubPage; + }), +}; + +const puppeteer = { + launch: jest.fn(() => { + return stubBrowser; + }), +}; + +// eslint-disable-next-line import/no-default-export +export default puppeteer; diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.test.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.test.ts new file mode 100644 index 0000000000000..fb07eac64ed17 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server'; +import * as puppeteer from 'puppeteer'; +import { Size } from '../../../common/layout'; +import { ConfigType } from '../../config'; +import { PreserveLayout } from '../../layouts/preserve_layout'; +import { HeadlessChromiumDriver } from './driver'; + +describe('chromium driver', () => { + let mockConfig: ConfigType; + let mockLogger: Logger; + let mockScreenshotModeSetup: ScreenshotModePluginSetup; + let mockPage: puppeteer.Page; + + const mockBasePath = '/kibanaTest1'; + + beforeEach(() => { + mockLogger = { debug: jest.fn(), error: jest.fn(), info: jest.fn() } as unknown as Logger; + mockLogger.get = () => mockLogger; + + mockConfig = { + networkPolicy: { + enabled: false, + rules: [], + }, + browser: { + autoDownload: false, + chromium: { proxy: { enabled: false } }, + }, + capture: { + timeouts: { + openUrl: 60000, + waitForElements: 60000, + renderComplete: 60000, + }, + zoom: 2, + }, + poolSize: 1, + }; + + mockPage = { + screenshot: jest.fn().mockResolvedValue(`you won't believe this one weird screenshot`), + evaluate: jest.fn(), + } as unknown as puppeteer.Page; + + mockScreenshotModeSetup = { + setScreenshotContext: jest.fn(), + setScreenshotModeEnabled: jest.fn(), + isScreenshotMode: jest.fn(), + }; + }); + + it('return screenshot with preserve layout option', async () => { + const driver = new HeadlessChromiumDriver( + mockScreenshotModeSetup, + mockConfig, + mockBasePath, + mockPage + ); + + const result = await driver.screenshot({ + elementPosition: { + boundingClientRect: { top: 200, left: 10, height: 10, width: 100 }, + scroll: { x: 100, y: 300 }, + }, + layout: new PreserveLayout({ width: 16, height: 16 }), + }); + + expect(result).toEqual(Buffer.from(`you won't believe this one weird screenshot`, 'base64')); + }); + + it('add error to screenshot contents', async () => { + const driver = new HeadlessChromiumDriver( + mockScreenshotModeSetup, + mockConfig, + mockBasePath, + mockPage + ); + + // @ts-expect-error spy on non-public class method + const testSpy = jest.spyOn(driver, 'injectScreenshottingErrorHeader'); + + const result = await driver.screenshot({ + elementPosition: { + boundingClientRect: { top: 200, left: 10, height: 10, width: 100 }, + scroll: { x: 100, y: 300 }, + }, + layout: new PreserveLayout({} as Size), + error: new Error(`Here's the fake error!`), + }); + + expect(testSpy.mock.lastCall).toMatchInlineSnapshot(` + Array [ + [Error: Here's the fake error!], + "[data-shared-items-container]", + ] + `); + expect(result).toEqual(Buffer.from(`you won't believe this one weird screenshot`, 'base64')); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts index 325a734edd741..c3e6f75d6d511 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts @@ -6,6 +6,7 @@ */ import type { Logger } from '@kbn/core/server'; +import { loggerMock } from '@kbn/logging-mocks'; import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server'; import * as puppeteer from 'puppeteer'; import * as Rx from 'rxjs'; @@ -24,22 +25,18 @@ describe('HeadlessChromiumDriverFactory', () => { }, }, } as ConfigType; - let logger: jest.Mocked; - let screenshotMode: jest.Mocked; + let logger: Logger; + let screenshotMode: ScreenshotModePluginSetup; let factory: HeadlessChromiumDriverFactory; - let mockBrowser: jest.Mocked; + let mockBrowser: puppeteer.Browser; beforeEach(async () => { - logger = { - debug: jest.fn(), - error: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - get: jest.fn(() => logger), - } as unknown as typeof logger; - screenshotMode = {} as unknown as typeof screenshotMode; + logger = loggerMock.create(); + + screenshotMode = {} as unknown as ScreenshotModePluginSetup; let pageClosed = false; + mockBrowser = { newPage: jest.fn().mockResolvedValue({ target: jest.fn(() => ({ @@ -57,9 +54,8 @@ describe('HeadlessChromiumDriverFactory', () => { pageClosed = true; }), process: jest.fn(), - } as unknown as jest.Mocked; - - (puppeteer as jest.Mocked).launch.mockResolvedValue(mockBrowser); + } as unknown as puppeteer.Browser; + jest.spyOn(puppeteer, 'launch').mockResolvedValue(mockBrowser); factory = new HeadlessChromiumDriverFactory(screenshotMode, config, logger, path, ''); jest.spyOn(factory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY); @@ -84,9 +80,8 @@ describe('HeadlessChromiumDriverFactory', () => { }); it('rejects if Puppeteer launch fails', async () => { - (puppeteer as jest.Mocked).launch.mockRejectedValue( - `Puppeteer Launch mock fail.` - ); + jest.spyOn(puppeteer, 'launch').mockRejectedValue(`Puppeteer Launch mock fail.`); + expect(() => factory .createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT }) @@ -99,9 +94,8 @@ describe('HeadlessChromiumDriverFactory', () => { describe('close behaviour', () => { it('does not allow close to be called on the browse more than once', async () => { - await factory - .createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT }) - .pipe( + await Rx.firstValueFrom( + factory.createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT }).pipe( take(1), mergeMap(async ({ close }) => { expect(mockBrowser.close).not.toHaveBeenCalled(); @@ -110,7 +104,7 @@ describe('HeadlessChromiumDriverFactory', () => { expect(mockBrowser.close).toHaveBeenCalledTimes(1); }) ) - .toPromise(); + ); // Check again, after the observable completes expect(mockBrowser.close).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index f8dd839dfc55c..b1a0d98fe8a27 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -5,48 +5,18 @@ * 2.0. */ -import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import type { HttpServiceSetup, KibanaRequest, Logger, PackageInfo } from '@kbn/core/server'; +import type { KibanaRequest } from '@kbn/core/server'; import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common'; import type { Optional } from '@kbn/utility-types'; -import { Semaphore } from '@kbn/std'; -import ipaddr from 'ipaddr.js'; -import { defaultsDeep, sum } from 'lodash'; -import { from, Observable, of, throwError } from 'rxjs'; -import { - catchError, - concatMap, - first, - map, - mergeMap, - take, - takeUntil, - tap, - toArray, -} from 'rxjs/operators'; -import { - errors, - LayoutParams, - SCREENSHOTTING_APP_ID, - SCREENSHOTTING_EXPRESSION, - SCREENSHOTTING_EXPRESSION_INPUT, -} from '../../common'; -import { HeadlessChromiumDriverFactory, PerformanceMetrics } from '../browsers'; -import { systemHasInsufficientMemory } from '../cloud'; -import type { ConfigType } from '../config'; -import { durationToNumber } from '../config'; +import { LayoutParams } from '../../common'; +import { PerformanceMetrics } from '../browsers'; import { PdfScreenshotOptions, PdfScreenshotResult, PngScreenshotOptions, PngScreenshotResult, - toPdf, - toPng, } from '../formats'; -import { createLayout, Layout } from '../layouts'; -import { EventLogger, Transactions } from './event_logger'; import type { ScreenshotObservableOptions, ScreenshotObservableResult } from './observable'; -import { ScreenshotObservableHandler, UrlOrUrlWithContext } from './observable'; export type { ScreenshotObservableResult, UrlOrUrlWithContext } from './observable'; @@ -55,17 +25,14 @@ export interface CaptureOptions extends Optional { - const { browserTimezone } = options; - - return this.browserDriverFactory - .createPage( - { - browserTimezone, - openUrlTimeout: durationToNumber(this.config.capture.timeouts.openUrl), - defaultViewport: { width: layout.width, deviceScaleFactor: layout.getBrowserZoom() }, - }, - this.logger - ) - .pipe( - this.semaphore.acquire(), - mergeMap(({ driver, error$, close }) => { - const screen: ScreenshotObservableHandler = new ScreenshotObservableHandler( - driver, - this.config, - eventLogger, - layout, - options - ); - - return from(options.urls).pipe( - concatMap((url, index) => - screen.setupPage(index, url).pipe( - catchError((error) => { - screen.checkPageIsOpen(); // this fails the job if the browser has closed - - this.logger.error(error); - eventLogger.error(error, Transactions.SCREENSHOTTING); - return of({ ...DEFAULT_SETUP_RESULT, error }); // allow "as-is" screenshot with injected warning message - }), - takeUntil(error$), - screen.getScreenshots() - ) - ), - take(options.urls.length), - toArray(), - mergeMap((results) => - // At this point we no longer need the page, close it and send out the results - close().pipe(map(({ metrics }) => ({ metrics, results }))) - ) - ); - }), - first() - ); - } - - private getScreenshottingAppUrl() { - const info = this.http.getServerInfo(); - const { protocol, port } = info; - let { hostname } = info; - - if (ipaddr.isValid(hostname) && !sum(ipaddr.parse(hostname).toByteArray())) { - hostname = 'localhost'; - } - - return `${protocol}://${hostname}:${port}${this.http.basePath.serverBasePath}/app/${SCREENSHOTTING_APP_ID}`; - } - - private getCaptureOptions({ - expression, - input, - request, - ...options - }: ScreenshotOptions): ScreenshotObservableOptions { - const headers = { ...(request?.headers ?? {}), ...(options.headers ?? {}) }; - const urls = expression - ? [ - [ - this.getScreenshottingAppUrl(), - { - [SCREENSHOTTING_EXPRESSION]: expression, - [SCREENSHOTTING_EXPRESSION_INPUT]: input, - }, - ] as UrlOrUrlWithContext, - ] - : options.urls; - - return defaultsDeep( - { - ...options, - headers, - urls, - }, - { - timeouts: { - openUrl: 60000, - waitForElements: 60000, - renderComplete: 120000, - }, - urls: [], - } - ); - } - - systemHasInsufficientMemory(): boolean { - return systemHasInsufficientMemory(this.cloud, this.logger.get('cloud')); - } - - getScreenshots(options: PngScreenshotOptions): Observable; - getScreenshots(options: PdfScreenshotOptions): Observable; - getScreenshots(options: ScreenshotOptions): Observable; - getScreenshots(options: ScreenshotOptions): Observable { - if (this.systemHasInsufficientMemory()) { - return throwError(() => new errors.InsufficientMemoryAvailableOnCloudError()); - } - - const eventLogger = new EventLogger(this.logger, this.config); - const transactionEnd = eventLogger.startTransaction(Transactions.SCREENSHOTTING); - - const layout = createLayout(options.layout ?? {}); - const captureOptions = this.getCaptureOptions(options); - - return this.captureScreenshots(eventLogger, layout, captureOptions).pipe( - tap(({ results, metrics }) => { - transactionEnd({ - labels: { - cpu: metrics?.cpu, - memory: metrics?.memory, - memory_mb: metrics?.memoryInMegabytes, - ...eventLogger.getByteLengthFromCaptureResults(results), - }, - }); - }), - mergeMap((result) => { - switch (options.format) { - case 'pdf': - return toPdf(eventLogger, this.packageInfo, layout, options, result); - default: - return toPng(result); - } - }) - ); - } -} +export { Screenshots } from './screenshots'; diff --git a/x-pack/plugins/screenshotting/server/screenshots/screenshots.test.ts b/x-pack/plugins/screenshotting/server/screenshots/screenshots.test.ts new file mode 100644 index 0000000000000..d5e8757803c81 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/screenshots.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { CloudSetup } from '@kbn/cloud-plugin/server'; +import type { HttpServiceSetup } from '@kbn/core-http-server'; +import type { PackageInfo } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server'; +import puppeteer from 'puppeteer'; +import * as Rx from 'rxjs'; +import { firstValueFrom } from 'rxjs'; +import type { PngScreenshotOptions } from '..'; +import { HeadlessChromiumDriverFactory } from '../browsers'; +import type { ConfigType } from '../config'; +import { Screenshots } from './screenshots'; + +jest.mock('puppeteer'); + +describe('class Screenshots', () => { + let mockConfig: ConfigType; + let browserDriverFactory: HeadlessChromiumDriverFactory; + let mockPackageInfo: PackageInfo; + let mockHttpSetup: HttpServiceSetup; + let mockCloudSetup: CloudSetup; + let mockLogger: Logger; + let mockScreenshotModeSetup: ScreenshotModePluginSetup; + + const mockBinaryPath = '/kibana/x-pack/plugins/screenshotting/chromium/linux/headless_shell'; + const mockBasePath = '/kibanaTest1'; + + beforeEach(() => { + mockLogger = loggerMock.create(); + + mockConfig = { + networkPolicy: { + enabled: false, + rules: [], + }, + browser: { + autoDownload: false, + chromium: { proxy: { enabled: false } }, + }, + capture: { + timeouts: { + openUrl: 60000, + waitForElements: 60000, + renderComplete: 60000, + }, + zoom: 2, + }, + poolSize: 1, + }; + + mockScreenshotModeSetup = {} as unknown as ScreenshotModePluginSetup; + + browserDriverFactory = new HeadlessChromiumDriverFactory( + mockScreenshotModeSetup, + mockConfig, + mockLogger, + mockBinaryPath, + mockBasePath + ); + + mockCloudSetup = { isCloudEnabled: true, instanceSizeMb: 8000 } as unknown as CloudSetup; + }); + + const getScreenshotsInstance = () => + new Screenshots( + browserDriverFactory, + mockLogger, + mockPackageInfo, + mockHttpSetup, + mockConfig, + mockCloudSetup + ); + + it('detects sufficient memory from cloud plugin', () => { + const screenshotsInstance = getScreenshotsInstance(); + const hasInsufficient = screenshotsInstance.systemHasInsufficientMemory(); + expect(hasInsufficient).toBe(false); + }); + + it('detects insufficient memory from cloud plugin', () => { + mockCloudSetup = { isCloudEnabled: true, instanceSizeMb: 1000 } as unknown as CloudSetup; + const screenshotsInstance = getScreenshotsInstance(); + const hasInsufficient = screenshotsInstance.systemHasInsufficientMemory(); + expect(hasInsufficient).toBe(true); + }); + + it('ignores insufficient memory if cloud is not enabled', () => { + mockCloudSetup = { isCloudEnabled: false, instanceSizeMb: 1000 } as unknown as CloudSetup; + const screenshotsInstance = getScreenshotsInstance(); + const hasInsufficient = screenshotsInstance.systemHasInsufficientMemory(); + expect(hasInsufficient).toBe(false); + }); + + describe('getScreenshots', () => { + beforeAll(() => { + jest.mock('puppeteer'); // see __mocks__/puppeteer.ts + }); + + beforeEach(() => { + jest.spyOn(browserDriverFactory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY); + jest.spyOn(browserDriverFactory, 'getProcessLogger').mockReturnValue(Rx.EMPTY); + jest.spyOn(browserDriverFactory, 'getPageExit').mockReturnValue(Rx.EMPTY); + }); + + it('getScreenshots with PngScreenshotOptions', async () => { + const screenshotsInstance = getScreenshotsInstance(); + + const options: PngScreenshotOptions = { + format: 'png', + layout: { id: 'preserve_layout' }, + urls: ['/app/home/test'], + }; + + const observe = screenshotsInstance.getScreenshots(options); + await firstValueFrom(observe).then((captureResult) => { + expect(captureResult.results[0].screenshots[0].data).toEqual( + Buffer.from(`you won't believe this one weird screenshot`, 'base64') + ); + expect(captureResult.results[0].renderErrors).toBe(undefined); + expect(captureResult.results[0].error).toBe(undefined); + }); + }); + + it('adds warning to the screenshot in case of openUrl timeout', async () => { + // @ts-expect-error should not assign new value to read-only property + mockConfig.capture.timeouts.openUrl = 10; // must be a small amount of milliseconds + + // mock override + const browser = await puppeteer.launch(); + const page = await browser.newPage(); // should be stubPage + const pageGotoSpy = jest.spyOn(page, 'goto'); + pageGotoSpy.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(resolve, 100); // must be larger than 10 + }) + ); + + const screenshotsInstance = getScreenshotsInstance(); + + const options: PngScreenshotOptions = { + format: 'png', + layout: { id: 'preserve_layout' }, + urls: ['/app/home/test'], + }; + + const observe = screenshotsInstance.getScreenshots(options); + await firstValueFrom(observe).then((captureResult) => { + expect(captureResult.results[0].error).toEqual( + new Error( + `Screenshotting encountered a timeout error: "open URL" took longer than 0.01 seconds.` + + ` You may need to increase "xpack.screenshotting.capture.timeouts.openUrl" in kibana.yml.` + ) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/screenshots/screenshots.ts b/x-pack/plugins/screenshotting/server/screenshots/screenshots.ts new file mode 100644 index 0000000000000..64f6ecdb5264a --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/screenshots.ts @@ -0,0 +1,208 @@ +/* + * 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 { CloudSetup } from '@kbn/cloud-plugin/server'; +import type { HttpServiceSetup, Logger, PackageInfo } from '@kbn/core/server'; +import { Semaphore } from '@kbn/std'; +import ipaddr from 'ipaddr.js'; +import { defaultsDeep, sum } from 'lodash'; +import { from, Observable, of, throwError } from 'rxjs'; +import { + catchError, + concatMap, + first, + map, + mergeMap, + take, + takeUntil, + tap, + toArray, +} from 'rxjs/operators'; +import { CaptureResult, ScreenshotOptions, ScreenshotResult } from '.'; +import { + errors, + SCREENSHOTTING_APP_ID, + SCREENSHOTTING_EXPRESSION, + SCREENSHOTTING_EXPRESSION_INPUT, +} from '../../common'; +import { HeadlessChromiumDriverFactory } from '../browsers'; +import { systemHasInsufficientMemory } from '../cloud'; +import type { ConfigType } from '../config'; +import { durationToNumber } from '../config'; +import { + PdfScreenshotOptions, + PdfScreenshotResult, + PngScreenshotOptions, + PngScreenshotResult, + toPdf, + toPng, +} from '../formats'; +import { createLayout, Layout } from '../layouts'; +import { EventLogger, Transactions } from './event_logger'; +import type { ScreenshotObservableOptions } from './observable'; +import { ScreenshotObservableHandler, UrlOrUrlWithContext } from './observable'; + +const DEFAULT_SETUP_RESULT = { + elementsPositionAndAttributes: null, + timeRange: null, +}; + +export class Screenshots { + private semaphore: Semaphore; + + constructor( + private readonly browserDriverFactory: HeadlessChromiumDriverFactory, + private readonly logger: Logger, + private readonly packageInfo: PackageInfo, + private readonly http: HttpServiceSetup, + private readonly config: ConfigType, + private readonly cloud?: CloudSetup + ) { + this.semaphore = new Semaphore(config.poolSize); + } + + private captureScreenshots( + eventLogger: EventLogger, + layout: Layout, + options: ScreenshotObservableOptions + ): Observable { + const { browserTimezone } = options; + + return this.browserDriverFactory + .createPage( + { + browserTimezone, + openUrlTimeout: durationToNumber(this.config.capture.timeouts.openUrl), + defaultViewport: { width: layout.width, deviceScaleFactor: layout.getBrowserZoom() }, + }, + this.logger + ) + .pipe( + this.semaphore.acquire(), + mergeMap(({ driver, error$, close }) => { + const screen: ScreenshotObservableHandler = new ScreenshotObservableHandler( + driver, + this.config, + eventLogger, + layout, + options + ); + + return from(options.urls).pipe( + concatMap((url, index) => + screen.setupPage(index, url).pipe( + catchError((error) => { + screen.checkPageIsOpen(); // this fails the job if the browser has closed + + this.logger.error(error); + eventLogger.error(error, Transactions.SCREENSHOTTING); + return of({ ...DEFAULT_SETUP_RESULT, error }); // allow "as-is" screenshot with injected warning message + }), + takeUntil(error$), + screen.getScreenshots() + ) + ), + take(options.urls.length), + toArray(), + mergeMap((results) => + // At this point we no longer need the page, close it and send out the results + close().pipe(map(({ metrics }) => ({ metrics, results }))) + ) + ); + }), + first() + ); + } + + private getScreenshottingAppUrl() { + const info = this.http.getServerInfo(); + const { protocol, port } = info; + let { hostname } = info; + + if (ipaddr.isValid(hostname) && !sum(ipaddr.parse(hostname).toByteArray())) { + hostname = 'localhost'; + } + + return `${protocol}://${hostname}:${port}${this.http.basePath.serverBasePath}/app/${SCREENSHOTTING_APP_ID}`; + } + + private getCaptureOptions({ + expression, + input, + request, + ...options + }: ScreenshotOptions): ScreenshotObservableOptions { + const headers = { ...(request?.headers ?? {}), ...(options.headers ?? {}) }; + const urls = expression + ? [ + [ + this.getScreenshottingAppUrl(), + { + [SCREENSHOTTING_EXPRESSION]: expression, + [SCREENSHOTTING_EXPRESSION_INPUT]: input, + }, + ] as UrlOrUrlWithContext, + ] + : options.urls; + + return defaultsDeep( + { + ...options, + headers, + urls, + }, + { + timeouts: { + openUrl: 60000, + waitForElements: 60000, + renderComplete: 120000, + }, + urls: [], + } + ); + } + + systemHasInsufficientMemory(): boolean { + return systemHasInsufficientMemory(this.cloud, this.logger.get('cloud')); + } + + getScreenshots(options: PngScreenshotOptions): Observable; + getScreenshots(options: PdfScreenshotOptions): Observable; + getScreenshots(options: ScreenshotOptions): Observable; + getScreenshots(options: ScreenshotOptions): Observable { + if (this.systemHasInsufficientMemory()) { + return throwError(() => new errors.InsufficientMemoryAvailableOnCloudError()); + } + + const eventLogger = new EventLogger(this.logger, this.config); + const transactionEnd = eventLogger.startTransaction(Transactions.SCREENSHOTTING); + + const layout = createLayout(options.layout ?? {}); + const captureOptions = this.getCaptureOptions(options); + + return this.captureScreenshots(eventLogger, layout, captureOptions).pipe( + tap(({ results, metrics }) => { + transactionEnd({ + labels: { + cpu: metrics?.cpu, + memory: metrics?.memory, + memory_mb: metrics?.memoryInMegabytes, + ...eventLogger.getByteLengthFromCaptureResults(results), + }, + }); + }), + mergeMap((result) => { + switch (options.format) { + case 'pdf': + return toPdf(eventLogger, this.packageInfo, layout, options, result); + default: + return toPng(result); + } + }) + ); + } +} diff --git a/x-pack/plugins/screenshotting/tsconfig.json b/x-pack/plugins/screenshotting/tsconfig.json index 9110ea16661c2..3749a60fc4fe4 100644 --- a/x-pack/plugins/screenshotting/tsconfig.json +++ b/x-pack/plugins/screenshotting/tsconfig.json @@ -23,6 +23,8 @@ "@kbn/utils", "@kbn/safer-lodash-set", "@kbn/core-logging-server-mocks", + "@kbn/logging-mocks", + "@kbn/core-http-server", ], "exclude": [ "target/**/*", diff --git a/x-pack/test/reporting_functional/reporting_and_timeout.config.ts b/x-pack/test/reporting_functional/reporting_and_timeout.config.ts deleted file mode 100644 index 05e77ea70cd6f..0000000000000 --- a/x-pack/test/reporting_functional/reporting_and_timeout.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrConfigProviderContext } from '@kbn/test'; -import { resolve } from 'path'; - -export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const functionalConfig = await readConfigFile(require.resolve('./reporting_and_security.config')); - - return { - ...functionalConfig.getAll(), - junit: { reportName: 'X-Pack Reporting Functional Tests: Reports and Timeout Handling' }, - testFiles: [resolve(__dirname, './reporting_and_timeout')], - kbnTestServer: { - ...functionalConfig.get('kbnTestServer'), - serverArgs: [ - ...functionalConfig.get('kbnTestServer.serverArgs'), - `--xpack.reporting.capture.timeouts.waitForElements=1s`, - ], - }, - }; -} diff --git a/x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png b/x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png deleted file mode 100644 index e86939f7ff741ef93185ebefa86052b49c908f85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95272 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 diff --git a/x-pack/test/reporting_functional/reporting_and_timeout/index.ts b/x-pack/test/reporting_functional/reporting_and_timeout/index.ts deleted file mode 100644 index c420c76d77304..0000000000000 --- a/x-pack/test/reporting_functional/reporting_and_timeout/index.ts +++ /dev/null @@ -1,98 +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 expect from '@kbn/expect'; -import fs from 'fs'; -import path from 'path'; -import { PNG } from 'pngjs'; -import { FtrProviderContext } from '../ftr_provider_context'; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'reporting', 'dashboard']); - const log = getService('log'); - const reportingAPI = getService('reportingAPI'); - const reportingFunctional = getService('reportingFunctional'); - const browser = getService('browser'); - const png = getService('png'); - const config = getService('config'); - const screenshotDir = config.get('screenshots.directory'); - - // FLAKY: https://github.com/elastic/kibana/issues/135309 - describe.skip('Reporting Functional Tests with forced timeout', function () { - const dashboardTitle = 'Ecom Dashboard Hidden Panel Titles'; - const sessionPngFullPage = 'warnings_capture_session_a'; - const sessionPngCropped = 'warnings_capture_session_b'; - const baselinePng = path.resolve(__dirname, 'fixtures/baseline/warnings_capture_b.png'); - - let url: string; - before(async () => { - await reportingAPI.logTaskManagerHealth(); - await reportingFunctional.initEcommerce(); - - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.loadSavedDashboard(dashboardTitle); - await PageObjects.reporting.setTimepickerInEcommerceDataRange(); - await browser.setWindowSize(800, 850); - - await PageObjects.reporting.openPngReportingPanel(); - await PageObjects.reporting.clickGenerateReportButton(); - - url = await PageObjects.reporting.getReportURL(60000); - - const res = await PageObjects.reporting.getResponse(url); - expect(res.status).to.equal(200); - expect(res.get('content-type')).to.equal('image/png'); - }); - - after(async () => { - await reportingFunctional.teardownEcommerce(); - }); - - it('adds a visual warning in the report output', async () => { - const captureData = await PageObjects.reporting.getRawPdfReportData(url); - const sessionReport = await PageObjects.reporting.writeSessionReport( - sessionPngFullPage, - 'png', - captureData, - screenshotDir - ); - - const region = { height: 320, width: 1540, srcX: 20, srcY: 10 }; - const dstPath = path.resolve(screenshotDir, sessionPngCropped + '.png'); - const dst = new PNG({ width: region.width, height: region.height }); - - const pngSessionFilePath = await new Promise((resolve) => { - fs.createReadStream(sessionReport) - .pipe(new PNG()) - .on('parsed', function () { - log.info(`cropping report to the visual warning area`); - this.bitblt(dst, region.srcX, region.srcY, region.width, region.height, 0, 0); - dst.pack().pipe(fs.createWriteStream(dstPath)); - resolve(dstPath); - }); - }); - - log.info(`saved cropped file to ${dstPath}`); - - expect( - await png.checkIfPngsMatch(pngSessionFilePath, baselinePng, screenshotDir) - ).to.be.lessThan(0.09); - - /** - * This test may fail when styling differences affect the result. To update the snapshot: - * - * 1. Run the functional test, to generate new temporary files for screenshot comparison. - * 2. Save the screenshot as the new baseline file: - * cp \ - * x-pack/test/functional/screenshots/session/warnings_capture_session_b_actual.png \ - * x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png - * 3. Commit the changes to the .png file - */ - }); - }); -} From a5182764281d8b8e968ae10a6d72e7a316e84792 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Tue, 22 Aug 2023 10:42:20 -0600 Subject: [PATCH 19/26] Change `i18n.translate()` to return `string` type instead of leaving it up to Typescript parser (#164464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary While researching why we had so many `ANY`s in our "Any counts by team" quality report, I noticed that all of the `i18n.translate` in our `uiSettings` we being reported as `any` even though Typescript says that function returns a `string`. When I explicitly define the return value, our `any` count drops from `46` to `2`. This is the command I ran for the report: `node scripts/build_api_docs --plugin observability --stats any` ### Before ``` ┌─────────┬─────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ (index) │ id │ link │ ├─────────┼─────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 0 │ 'def-server.getInspectResponse.$1.esResponse' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/common/utils/get_inspect_response.ts#:~:text=esResponse' │ │ 1 │ 'def-server.uiSettings.enableInspectEsQueries.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 2 │ 'def-server.uiSettings.enableInspectEsQueries.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 3 │ 'def-server.uiSettings.maxSuggestions.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 4 │ 'def-server.uiSettings.maxSuggestions.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 5 │ 'def-server.uiSettings.enableComparisonByDefault.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 6 │ 'def-server.uiSettings.enableComparisonByDefault.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 7 │ 'def-server.uiSettings.defaultApmServiceEnvironment.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 8 │ 'def-server.uiSettings.defaultApmServiceEnvironment.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 9 │ 'def-server.uiSettings.apmProgressiveLoading.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 10 │ 'def-server.uiSettings.apmProgressiveLoading.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 11 │ 'def-server.uiSettings.apmProgressiveLoading.optionLabels.ProgressiveLoadingQuality.off' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=%5BProgressiveLoadingQuality.off%5D' │ │ 12 │ 'def-server.uiSettings.apmProgressiveLoading.optionLabels.ProgressiveLoadingQuality.low' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=%5BProgressiveLoadingQuality.low%5D' │ │ 13 │ 'def-server.uiSettings.apmProgressiveLoading.optionLabels.ProgressiveLoadingQuality.medium' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=%5BProgressiveLoadingQuality.medium%5D' │ │ 14 │ 'def-server.uiSettings.apmProgressiveLoading.optionLabels.ProgressiveLoadingQuality.high' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=%5BProgressiveLoadingQuality.high%5D' │ │ 15 │ 'def-server.uiSettings.apmServiceInventoryOptimizedSorting.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 16 │ 'def-server.uiSettings.apmServiceInventoryOptimizedSorting.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 17 │ 'def-server.uiSettings.apmServiceGroupMaxNumberOfServices.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 18 │ 'def-server.uiSettings.apmServiceGroupMaxNumberOfServices.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 19 │ 'def-server.uiSettings.apmTraceExplorerTab.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 20 │ 'def-server.uiSettings.apmTraceExplorerTab.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 21 │ 'def-server.uiSettings.apmLabsButton.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 22 │ 'def-server.uiSettings.apmLabsButton.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 23 │ 'def-server.uiSettings.enableInfrastructureHostsView.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 24 │ 'def-server.uiSettings.enableInfrastructureHostsView.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 25 │ 'def-server.uiSettings.enableAwsLambdaMetrics.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 26 │ 'def-server.uiSettings.enableAwsLambdaMetrics.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 27 │ 'def-server.uiSettings.enableAgentExplorerView.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 28 │ 'def-server.uiSettings.enableAgentExplorerView.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 29 │ 'def-server.uiSettings.apmAWSLambdaPriceFactor.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 30 │ 'def-server.uiSettings.apmAWSLambdaPriceFactor.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 31 │ 'def-server.uiSettings.apmAWSLambdaRequestCostPerMillion.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 32 │ 'def-server.uiSettings.apmEnableServiceMetrics.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 33 │ 'def-server.uiSettings.apmEnableServiceMetrics.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 34 │ 'def-server.uiSettings.apmEnableContinuousRollups.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 35 │ 'def-server.uiSettings.apmEnableContinuousRollups.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 36 │ 'def-server.uiSettings.enableCriticalPath.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 37 │ 'def-server.uiSettings.enableCriticalPath.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 38 │ 'def-server.uiSettings.syntheticsThrottlingEnabled.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 39 │ 'def-server.uiSettings.syntheticsThrottlingEnabled.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 40 │ 'def-server.uiSettings.enableLegacyUptimeApp.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 41 │ 'def-server.uiSettings.enableLegacyUptimeApp.description' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=description' │ │ 42 │ 'def-server.uiSettings.apmEnableProfilingIntegration.name' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/server/ui_settings.ts#:~:text=name' │ │ 43 │ 'def-common.AsPercent.$3' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/common/utils/formatters/formatters.ts#:~:text=fallbackResult' │ │ 44 │ 'def-common.asPercent.$3' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/common/utils/formatters/formatters.ts#:~:text=fallbackResult' │ │ 45 │ 'def-common.getInspectResponse.$1.esResponse' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/common/utils/get_inspect_response.ts#:~:text=esResponse' │ └─────────┴─────────────────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ``` ### After ``` ┌─────────┬───────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ (index) │ id │ link │ ├─────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 0 │ 'def-server.getInspectResponse.$1.esResponse' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/common/utils/get_inspect_response.ts#:~:text=esResponse' │ │ 1 │ 'def-common.getInspectResponse.$1.esResponse' │ 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability/common/utils/get_inspect_response.ts#:~:text=esResponse' │ └─────────┴───────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ``` --- packages/kbn-i18n/src/core/i18n.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-i18n/src/core/i18n.ts b/packages/kbn-i18n/src/core/i18n.ts index cde5506383dd6..1fa0ceb997410 100644 --- a/packages/kbn-i18n/src/core/i18n.ts +++ b/packages/kbn-i18n/src/core/i18n.ts @@ -165,7 +165,7 @@ export interface TranslateArguments { * @param [options.values] - values to pass into translation * @param [options.defaultMessage] - will be used unless translation was successful */ -export function translate(id: string, { values = {}, defaultMessage }: TranslateArguments) { +export function translate(id: string, { values = {}, defaultMessage }: TranslateArguments): string { const shouldUsePseudoLocale = isPseudoLocale(currentLocale); if (!id || !isString(id)) { From 8a331e04051238b4abc838d29eafce302734ce13 Mon Sep 17 00:00:00 2001 From: Rachel Shen Date: Tue, 22 Aug 2023 10:43:21 -0600 Subject: [PATCH 20/26] [Reporting] Implement successful csv download in serverless (#164320) ## Summary PR https://github.com/elastic/kibana/pull/163796 provided the `?elasticInternalOrigin=true` parameter to permit internal routes such as is the case with csv download in serverless. This PR implements this parameter in the url for csv downloads in the places that csv downloads can occur (mainly the toast download report success button and the reporting management page) ### 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 --- .../reporting_api_client.test.ts | 2 +- .../reporting_api_client/reporting_api_client.ts | 15 ++++----------- .../reporting/public/lib/stream_handler.test.ts | 2 +- x-pack/plugins/reporting/tsconfig.json | 1 + 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.test.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.test.ts index 38e95e9d75323..8737c80e969f8 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.test.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.test.ts @@ -27,7 +27,7 @@ describe('ReportingAPIClient', () => { describe('getReportURL', () => { it('should generate the internal report download URL', () => { expect(apiClient.getReportURL('123')).toMatchInlineSnapshot( - `"/base/path/internal/reporting/jobs/download/123"` + `"/base/path/internal/reporting/jobs/download/123?elasticInternalOrigin=true"` ); }); }); diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 2681789745751..7e30866474991 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ELASTIC_INTERNAL_ORIGIN_QUERY_PARAM } from '@kbn/core-http-common'; import type { HttpFetchQuery } from '@kbn/core/public'; import { HttpSetup, IUiSettingsClient } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; @@ -17,13 +18,7 @@ import { PUBLIC_ROUTES, REPORTING_MANAGEMENT_HOME, } from '../../../common/constants'; -import { - BaseParams, - DownloadReportFn, - JobId, - ManagementLinkFn, - ReportApiJSON, -} from '../../../common/types'; +import { BaseParams, JobId, ManagementLinkFn, ReportApiJSON } from '../../../common/types'; import { add } from '../../notifier/job_completion_notifications'; import { Job } from '../job'; @@ -58,7 +53,6 @@ interface IReportingAPI { // Function props getManagementLink: ManagementLinkFn; - getDownloadLink: DownloadReportFn; // Diagnostic-related API calls verifyBrowser(): Promise; @@ -98,7 +92,7 @@ export class ReportingAPIClient implements IReportingAPI { */ public getReportURL(jobId: string) { const downloadLink = this.http.basePath.prepend( - `${INTERNAL_ROUTES.JOBS.DOWNLOAD_PREFIX}/${jobId}` + `${INTERNAL_ROUTES.JOBS.DOWNLOAD_PREFIX}/${jobId}?${ELASTIC_INTERNAL_ORIGIN_QUERY_PARAM}=true` ); return downloadLink; @@ -218,8 +212,7 @@ export class ReportingAPIClient implements IReportingAPI { public getManagementLink: ManagementLinkFn = () => this.http.basePath.prepend(REPORTING_MANAGEMENT_HOME); - public getDownloadLink: DownloadReportFn = (jobId: JobId) => - this.http.basePath.prepend(`${INTERNAL_ROUTES.JOBS.DOWNLOAD_PREFIX}/${jobId}`); + public getDownloadLink = (jobId: JobId) => this.getReportURL(jobId); public getServerBasePath = () => this.http.basePath.serverBasePath; diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 6f575652450c1..ed3b973a95e64 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -34,7 +34,7 @@ jobQueueClientMock.getInfo = () => Promise.resolve({ content: 'this is the completed report data' } as unknown as Job); jobQueueClientMock.getError = () => Promise.resolve('this is the failed report error'); jobQueueClientMock.getManagementLink = () => '/#management'; -jobQueueClientMock.getDownloadLink = () => '/reporting/download/job-123'; +jobQueueClientMock.getReportURL = () => '/reporting/download/job-123'; const mockShowDanger = stub(); const mockShowSuccess = stub(); diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json index 05e62e6251e19..f307442001655 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/reporting-common", "@kbn/saved-search-plugin", "@kbn/core-http-router-server-internal", + "@kbn/core-http-common", ], "exclude": [ "target/**/*", From db4ffd0059f66d67133bcffbbeaf76c96a8ba4a6 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 22 Aug 2023 17:43:37 +0100 Subject: [PATCH 21/26] [SecuritySolution] Hide loading icon when the user has no permission to read Security dashboard (#164439) ## Summary Original issue and **Steps to reproduce**: https://github.com/elastic/kibana/issues/164405 **Before** - User without SecuritySolution read permission saw the loading icon spinning. Screenshot 2023-08-22 at 11 10 38 **After** - User without SecuritySolution read permission cannot see the dashboard list. Screenshot 2023-08-22 at 13 27 24 ### 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 --- .../dashboards/pages/landing_page/index.test.tsx | 10 ++++++++++ .../public/dashboards/pages/landing_page/index.tsx | 9 ++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx index 426549123ef10..41479adb42623 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx @@ -157,6 +157,16 @@ describe('Dashboards landing', () => { expect(screen.queryByTestId('dashboardsTable')).not.toBeInTheDocument(); }); + it('should not render loading icon if no read capability', async () => { + mockUseCapabilities.mockReturnValue({ + ...DEFAULT_DASHBOARD_CAPABILITIES, + show: false, + }); + await renderDashboardLanding(); + + expect(screen.queryByTestId('dashboardLoadingIcon')).not.toBeInTheDocument(); + }); + describe('Create Security Dashboard button', () => { it('should render', async () => { await renderDashboardLanding(); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx index a843533400cf7..e72e38429f35d 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx @@ -132,7 +132,7 @@ export const DashboardsLandingPage = () => { /> - {canReadDashboard && securityTagsExist && initialFilter ? ( + {canReadDashboard && securityTagsExist && initialFilter && ( <> @@ -148,8 +148,11 @@ export const DashboardsLandingPage = () => { urlStateEnabled={false} /> - ) : ( - } /> + )} + {canReadDashboard && !securityTagsExist && ( + } + /> )} From 433d4f21c39fa7c364e3275443f673360dda5d18 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 22 Aug 2023 18:55:17 +0200 Subject: [PATCH 22/26] [Fleet] Fix index patterns being recreated in default space, fix migration version setting (#164456) ## Summary Closes https://github.com/elastic/kibana/issues/164243 Added a `typeMigrationVersion: '8.0.0'` to index patterns until https://github.com/elastic/kibana/issues/164454 is fixed. Related to https://github.com/elastic/kibana/pull/161969 Feedback from kibana core team, we should always set the migration version fields, even if `migrationVersion` is not set. It would be good to backport this to 8.9 too. How to test locally: - create a test space - install system integration in test space - restart kibana - verify that the index patterns `logs-*`, `metrics-*` are still in test space in Data Views UI ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../server/services/epm/kibana/assets/install.ts | 13 ++++++------- .../services/epm/kibana/index_pattern/install.ts | 2 ++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index ec0cbab539bcd..fdeb75480ea79 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -101,13 +101,12 @@ export function createSavedObjectKibanaAsset(asset: ArchiveAsset): SavedObjectTo if (asset.migrationVersion) { so.migrationVersion = asset.migrationVersion; - } else { - if (asset.coreMigrationVersion) { - so.coreMigrationVersion = asset.coreMigrationVersion; - } - if (asset.typeMigrationVersion) { - so.typeMigrationVersion = asset.typeMigrationVersion; - } + } + if (asset.coreMigrationVersion) { + so.coreMigrationVersion = asset.coreMigrationVersion; + } + if (asset.typeMigrationVersion) { + so.typeMigrationVersion = asset.typeMigrationVersion; } return so as SavedObjectToBe; } diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/index_pattern/install.ts index 1637bf19d8df4..91e603e80aee2 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/index_pattern/install.ts @@ -18,6 +18,8 @@ export function getIndexPatternSavedObjects() { return indexPatternTypes.map((indexPatternType) => ({ id: `${indexPatternType}-*`, type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + // workaround until https://github.com/elastic/kibana/issues/164454 is fixed + typeMigrationVersion: '8.0.0', attributes: { title: `${indexPatternType}-*`, timeFieldName: '@timestamp', From cd8a94ba3203022a7791e85a8dd8a6c16ae381ab Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Tue, 22 Aug 2023 18:05:15 +0100 Subject: [PATCH 23/26] [Security Selection][Detection engine] fixes rule preview performance issues (#164207) ## Summary On every single keyboard type on query input, the whole preview part was re-rendering. If there are too many alerts, it could take ~150ms-200ms, for its re-render. Ultimately, making form almost unusable So, in this PR I'm adding memoization for 2 Preview components, to resolve this issue ### Before Screenshot 2023-08-17 at 17 13 07 ### After Screenshot 2023-08-17 at 17 07 56 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../components/rules/rule_preview/index.tsx | 4 ++-- .../rules/rule_preview/preview_histogram.tsx | 5 ++++- .../components/rules/rule_preview/preview_logs.tsx | 11 +++++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx index 55931e74ff9d4..c4d50062675d2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx @@ -25,7 +25,7 @@ import { isEqual } from 'lodash'; import * as i18n from './translations'; import { usePreviewRoute } from './use_preview_route'; import { PreviewHistogram } from './preview_histogram'; -import { PreviewLogsComponent } from './preview_logs'; +import { PreviewLogs } from './preview_logs'; import { useKibana } from '../../../../common/lib/kibana'; import { LoadingHistogram } from './loading_histogram'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; @@ -274,7 +274,7 @@ const RulePreviewComponent: React.FC = ({ timeframeOptions={previewData.timeframeOptions} /> )} - + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx index 89ed3aaf97b91..24f6b82383a13 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx @@ -69,7 +69,7 @@ interface PreviewHistogramProps { const DEFAULT_HISTOGRAM_HEIGHT = 300; -export const PreviewHistogram = ({ +const PreviewHistogramComponent = ({ previewId, addNoiseWarning, spaceId, @@ -262,3 +262,6 @@ export const PreviewHistogram = ({ ); }; + +export const PreviewHistogram = React.memo(PreviewHistogramComponent); +PreviewHistogram.displayName = 'PreviewHistogram'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx index b6f9135c8b99f..1c01a0491e92c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_logs.tsx @@ -10,7 +10,7 @@ import { EuiCallOut, EuiText, EuiSpacer, EuiAccordion } from '@elastic/eui'; import type { RulePreviewLogs } from '../../../../../common/api/detection_engine'; import * as i18n from './translations'; -interface PreviewLogsComponentProps { +interface PreviewLogsProps { logs: RulePreviewLogs[]; hasNoiseWarning: boolean; isAborted: boolean; @@ -42,11 +42,7 @@ const addLogs = ( allLogs: SortedLogs[] ) => (logs.length ? [{ startedAt, logs, duration }, ...allLogs] : allLogs); -export const PreviewLogsComponent: React.FC = ({ - logs, - hasNoiseWarning, - isAborted, -}) => { +const PreviewLogsComponent: React.FC = ({ logs, hasNoiseWarning, isAborted }) => { const sortedLogs = useMemo( () => logs.reduce<{ @@ -73,6 +69,9 @@ export const PreviewLogsComponent: React.FC = ({ ); }; +export const PreviewLogs = React.memo(PreviewLogsComponent); +PreviewLogs.displayName = 'PreviewLogs'; + const LogAccordion: React.FC = ({ logs, isError, children }) => { const firstLog = logs[0]; if (!(children || firstLog)) return null; From 6cf859887d8ec943f0cf7a08d4bded5874295053 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 22 Aug 2023 10:27:35 -0700 Subject: [PATCH 24/26] [OAS] Add Webhook connector configuration details (#163899) --- .../connector-apis-passthru.asciidoc | 41 +++++- .../plugins/actions/docs/openapi/bundled.json | 124 +++++++++++++++++- .../plugins/actions/docs/openapi/bundled.yaml | 104 ++++++++++++++- .../create_webhook_connector_request.yaml | 13 ++ .../create_webhook_connector_response.yaml | 17 +++ .../schemas/config_properties_webhook.yaml | 54 +++++++- .../schemas/secrets_properties_webhook.yaml | 22 +++- .../s@{spaceid}@api@actions@connector.yaml | 4 + 8 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/actions/docs/openapi/components/examples/create_webhook_connector_request.yaml create mode 100644 x-pack/plugins/actions/docs/openapi/components/examples/create_webhook_connector_response.yaml diff --git a/docs/api-generated/connectors/connector-apis-passthru.asciidoc b/docs/api-generated/connectors/connector-apis-passthru.asciidoc index 64bf78a2b66ed..08a7d8d9638b0 100644 --- a/docs/api-generated/connectors/connector-apis-passthru.asciidoc +++ b/docs/api-generated/connectors/connector-apis-passthru.asciidoc @@ -1010,6 +1010,7 @@ Any modifications made to this file will be overwritten.
  • config_properties_servicenow - Connector request properties for a ServiceNow ITSM connector
  • config_properties_servicenow_itom - Connector request properties for a ServiceNow ITSM connector
  • config_properties_swimlane - Connector request properties for a Swimlane connector
  • +
  • config_properties_webhook - Connector request properties for a Webhook connector
  • connector_response_properties - Connector response properties
  • connector_response_properties_cases_webhook - Connector request properties for a Webhook - Case Management connector
  • connector_response_properties_email - Connector response properties for an email connector
  • @@ -1093,6 +1094,7 @@ Any modifications made to this file will be overwritten.
  • secrets_properties_slack_api - Connector secrets properties for a Web API Slack connector
  • secrets_properties_slack_webhook - Connector secrets properties for a Webhook Slack connector
  • secrets_properties_swimlane - Connector secrets properties for a Swimlane connector
  • +
  • secrets_properties_webhook - Connector secrets properties for a Webhook connector
  • updateConnector_400_response -
  • update_connector_request_cases_webhook - Update Webhook - Case Managment connector request
  • update_connector_request_index - Update index connector request
  • @@ -1450,6 +1452,28 @@ Any modifications made to this file will be overwritten.
    mappings (optional)